diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e46d30f1a..31fffc4b7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,7 +16,7 @@ _Was anything unclear during your work on this PR? Anything we should definitely **On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:** -**For frontend changes: Tested on mobile? Please answer below:** +**For frontend changes: Tested on mobile, light and dark mode? Please answer below:** **Did you introduce any new environment variables? If so, call them out explicitly here:** diff --git a/api/paidAction/README.md b/api/paidAction/README.md index 9373042ff..13661b56f 100644 --- a/api/paidAction/README.md +++ b/api/paidAction/README.md @@ -103,6 +103,7 @@ stateDiagram-v2 | donations | x | | x | x | x | | | | update posts | x | | x | | x | | x | | update comments | x | | x | | x | | x | +| receive | | x | | x | x | x | x | ## Not-custodial zaps (ie p2p wrapped payments) Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap. @@ -139,8 +140,14 @@ Each paid action is implemented in its own file in the `paidAction` directory. E ### Boolean flags - `anonable`: can be performed anonymously -- `supportsPessimism`: supports a pessimistic payment flow -- `supportsOptimism`: supports an optimistic payment flow + +### Payment methods +- `paymentMethods`: an array of payment methods that the action supports ordered from most preferred to least preferred + - P2P: a p2p payment made directly from the client to the recipient + - after wrapping the invoice, anonymous users will follow a PESSIMISTIC flow to pay the invoice and logged in users will follow an OPTIMISTIC flow + - FEE_CREDIT: a payment made from the user's fee credit balance + - OPTIMISTIC: an optimistic payment flow + - PESSIMISTIC: a pessimistic payment flow ### Functions diff --git a/api/paidAction/boost.js b/api/paidAction/boost.js index 8fa92e464..af96b4c83 100644 --- a/api/paidAction/boost.js +++ b/api/paidAction/boost.js @@ -1,8 +1,12 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { msatsToSats, satsToMsats } from '@/lib/format' export const anonable = false -export const supportsPessimism = false -export const supportsOptimism = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC +] export async function getCost ({ sats }) { return satsToMsats(sats) diff --git a/api/paidAction/buyCredits.js b/api/paidAction/buyCredits.js deleted file mode 100644 index 85651de20..000000000 --- a/api/paidAction/buyCredits.js +++ /dev/null @@ -1,26 +0,0 @@ -// XXX we don't use this yet ... -// it's just showing that even buying credits -// can eventually be a paid action - -import { USER_ID } from '@/lib/constants' -import { satsToMsats } from '@/lib/format' - -export const anonable = false -export const supportsPessimism = false -export const supportsOptimism = true - -export async function getCost ({ amount }) { - return satsToMsats(amount) -} - -export async function onPaid ({ invoice }, { tx }) { - return await tx.users.update({ - where: { id: invoice.userId }, - data: { balance: { increment: invoice.msatsReceived } } - }) -} - -export async function describe ({ amount }, { models, me }) { - const user = await models.user.findUnique({ where: { id: me?.id ?? USER_ID.anon } }) - return `SN: buying credits for @${user.name}` -} diff --git a/api/paidAction/donate.js b/api/paidAction/donate.js index 11321d0ae..e8bcfbbb5 100644 --- a/api/paidAction/donate.js +++ b/api/paidAction/donate.js @@ -1,9 +1,12 @@ -import { USER_ID } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { satsToMsats } from '@/lib/format' export const anonable = true -export const supportsPessimism = true -export const supportsOptimism = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] export async function getCost ({ sats }) { return satsToMsats(sats) diff --git a/api/paidAction/downZap.js b/api/paidAction/downZap.js index 0942af3a5..4266fbfa6 100644 --- a/api/paidAction/downZap.js +++ b/api/paidAction/downZap.js @@ -1,8 +1,12 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { msatsToSats, satsToMsats } from '@/lib/format' export const anonable = false -export const supportsPessimism = false -export const supportsOptimism = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC +] export async function getCost ({ sats }) { return satsToMsats(sats) @@ -74,6 +78,6 @@ export async function onFail ({ invoice }, { tx }) { await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) } -export async function describe ({ itemId, sats }, { cost, actionId }) { +export async function describe ({ id: itemId, sats }, { cost, actionId }) { return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}` } diff --git a/api/paidAction/index.js b/api/paidAction/index.js index b4cec7d39..9397a50c5 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -1,8 +1,11 @@ import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' import { datePivot } from '@/lib/time' -import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { createHmac } from '@/api/resolvers/wallet' import { Prisma } from '@prisma/client' +import { createWrappedInvoice } from '@/wallets/server' +import { assertBelowMaxPendingInvoices } from './lib/assert' + import * as ITEM_CREATE from './itemCreate' import * as ITEM_UPDATE from './itemUpdate' import * as ZAP from './zap' @@ -14,7 +17,7 @@ import * as TERRITORY_BILLING from './territoryBilling' import * as TERRITORY_UNARCHIVE from './territoryUnarchive' import * as DONATE from './donate' import * as BOOST from './boost' -import { createWrappedInvoice } from 'wallets/server' +import * as RECEIVE from './receive' export const paidActions = { ITEM_CREATE, @@ -27,12 +30,13 @@ export const paidActions = { TERRITORY_UPDATE, TERRITORY_BILLING, TERRITORY_UNARCHIVE, - DONATE + DONATE, + RECEIVE } -export default async function performPaidAction (actionType, args, context) { +export default async function performPaidAction (actionType, args, incomingContext) { try { - const { me, models, forceFeeCredits } = context + const { me, models, forcePaymentMethod } = incomingContext const paidAction = paidActions[actionType] console.group('performPaidAction', actionType, args) @@ -41,52 +45,70 @@ export default async function performPaidAction (actionType, args, context) { throw new Error(`Invalid action type ${actionType}`) } - context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined - context.cost = await paidAction.getCost(args, context) - context.sybilFeePercent = await paidAction.getSybilFeePercent?.(args, context) + if (!me && !paidAction.anonable) { + throw new Error('You must be logged in to perform this action') + } - if (!me) { - if (!paidAction.anonable) { - throw new Error('You must be logged in to perform this action') - } + // treat context as immutable + const contextWithMe = { + ...incomingContext, + me: me ? await models.user.findUnique({ where: { id: me.id } }) : undefined + } + const context = { + ...contextWithMe, + cost: await paidAction.getCost(args, contextWithMe) + } - if (context.cost > 0) { - console.log('we are anon so can only perform pessimistic action that require payment') - return await performPessimisticAction(actionType, args, context) - } + // special case for zero cost actions + if (context.cost === 0n) { + console.log('performing zero cost action') + return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod: 'ZERO_COST' }) } - const isRich = context.cost <= (context.me?.msats ?? 0) - if (isRich) { - try { - console.log('enough fee credits available, performing fee credit action') - return await performFeeCreditAction(actionType, args, context) - } catch (e) { - console.error('fee credit action failed', e) + for (const paymentMethod of paidAction.paymentMethods) { + console.log(`considering payment method ${paymentMethod}`) + + if (forcePaymentMethod && + paymentMethod !== forcePaymentMethod) { + console.log('skipping payment method', paymentMethod, 'because forcePaymentMethod is set to', forcePaymentMethod) + continue + } - // if we fail with fee credits, but not because of insufficient funds, bail - if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { + // payment methods that anonymous users can use + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) { + try { + return await performP2PAction(actionType, args, context) + } catch (e) { + if (e instanceof NonInvoiceablePeerError) { + console.log('peer cannot be invoiced, skipping') + continue + } + console.error(`${paymentMethod} action failed`, e) throw e } + } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) { + return await beginPessimisticAction(actionType, args, context) } - } - - // this is set if the worker executes a paid action in behalf of a user. - // in that case, only payment via fee credits is possible - // since there is no client to which we could send an invoice. - // example: automated territory billing - if (forceFeeCredits) { - throw new Error('forceFeeCredits is set, but user does not have enough fee credits') - } - // if we fail to do the action with fee credits, we should fall back to optimistic - if (paidAction.supportsOptimism) { - console.log('performing optimistic action') - return await performOptimisticAction(actionType, args, context) + // additionalpayment methods that logged in users can use + if (me) { + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) { + try { + return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod }) + } catch (e) { + // if we fail with fee credits or reward sats, but not because of insufficient funds, bail + console.error(`${paymentMethod} action failed`, e) + if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { + throw e + } + } + } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) { + return await performOptimisticAction(actionType, args, context) + } + } } - console.error('action does not support optimism and fee credits failed, performing pessimistic action') - return await performPessimisticAction(actionType, args, context) + throw new Error('No working payment method found') } catch (e) { console.error('performPaidAction failed', e) throw e @@ -95,50 +117,48 @@ export default async function performPaidAction (actionType, args, context) { } } -async function performFeeCreditAction (actionType, args, context) { - const { me, models, cost } = context +async function performNoInvoiceAction (actionType, args, incomingContext) { + const { me, models, cost, paymentMethod } = incomingContext const action = paidActions[actionType] const result = await models.$transaction(async tx => { - context.tx = tx + const context = { ...incomingContext, tx } - await tx.user.update({ - where: { - id: me?.id ?? USER_ID.anon - }, - data: { - msats: { - decrement: cost - } - } - }) + if (paymentMethod === 'FEE_CREDIT') { + await tx.user.update({ + where: { + id: me?.id ?? USER_ID.anon + }, + data: { msats: { decrement: cost } } + }) + } const result = await action.perform(args, context) await action.onPaid?.(result, context) return { result, - paymentMethod: 'FEE_CREDIT' + paymentMethod } }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) // run non critical side effects in the background // after the transaction has been committed - action.nonCriticalSideEffects?.(result.result, context).catch(console.error) + action.nonCriticalSideEffects?.(result.result, incomingContext).catch(console.error) return result } -async function performOptimisticAction (actionType, args, context) { - const { models } = context +async function performOptimisticAction (actionType, args, incomingContext) { + const { models, invoiceArgs: incomingInvoiceArgs } = incomingContext const action = paidActions[actionType] - context.optimistic = true - const invoiceArgs = await createLightningInvoice(actionType, args, context) + const optimisticContext = { ...incomingContext, optimistic: true } + const invoiceArgs = incomingInvoiceArgs ?? await createSNInvoice(actionType, args, optimisticContext) return await models.$transaction(async tx => { - context.tx = tx + const context = { ...optimisticContext, tx, invoiceArgs } - const invoice = await createDbInvoice(actionType, args, context, invoiceArgs) + const invoice = await createDbInvoice(actionType, args, context) return { invoice, @@ -148,23 +168,67 @@ async function performOptimisticAction (actionType, args, context) { }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) } -async function performPessimisticAction (actionType, args, context) { +async function beginPessimisticAction (actionType, args, context) { const action = paidActions[actionType] - if (!action.supportsPessimism) { + if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC)) { throw new Error(`This action ${actionType} does not support pessimistic invoicing`) } // just create the invoice and complete action when it's paid - const invoiceArgs = await createLightningInvoice(actionType, args, context) + const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context) return { - invoice: await createDbInvoice(actionType, args, context, invoiceArgs), + invoice: await createDbInvoice(actionType, args, { ...context, invoiceArgs }), paymentMethod: 'PESSIMISTIC' } } -export async function retryPaidAction (actionType, args, context) { - const { models, me } = context +async function performP2PAction (actionType, args, incomingContext) { + // if the action has an invoiceable peer, we'll create a peer invoice + // wrap it, and return the wrapped invoice + const { cost, models, lnd, me } = incomingContext + const sybilFeePercent = await paidActions[actionType].getSybilFeePercent?.(args, incomingContext) + if (!sybilFeePercent) { + throw new Error('sybil fee percent is not set for an invoiceable peer action') + } + + const contextWithSybilFeePercent = { + ...incomingContext, + sybilFeePercent + } + + const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, contextWithSybilFeePercent) + if (!userId) { + throw new NonInvoiceablePeerError() + } + + await assertBelowMaxPendingInvoices(contextWithSybilFeePercent) + + const description = await paidActions[actionType].describe(args, contextWithSybilFeePercent) + const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, { + msats: cost, + feePercent: sybilFeePercent, + description, + expiry: INVOICE_EXPIRE_SECS + }, { models, me, lnd }) + + const context = { + ...contextWithSybilFeePercent, + invoiceArgs: { + bolt11: invoice, + wrappedBolt11: wrappedInvoice, + wallet, + maxFee + } + } + + return me + ? await performOptimisticAction(actionType, args, context) + : await beginPessimisticAction(actionType, args, context) +} + +export async function retryPaidAction (actionType, args, incomingContext) { + const { models, me } = incomingContext const { invoice: failedInvoice } = args console.log('retryPaidAction', actionType, args) @@ -178,7 +242,7 @@ export async function retryPaidAction (actionType, args, context) { throw new Error(`retryPaidAction - must be logged in ${actionType}`) } - if (!action.supportsOptimism) { + if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) { throw new Error(`retryPaidAction - action does not support optimism ${actionType}`) } @@ -190,16 +254,19 @@ export async function retryPaidAction (actionType, args, context) { throw new Error(`retryPaidAction - missing invoice ${actionType}`) } - context.optimistic = true - context.me = await models.user.findUnique({ where: { id: me.id } }) - const { msatsRequested, actionId } = failedInvoice - context.cost = BigInt(msatsRequested) - context.actionId = actionId - const invoiceArgs = await createSNInvoice(actionType, args, context) + const retryContext = { + ...incomingContext, + optimistic: true, + me: await models.user.findUnique({ where: { id: me.id } }), + cost: BigInt(msatsRequested), + actionId + } + + const invoiceArgs = await createSNInvoice(actionType, args, retryContext) return await models.$transaction(async tx => { - context.tx = tx + const context = { ...retryContext, tx, invoiceArgs } // update the old invoice to RETRYING, so that it's not confused with FAILED await tx.invoice.update({ @@ -213,7 +280,7 @@ export async function retryPaidAction (actionType, args, context) { }) // create a new invoice - const invoice = await createDbInvoice(actionType, args, context, invoiceArgs) + const invoice = await createDbInvoice(actionType, args, context) return { result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context), @@ -224,57 +291,12 @@ export async function retryPaidAction (actionType, args, context) { } const INVOICE_EXPIRE_SECS = 600 -const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 - -export async function createLightningInvoice (actionType, args, context) { - // if the action has an invoiceable peer, we'll create a peer invoice - // wrap it, and return the wrapped invoice - const { cost, models, lnd, sybilFeePercent, me } = context - - // count pending invoices and bail if we're over the limit - const pendingInvoices = await models.invoice.count({ - where: { - userId: me?.id ?? USER_ID.anon, - actionState: { - // not in a terminal state. Note: null isn't counted by prisma - notIn: PAID_ACTION_TERMINAL_STATES - } - } - }) - - console.log('pending paid actions', pendingInvoices) - if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) { - throw new Error('You have too many pending paid actions, cancel some or wait for them to expire') - } - - const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, context) - if (userId) { - try { - if (!sybilFeePercent) { - throw new Error('sybil fee percent is not set for an invoiceable peer action') - } - const description = await paidActions[actionType].describe(args, context) - - const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, { - msats: cost, - feePercent: sybilFeePercent, - description, - expiry: INVOICE_EXPIRE_SECS - }, { models, me, lnd }) - - return { - bolt11: invoice, - wrappedBolt11: wrappedInvoice, - wallet, - maxFee - } - } catch (e) { - console.error('failed to create stacker invoice, falling back to SN invoice', e) - } +export class NonInvoiceablePeerError extends Error { + constructor () { + super('non invoiceable peer') + this.name = 'NonInvoiceablePeerError' } - - return await createSNInvoice(actionType, args, context) } // we seperate the invoice creation into two functions because @@ -284,6 +306,8 @@ async function createSNInvoice (actionType, args, context) { const action = paidActions[actionType] const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice + await assertBelowMaxPendingInvoices(context) + if (cost < 1000n) { // sanity check throw new Error('The cost of the action must be at least 1 sat') @@ -299,9 +323,10 @@ async function createSNInvoice (actionType, args, context) { return { bolt11: invoice.request, preimage: invoice.secret } } -async function createDbInvoice (actionType, args, context, - { bolt11, wrappedBolt11, preimage, wallet, maxFee }) { - const { me, models, tx, cost, optimistic, actionId } = context +async function createDbInvoice (actionType, args, context) { + const { me, models, tx, cost, optimistic, actionId, invoiceArgs } = context + const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs + const db = tx ?? models if (cost < 1000n) { diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js index ab11f78a0..4b2b8bb9e 100644 --- a/api/paidAction/itemCreate.js +++ b/api/paidAction/itemCreate.js @@ -1,11 +1,15 @@ -import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, USER_ID } from '@/lib/constants' +import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush' import { getItemMentions, getMentions, performBotBehavior } from './lib/item' import { msatsToSats, satsToMsats } from '@/lib/format' export const anonable = true -export const supportsPessimism = true -export const supportsOptimism = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) { const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } }) diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index d3e2c8fb9..b27968ce2 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -1,12 +1,15 @@ -import { USER_ID } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { uploadFees } from '../resolvers/upload' import { getItemMentions, getMentions, performBotBehavior } from './lib/item' import { notifyItemMention, notifyMention } from '@/lib/webPush' import { satsToMsats } from '@/lib/format' export const anonable = true -export const supportsPessimism = true -export const supportsOptimism = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }) { // the only reason updating items costs anything is when it has new uploads diff --git a/api/paidAction/lib/assert.js b/api/paidAction/lib/assert.js new file mode 100644 index 000000000..c0e0d634b --- /dev/null +++ b/api/paidAction/lib/assert.js @@ -0,0 +1,65 @@ +import { BALANCE_LIMIT_MSATS, PAID_ACTION_TERMINAL_STATES, USER_ID, SN_ADMIN_IDS } from '@/lib/constants' +import { msatsToSats, numWithUnits } from '@/lib/format' + +const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 +const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad] + +export async function assertBelowMaxPendingInvoices (context) { + const { models, me } = context + const pendingInvoices = await models.invoice.count({ + where: { + userId: me?.id ?? USER_ID.anon, + actionState: { + notIn: PAID_ACTION_TERMINAL_STATES + } + } + }) + + if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) { + throw new Error('You have too many pending paid actions, cancel some or wait for them to expire') + } +} + +export async function assertBelowBalanceLimit (context) { + const { me, tx } = context + if (!me || USER_IDS_BALANCE_NO_LIMIT.includes(me.id)) return + + // we need to prevent this invoice (and any other pending invoices and withdrawls) + // from causing the user's balance to exceed the balance limit + const pendingInvoices = await tx.invoice.aggregate({ + where: { + userId: me.id, + // p2p invoices are never in state PENDING + actionState: 'PENDING', + actionType: 'RECEIVE' + }, + _sum: { + msatsRequested: true + } + }) + + // Get pending withdrawals total + const pendingWithdrawals = await tx.withdrawl.aggregate({ + where: { + userId: me.id, + status: null + }, + _sum: { + msatsPaying: true, + msatsFeePaying: true + } + }) + + // Calculate total pending amount + const pendingMsats = (pendingInvoices._sum.msatsRequested ?? 0n) + + ((pendingWithdrawals._sum.msatsPaying ?? 0n) + (pendingWithdrawals._sum.msatsFeePaying ?? 0n)) + + // Check balance limit + if (pendingMsats + me.msats > BALANCE_LIMIT_MSATS) { + throw new Error( + `pending invoices and withdrawals must not cause balance to exceed ${ + numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS)) + }` + ) + } +} diff --git a/api/paidAction/pollVote.js b/api/paidAction/pollVote.js index c5a79005c..c63ecef2e 100644 --- a/api/paidAction/pollVote.js +++ b/api/paidAction/pollVote.js @@ -1,8 +1,12 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { satsToMsats } from '@/lib/format' export const anonable = false -export const supportsPessimism = true -export const supportsOptimism = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC +] export async function getCost ({ id }, { me, models }) { const pollOption = await models.pollOption.findUnique({ diff --git a/api/paidAction/receive.js b/api/paidAction/receive.js new file mode 100644 index 000000000..f44e2bca8 --- /dev/null +++ b/api/paidAction/receive.js @@ -0,0 +1,84 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { toPositiveBigInt } from '@/lib/validate' +import { notifyDeposit } from '@/lib/webPush' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' +import { getInvoiceableWallets } from '@/wallets/server' +import { assertBelowBalanceLimit } from './lib/assert' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.P2P, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC +] + +export async function getCost ({ msats }) { + return toPositiveBigInt(msats) +} + +export async function getInvoiceablePeer (_, { me, models, cost }) { + if (!me?.proxyReceive) return null + const wallets = await getInvoiceableWallets(me.id, { models }) + + // if the user has any invoiceable wallets and this action will result in their balance + // being greater than their desired threshold + if (wallets.length > 0 && (cost + me.msats) > satsToMsats(me.autoWithdrawThreshold)) { + return me.id + } + + return null +} + +export async function getSybilFeePercent () { + return 10n +} + +export async function perform ({ + invoiceId, + comment, + lud18Data +}, { me, tx }) { + const invoice = await tx.invoice.update({ + where: { id: invoiceId }, + data: { + comment, + lud18Data + }, + include: { invoiceForward: true } + }) + + if (!invoice.invoiceForward) { + // if the invoice is not p2p, assert that the user's balance limit is not exceeded + await assertBelowBalanceLimit({ me, tx }) + } +} + +export async function describe ({ description }, { me, cost, sybilFeePercent }) { + const fee = sybilFeePercent ? cost * BigInt(sybilFeePercent) / 100n : 0n + return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}` +} + +export async function onPaid ({ invoice }, { tx }) { + if (!invoice) { + throw new Error('invoice is required') + } + + // P2P lnurlp does not need to update the user's balance + if (invoice?.invoiceForward) return + + await tx.user.update({ + where: { id: invoice.userId }, + data: { + msats: { + increment: invoice.msatsReceived + } + } + }) +} + +export async function nonCriticalSideEffects ({ invoice }, { models }) { + await notifyDeposit(invoice.userId, invoice) + await models.$executeRaw` + INSERT INTO pgboss.job (name, data) + VALUES ('nip57', jsonb_build_object('hash', ${invoice.hash}))` +} diff --git a/api/paidAction/territoryBilling.js b/api/paidAction/territoryBilling.js index 7fcff694c..3f5d6fede 100644 --- a/api/paidAction/territoryBilling.js +++ b/api/paidAction/territoryBilling.js @@ -1,10 +1,13 @@ -import { TERRITORY_PERIOD_COST } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' import { satsToMsats } from '@/lib/format' import { nextBilling } from '@/lib/territory' export const anonable = false -export const supportsPessimism = true -export const supportsOptimism = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] export async function getCost ({ name }, { models }) { const sub = await models.sub.findUnique({ diff --git a/api/paidAction/territoryCreate.js b/api/paidAction/territoryCreate.js index 5525febe4..3cb4bb8e5 100644 --- a/api/paidAction/territoryCreate.js +++ b/api/paidAction/territoryCreate.js @@ -1,9 +1,13 @@ -import { TERRITORY_PERIOD_COST } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' import { satsToMsats } from '@/lib/format' import { nextBilling } from '@/lib/territory' + export const anonable = false -export const supportsPessimism = true -export const supportsOptimism = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] export async function getCost ({ billingType }) { return satsToMsats(TERRITORY_PERIOD_COST(billingType)) diff --git a/api/paidAction/territoryUnarchive.js b/api/paidAction/territoryUnarchive.js index 84743ae39..70f03931c 100644 --- a/api/paidAction/territoryUnarchive.js +++ b/api/paidAction/territoryUnarchive.js @@ -1,10 +1,13 @@ -import { TERRITORY_PERIOD_COST } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' import { satsToMsats } from '@/lib/format' import { nextBilling } from '@/lib/territory' export const anonable = false -export const supportsPessimism = true -export const supportsOptimism = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] export async function getCost ({ billingType }) { return satsToMsats(TERRITORY_PERIOD_COST(billingType)) diff --git a/api/paidAction/territoryUpdate.js b/api/paidAction/territoryUpdate.js index 8535b4c70..54bdc42bf 100644 --- a/api/paidAction/territoryUpdate.js +++ b/api/paidAction/territoryUpdate.js @@ -1,11 +1,14 @@ -import { TERRITORY_PERIOD_COST } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' import { satsToMsats } from '@/lib/format' import { proratedBillingCost } from '@/lib/territory' import { datePivot } from '@/lib/time' export const anonable = false -export const supportsPessimism = true -export const supportsOptimism = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] export async function getCost ({ oldName, billingType }, { models }) { const oldSub = await models.sub.findUnique({ diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index eeafa7f7d..51ac29b06 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -1,10 +1,16 @@ -import { USER_ID } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { msatsToSats, satsToMsats } from '@/lib/format' import { notifyZapped } from '@/lib/webPush' +import { getInvoiceableWallets } from '@/wallets/server' export const anonable = true -export const supportsPessimism = true -export const supportsOptimism = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.P2P, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] export async function getCost ({ sats }) { return satsToMsats(sats) @@ -13,18 +19,13 @@ export async function getCost ({ sats }) { export async function getInvoiceablePeer ({ id }, { models }) { const item = await models.item.findUnique({ where: { id: parseInt(id) }, - include: { - itemForwards: true, - user: { - include: { - wallets: true - } - } - } + include: { itemForwards: true } }) + const wallets = await getInvoiceableWallets(item.userId, { models }) + // request peer invoice if they have an attached wallet and have not forwarded the item - return item.user.wallets.length > 0 && item.itemForwards.length === 0 ? item.userId : null + return wallets.length > 0 && item.itemForwards.length === 0 ? item.userId : null } export async function getSybilFeePercent () { diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 11a61ad69..97c48d52c 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -535,6 +535,7 @@ export default { '"Item".bio = false', ad ? `"Item".id <> ${ad.id}` : '', activeOrMine(me), + await filterClause(me, models, type), subClause(sub, 3, 'Item', me, showNsfw), muteClause(me))} ORDER BY rank DESC @@ -1032,6 +1033,7 @@ export default { }, ItemAct: { invoice: async (itemAct, args, { models }) => { + // we never want to fetch the sensitive data full monty in nested resolvers if (itemAct.invoiceId) { return { id: itemAct.invoiceId, @@ -1281,6 +1283,7 @@ export default { return root }, invoice: async (item, args, { models }) => { + // we never want to fetch the sensitive data full monty in nested resolvers if (item.invoiceId) { return { id: item.invoiceId, diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index a934f64f0..a7af37bc5 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -217,14 +217,20 @@ export default { if (meFull.noteDeposits) { queries.push( - `(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats", + `(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", + FLOOR("Invoice"."msatsReceived" / 1000) as "earnedSats", 'InvoicePaid' AS type FROM "Invoice" WHERE "Invoice"."userId" = $1 - AND "confirmedAt" IS NOT NULL - AND "isHeld" IS NULL - AND "actionState" IS NULL - AND created_at < $2 + AND "Invoice"."confirmedAt" IS NOT NULL + AND "Invoice"."created_at" < $2 + AND ( + ("Invoice"."isHeld" IS NULL AND "Invoice"."actionType" IS NULL) + OR ( + "Invoice"."actionType" = 'RECEIVE' + AND "Invoice"."actionState" = 'PAID' + ) + ) ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) @@ -232,12 +238,17 @@ export default { if (meFull.noteWithdrawals) { queries.push( - `(SELECT "Withdrawl".id::text, "Withdrawl".created_at AS "sortTime", FLOOR("msatsPaid" / 1000) as "earnedSats", + `(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime", + FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats", 'WithdrawlPaid' AS type FROM "Withdrawl" + LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id + LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id WHERE "Withdrawl"."userId" = $1 - AND status = 'CONFIRMED' - AND created_at < $2 + AND "Withdrawl".status = 'CONFIRMED' + AND "Withdrawl".created_at < $2 + AND ("InvoiceForward"."id" IS NULL OR "Invoice"."actionType" = 'ZAP') + GROUP BY "Withdrawl".id ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js index 233279038..3fc20c4e5 100644 --- a/api/resolvers/paidAction.js +++ b/api/resolvers/paidAction.js @@ -19,6 +19,8 @@ function paidActionType (actionType) { return 'DonatePaidAction' case 'POLL_VOTE': return 'PollVotePaidAction' + case 'RECEIVE': + return 'ReceivePaidAction' default: throw new Error('Unknown action type') } diff --git a/api/resolvers/user.js b/api/resolvers/user.js index cd518e70d..2ebcb4a52 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -421,8 +421,16 @@ export default { confirmedAt: { gt: lastChecked }, - isHeld: null, - actionType: null + OR: [ + { + isHeld: null, + actionType: null + }, + { + actionType: 'RECEIVE', + actionState: 'PAID' + } + ] } }) if (invoice) { @@ -438,7 +446,23 @@ export default { status: 'CONFIRMED', updatedAt: { gt: lastChecked - } + }, + OR: [ + { + invoiceForward: { + none: {} + } + }, + { + invoiceForward: { + some: { + invoice: { + actionType: 'ZAP' + } + } + } + } + ] } }) if (wdrwl) { @@ -922,7 +946,8 @@ export default { createdAt: { gte, lte - } + }, + OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }] } }) }, @@ -939,7 +964,8 @@ export default { createdAt: { gte, lte - } + }, + OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }] } }) }, @@ -956,7 +982,8 @@ export default { createdAt: { gte, lte - } + }, + OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }] } }) }, diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 875d86392..79765993f 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,5 +1,5 @@ import { - createHodlInvoice, createInvoice, payViaPaymentRequest, + payViaPaymentRequest, getInvoice as getInvoiceFromLnd, deletePayment, getPayment, parsePaymentRequest } from 'ln-service' @@ -7,24 +7,23 @@ import crypto, { timingSafeEqual } from 'crypto' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { SELECT, itemQueryWithMeta } from './item' -import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal } from '@/lib/format' +import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format' import { - ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, - INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS + USER_ID, INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' -import { datePivot } from '@/lib/time' import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' import { bolt11Tags } from '@/lib/bolt11' -import { finalizeHodlInvoice } from 'worker/wallet' -import walletDefs from 'wallets/server' +import { finalizeHodlInvoice } from '@/worker/wallet' +import walletDefs from '@/wallets/server' import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' import validateWallet from '@/wallets/validate' import { canReceive } from '@/wallets/common' +import performPaidAction from '../paidAction' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -84,9 +83,6 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) { const inv = await models.invoice.findUnique({ where: { id: Number(id) - }, - include: { - user: true } }) @@ -94,29 +90,16 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) { throw new GqlInputError('invoice not found') } - if (inv.user.id === USER_ID.anon) { + if (inv.userId === USER_ID.anon) { return inv } if (!me) { throw new GqlAuthenticationError() } - if (inv.user.id !== me.id) { + if (inv.userId !== me.id) { throw new GqlInputError('not ur invoice') } - try { - inv.nostr = JSON.parse(inv.desc) - } catch (err) { - } - - try { - if (inv.confirmedAt) { - inv.confirmedPreimage = inv.preimage ?? (await getInvoiceFromLnd({ id: inv.hash, lnd })).secret - } - } catch (err) { - console.error('error fetching invoice from LND', err) - } - return inv } @@ -128,10 +111,6 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) { const wdrwl = await models.withdrawl.findUnique({ where: { id: Number(id) - }, - include: { - user: true, - invoiceForward: true } }) @@ -139,7 +118,7 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) { throw new GqlInputError('withdrawal not found') } - if (wdrwl.user.id !== me.id) { + if (wdrwl.userId !== me.id) { throw new GqlInputError('not ur withdrawal') } @@ -458,50 +437,15 @@ const resolvers = { __resolveType: wallet => wallet.__resolveType }, Mutation: { - createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => { + createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => { await validateSchema(amountSchema, { amount }) await assertGofacYourself({ models, headers }) - let expirePivot = { seconds: expireSecs } - let invLimit = INV_PENDING_LIMIT - let balanceLimit = (hodlInvoice || USER_IDS_BALANCE_NO_LIMIT.includes(Number(me?.id))) ? 0 : BALANCE_LIMIT_MSATS - let id = me?.id - if (!me) { - expirePivot = { seconds: Math.min(expireSecs, 180) } - invLimit = ANON_INV_PENDING_LIMIT - balanceLimit = ANON_BALANCE_LIMIT_MSATS - id = USER_ID.anon - } + const { invoice } = await performPaidAction('RECEIVE', { + msats: satsToMsats(amount) + }, { models, lnd, me }) - const user = await models.user.findUnique({ where: { id } }) - - const expiresAt = datePivot(new Date(), expirePivot) - const description = `Funding @${user.name} on stacker.news` - try { - const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({ - description: user.hideInvoiceDesc ? undefined : description, - lnd, - tokens: amount, - expires_at: expiresAt - }) - - const [inv] = await serialize( - models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.secret}::TEXT, ${invoice.request}, - ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL, - ${invLimit}::INTEGER, ${balanceLimit})`, - { models } - ) - - // the HMAC is only returned during invoice creation - // this makes sure that only the person who created this invoice - // has access to the HMAC - const hmac = createHmac(inv.hash) - - return { ...inv, hmac } - } catch (error) { - console.log(error) - throw error - } + return invoice }, createWithdrawl: createWithdrawal, sendToLnAddr, @@ -509,7 +453,7 @@ const resolvers = { verifyHmac(hash, hmac) const dbInv = await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) - if (dbInv.invoiceForward) { + if (dbInv?.invoiceForward) { const { wallet, bolt11 } = dbInv.invoiceForward const logger = walletLogger({ wallet, models }) const decoded = await parsePaymentRequest({ request: bolt11 }) @@ -596,7 +540,15 @@ const resolvers = { satsPaid: w => msatsToSats(w.msatsPaid), satsFeePaying: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaying), satsFeePaid: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaid), - p2p: w => !!w.invoiceForward?.length, + // we never want to fetch the sensitive data full monty in nested resolvers + forwardedActionType: async (withdrawl, args, { models }) => { + return (await models.invoiceForward.findFirst({ + where: { withdrawlId: Number(withdrawl.id) }, + include: { + invoice: true + } + }))?.invoice?.actionType + }, preimage: async (withdrawl, args, { lnd }) => { try { if (withdrawl.status === 'CONFIRMED') { @@ -611,6 +563,35 @@ const resolvers = { Invoice: { satsReceived: i => msatsToSats(i.msatsReceived), satsRequested: i => msatsToSats(i.msatsRequested), + // we never want to fetch the sensitive data full monty in nested resolvers + forwardedSats: async (invoice, args, { models }) => { + const msats = (await models.invoiceForward.findUnique({ + where: { invoiceId: Number(invoice.id) }, + include: { + withdrawl: true + } + }))?.withdrawl?.msatsPaid + return msats ? msatsToSats(msats) : null + }, + nostr: async (invoice, args, { models }) => { + try { + return JSON.parse(invoice.desc) + } catch (err) { + } + + return null + }, + confirmedPreimage: async (invoice, args, { lnd }) => { + try { + if (invoice.confirmedAt) { + return invoice.preimage ?? (await getInvoiceFromLnd({ id: invoice.hash, lnd })).secret + } + } catch (err) { + console.error('error fetching invoice from LND', err) + } + + return null + }, item: async (invoice, args, { models, me }) => { if (!invoice.actionId) return null switch (invoice.actionType) { @@ -675,6 +656,16 @@ const resolvers = { export default injectResolvers(resolvers) export const walletLogger = ({ wallet, models }) => { + // no-op logger if wallet is not provided + if (!wallet) { + return { + ok: () => {}, + info: () => {}, + error: () => {}, + warn: () => {} + } + } + // server implementation of wallet logger interface on client const log = (level) => async (message, context = {}) => { try { @@ -687,9 +678,12 @@ export const walletLogger = ({ wallet, models }) => { payment_hash: decoded.id, created_at: decoded.created_at, expires_at: decoded.expires_at, - description: decoded.description + description: decoded.description, + // payments should affect wallet status + status: true } } + context.recv = true await models.walletLog.create({ data: { @@ -886,6 +880,17 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model const user = await models.user.findUnique({ where: { id: me.id } }) + // check if there's an invoice with same hash that has an invoiceForward + // we can't allow this because it creates two outgoing payments from our node + // with the same hash + const selfPayment = await models.invoice.findUnique({ + where: { hash: decoded.id }, + include: { invoiceForward: true } + }) + if (selfPayment?.invoiceForward) { + throw new GqlInputError('SN cannot pay an invoice that SN is proxying') + } + const autoWithdraw = !!wallet?.id // create withdrawl transactionally (id, bolt11, amount, fee) const [withdrawl] = await serialize( diff --git a/api/typeDefs/paidAction.js b/api/typeDefs/paidAction.js index 1a6c7a59c..56dd74323 100644 --- a/api/typeDefs/paidAction.js +++ b/api/typeDefs/paidAction.js @@ -12,6 +12,7 @@ extend type Mutation { enum PaymentMethod { FEE_CREDIT + ZERO_COST OPTIMISTIC PESSIMISTIC } diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index daeadf5c3..9452a364d 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -107,6 +107,7 @@ export default gql` zapUndos: Int wildWestMode: Boolean! withdrawMaxFeeDefault: Int! + proxyReceive: Boolean } type AuthMethods { @@ -185,6 +186,7 @@ export default gql` autoWithdrawMaxFeeTotal: Int vaultKeyHash: String walletsUpdatedAt: Date + proxyReceive: Boolean } type UserOptional { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index f65498969..a7b32ad55 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -1,7 +1,7 @@ import { gql } from 'graphql-tag' import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { isServerField } from '@/wallets/common' -import walletDefs from 'wallets/server' +import walletDefs from '@/wallets/server' function injectTypeDefs (typeDefs) { const injected = [rawTypeDefs(), mutationTypeDefs()] @@ -74,7 +74,7 @@ const typeDefs = ` } extend type Mutation { - createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice! + createInvoice(amount: Int!): Invoice! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl! cancelInvoice(hash: String!, hmac: String!): Invoice! @@ -122,6 +122,7 @@ const typeDefs = ` actionError: String item: Item itemAct: ItemAct + forwardedSats: Int } type Withdrawl { @@ -135,8 +136,8 @@ const typeDefs = ` satsFeePaid: Int status: String autoWithdraw: Boolean! - p2p: Boolean! preimage: String + forwardedActionType: String } type Fact { diff --git a/components/comment.js b/components/comment.js index 62e455f3e..e59897288 100644 --- a/components/comment.js +++ b/components/comment.js @@ -149,7 +149,7 @@ export default function Comment ({ ? : item.meDontLikeSats > item.meSats ? - : pin ? : } + : pin ? : }
{item.user?.meMute && !includeParent && collapse === 'yep' diff --git a/components/dark-mode.js b/components/dark-mode.js index e540dd244..a0eb4d2e7 100644 --- a/components/dark-mode.js +++ b/components/dark-mode.js @@ -48,6 +48,15 @@ const listenForThemeChange = (onChange) => { onChange({ user: true, dark }) } } + + const root = window.document.documentElement + const observer = new window.MutationObserver(() => { + const theme = root.getAttribute('data-bs-theme') + onChange(dark => ({ ...dark, dark: theme === 'dark' })) + }) + observer.observe(root, { attributes: true, attributeFilter: ['data-bs-theme'] }) + + return () => observer.disconnect() } export default function useDarkMode () { diff --git a/components/form.js b/components/form.js index a67853f83..9864b0eac 100644 --- a/components/form.js +++ b/components/form.js @@ -131,6 +131,14 @@ export function InputSkeleton ({ label, hint }) { ) } +// fix https://github.com/stackernews/stacker.news/issues/1522 +// see https://github.com/facebook/react/issues/11488#issuecomment-558874287 +function setNativeValue (textarea, value) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(textarea, value) + textarea.dispatchEvent(new Event('input', { bubbles: true, value })) +} + export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) { const [tab, setTab] = useState('write') const [, meta, helpers] = useField(props) @@ -367,6 +375,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe let text = innerRef.current.value text = text.replace(`![Uploading ${name}…]()`, `![](${url})`) helpers.setValue(text) + setNativeValue(innerRef.current, text) const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) updateUploadFees({ variables: { s3Keys } }) setSubmitDisabled?.(false) @@ -509,6 +518,7 @@ function InputInner ({ if (storageKey) { window.localStorage.setItem(storageKey, overrideValue) } + onChange && onChange(formik, { target: { value: overrideValue } }) } else if (storageKey) { const draft = window.localStorage.getItem(storageKey) if (draft) { diff --git a/components/invoice.js b/components/invoice.js index a51d8c61c..cdc9bc2f5 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -14,6 +14,8 @@ import Item from './item' import { CommentFlat } from './comment' import classNames from 'classnames' import Moon from '@/svgs/moon-fill.svg' +import { Badge } from 'react-bootstrap' +import styles from './invoice.module.css' export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb = 'deposited', @@ -54,10 +56,27 @@ export default function Invoice ({ let variant = 'default' let status = 'waiting for you' + let sats = invoice.satsRequested + if (invoice.forwardedSats) { + if (invoice.actionType === 'RECEIVE') { + successVerb = 'forwarded' + sats = invoice.forwardedSats + } else { + successVerb = 'zapped' + } + } if (invoice.confirmedAt) { variant = 'confirmed' - status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb}` + status = ( + <> + {numWithUnits(sats, { abbreviate: false })} + {' '} + {successVerb} + {' '} + {invoice.forwardedSats && p2p} + + ) useWallet = false } else if (invoice.cancelled) { variant = 'failed' diff --git a/components/invoice.module.css b/components/invoice.module.css index e69de29bb..05a4a7fd4 100644 --- a/components/invoice.module.css +++ b/components/invoice.module.css @@ -0,0 +1,6 @@ +.badge { + color: var(--theme-grey) !important; + background: var(--theme-clickToContextColor) !important; + vertical-align: middle; + margin-left: 0.5rem; +} \ No newline at end of file diff --git a/components/layout.js b/components/layout.js index f41e6397f..e90f6ccf3 100644 --- a/components/layout.js +++ b/components/layout.js @@ -6,6 +6,7 @@ import Footer from './footer' import Seo, { SeoSearch } from './seo' import Search from './search' import styles from './layout.module.css' +import PullToRefresh from './pull-to-refresh' export default function Layout ({ sub, contain = true, footer = true, footerLinks = true, @@ -17,7 +18,7 @@ export default function Layout ({ {contain ? ( - + {children} ) diff --git a/components/log-message.js b/components/log-message.js index b15930273..73f762631 100644 --- a/components/log-message.js +++ b/components/log-message.js @@ -19,7 +19,16 @@ export default function LogMessage ({ showWallet, wallet, level, message, contex className = 'text-info' } - const hasContext = context && Object.keys(context).length > 0 + const filtered = context + ? Object.keys(context) + .filter(key => !['send', 'recv', 'status'].includes(key)) + .reduce((obj, key) => { + obj[key] = context[key] + return obj + }, {}) + : {} + + const hasContext = context && Object.keys(filtered).length > 0 const handleClick = () => { if (hasContext) { setShow(show => !show) } @@ -37,16 +46,17 @@ export default function LogMessage ({ showWallet, wallet, level, message, contex {message} {indicator} - {show && hasContext && Object.entries(context).map(([key, value], i) => { - const last = i === Object.keys(context).length - 1 - return ( - - - {key} - {value} - - ) - })} + {show && hasContext && Object.entries(filtered) + .map(([key, value], i) => { + const last = i === Object.keys(filtered).length - 1 + return ( + + + {key} + {value} + + ) + })} ) } diff --git a/components/media-or-link.js b/components/media-or-link.js index fa36842fb..5b42efe82 100644 --- a/components/media-or-link.js +++ b/components/media-or-link.js @@ -1,5 +1,5 @@ import styles from './text.module.css' -import { useState, useEffect, useMemo, useCallback, memo } from 'react' +import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react' import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url' import { useMe } from './me' import { UNKNOWN_LINK_REL } from '@/lib/constants' @@ -23,13 +23,31 @@ const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, height, onClick, onError, style, className, video }) { + const [loaded, setLoaded] = useState(!video) + const ref = useRef(null) + + const handleLoadedMedia = () => { + setLoaded(true) + } + + // events are not fired on elements during hydration + // https://github.com/facebook/react/issues/15446 + useEffect(() => { + if (ref.current) { + ref.current.src = src + } + }, [ref.current, src]) + return (
{video ?
) @@ -101,21 +122,28 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => useEffect(() => { // don't load the video at all if user doesn't want these if (!showMedia || isVideo || isImage) return - // make sure it's not a false negative by trying to load URL as - const img = new window.Image() - img.onload = () => setIsImage(true) - img.src = src + + // check if it's a video by trying to load it const video = document.createElement('video') - video.onloadeddata = () => setIsVideo(true) + video.onloadedmetadata = () => { + setIsVideo(true) + setIsImage(false) + } + video.onerror = () => { + // hack + // if it's not a video it will throw an error, so we can assume it's an image + const img = new window.Image() + img.onload = () => setIsImage(true) + img.src = src + } video.src = src return () => { - img.onload = null - img.src = '' - video.onloadeddata = null + video.onloadedmetadata = null + video.onerror = null video.src = '' } - }, [src, setIsImage, setIsVideo, showMedia, isVideo]) + }, [src, setIsImage, setIsVideo, showMedia, isImage]) const srcSet = useMemo(() => { if (Object.keys(srcSetObj).length === 0) return undefined diff --git a/components/notifications.js b/components/notifications.js index 7fb10dbe6..3567a32c9 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -326,10 +326,10 @@ function NostrZap ({ n }) { ) } -function InvoicePaid ({ n }) { +function getPayerSig (lud18Data) { let payerSig - if (n.invoice.lud18Data) { - const { name, identifier, email, pubkey } = n.invoice.lud18Data + if (lud18Data) { + const { name, identifier, email, pubkey } = lud18Data const id = identifier || email || pubkey payerSig = '- ' if (name) { @@ -339,10 +339,23 @@ function InvoicePaid ({ n }) { if (id) payerSig += id } + return payerSig +} + +function InvoicePaid ({ n }) { + const payerSig = getPayerSig(n.invoice.lud18Data) + let actionString = 'desposited to your account' + let sats = n.earnedSats + if (n.invoice.forwardedSats) { + actionString = 'sent directly to your attached wallet' + sats = n.invoice.forwardedSats + } + return (
- {numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account + {numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} {actionString} {timeSince(new Date(n.sortTime))} + {n.invoice.forwardedSats && p2p} {n.invoice.comment && {n.invoice.comment} @@ -484,13 +497,17 @@ function Invoicification ({ n: { invoice, sortTime } }) { } function WithdrawlPaid ({ n }) { + let actionString = n.withdrawl.autoWithdraw ? 'sent to your attached wallet' : 'withdrawn from your account' + if (n.withdrawl.forwardedActionType === 'ZAP') { + actionString = 'zapped directly to your attached wallet' + } return (
{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })} - {n.withdrawl.p2p || n.withdrawl.autoWithdraw ? 'sent to your attached wallet' : 'withdrawn from your account'} + {actionString} {timeSince(new Date(n.sortTime))} - {(n.withdrawl.p2p && p2p) || - (n.withdrawl.autoWithdraw && autowithdraw)} + {(n.withdrawl.forwardedActionType === 'ZAP' && p2p) || + (n.withdrawl.autoWithdraw && autowithdraw)}
) } diff --git a/components/pull-to-refresh.js b/components/pull-to-refresh.js new file mode 100644 index 000000000..9be61089f --- /dev/null +++ b/components/pull-to-refresh.js @@ -0,0 +1,81 @@ +import { useRouter } from 'next/router' +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import styles from './pull-to-refresh.module.css' + +const REFRESH_THRESHOLD = 50 + +export default function PullToRefresh ({ children, className }) { + const router = useRouter() + const [pullDistance, setPullDistance] = useState(0) + const [isPWA, setIsPWA] = useState(false) + const touchStartY = useRef(0) + const touchEndY = useRef(0) + + const checkPWA = () => { + const androidPWA = window.matchMedia('(display-mode: standalone)').matches + const iosPWA = window.navigator.standalone === true + setIsPWA(androidPWA || iosPWA) + } + + useEffect(checkPWA, []) + + const handleTouchStart = useCallback((e) => { + // don't handle if the user is not scrolling from the top of the page, is not on a PWA or if we want Android's native PTR + if (!isPWA || window.scrollY > 0) return + touchStartY.current = e.touches[0].clientY + }, [isPWA]) + + const handleTouchMove = useCallback((e) => { + if (touchStartY.current === 0) return + if (!isPWA) return + touchEndY.current = e.touches[0].clientY + const distance = touchEndY.current - touchStartY.current + setPullDistance(distance) + document.body.style.marginTop = `${Math.max(0, Math.min(distance / 2, 25))}px` + }, [isPWA]) + + const handleTouchEnd = useCallback(() => { + if (touchStartY.current === 0 || touchEndY.current === 0) return + if (touchEndY.current - touchStartY.current > REFRESH_THRESHOLD) { + router.push(router.asPath) + } + setPullDistance(0) + document.body.style.marginTop = '0px' + touchStartY.current = 0 + touchEndY.current = 0 + }, [router]) + + useEffect(() => { + if (!isPWA) return + document.body.style.overscrollBehaviorY = 'contain' + document.addEventListener('touchstart', handleTouchStart) + document.addEventListener('touchmove', handleTouchMove) + document.addEventListener('touchend', handleTouchEnd) + return () => { + document.body.style.overscrollBehaviorY = '' + document.body.style.marginTop = '0px' + document.removeEventListener('touchstart', handleTouchStart) + document.removeEventListener('touchmove', handleTouchMove) + document.removeEventListener('touchend', handleTouchEnd) + } + }, [isPWA, handleTouchStart, handleTouchMove, handleTouchEnd]) + + const pullMessage = useMemo(() => { + if (pullDistance > REFRESH_THRESHOLD) return 'release to refresh' + return 'pull down to refresh' + }, [pullDistance]) + + return ( +
+

+ {pullMessage} +

+ {children} +
+ ) +} diff --git a/components/pull-to-refresh.module.css b/components/pull-to-refresh.module.css new file mode 100644 index 000000000..ed6c991bc --- /dev/null +++ b/components/pull-to-refresh.module.css @@ -0,0 +1,13 @@ +.pullMessage { + position: absolute; + left: 50%; + top: -20px; + height: 15px; + transform: translateX(-50%); + font-size: small; + color: #a5a5a5; + opacity: 0; + transition: opacity 0.3s ease-in-out; + text-align: center; + opacity: 0.75; +} diff --git a/components/text.module.css b/components/text.module.css index ff99b8a90..faa1f1e3a 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -184,10 +184,14 @@ .p:has(> .mediaContainer) .mediaContainer { display: flex; - width: min-content; max-width: 100%; } +.p:has(> .mediaContainer) .mediaContainer.loaded +{ + width: min-content; +} + .p:has(> .mediaContainer) .mediaContainer img, .p:has(> .mediaContainer) .mediaContainer video { diff --git a/components/upvote.js b/components/upvote.js index d152e3684..9db088198 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -103,7 +103,7 @@ export const nextTip = (meSats, { tipDefault, turboTipping, tipRandom, tipRandom return defaultTipIncludingRandom({ tipDefault, tipRandom, tipRandomMin, tipRandomMax }) } -export default function UpVote ({ item, className }) { +export default function UpVote ({ item, className, collapsed }) { const showModal = useShowModal() const [voteShow, _setVoteShow] = useState(false) const [tipShow, _setTipShow] = useState(false) @@ -150,8 +150,8 @@ export default function UpVote ({ item, className }) { const zap = useZap() - const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt, - [item?.mine, item?.meForward, item?.deletedAt]) + const disabled = useMemo(() => collapsed || item?.mine || item?.meForward || item?.deletedAt, + [collapsed, item?.mine, item?.meForward, item?.deletedAt]) const [meSats, overlayText, color, nextColor] = useMemo(() => { const meSats = (me ? item?.meSats : item?.meAnonSats) || 0 @@ -244,9 +244,7 @@ export default function UpVote ({ item, className }) { onShortPress={handleShortPress} > -
+
setHover(true)} onMouseLeave={() => setHover(false)} diff --git a/components/wallet-buttonbar.js b/components/wallet-buttonbar.js index 76b60e92f..465729199 100644 --- a/components/wallet-buttonbar.js +++ b/components/wallet-buttonbar.js @@ -11,7 +11,7 @@ export default function WalletButtonBar ({ return (
- {isConfigured(wallet) && + {isConfigured(wallet) && wallet.def.requiresConfig && } {children}
diff --git a/components/wallet-card.js b/components/wallet-card.js index eff71d06e..a964f8d68 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -1,23 +1,34 @@ -import { Badge, Card } from 'react-bootstrap' +import { Card } from 'react-bootstrap' import styles from '@/styles/wallet.module.css' import Plug from '@/svgs/plug.svg' import Gear from '@/svgs/settings-5-fill.svg' import Link from 'next/link' import { Status, isConfigured } from '@/wallets/common' import DraggableIcon from '@/svgs/draggable.svg' +import RecvIcon from '@/svgs/arrow-left-down-line.svg' +import SendIcon from '@/svgs/arrow-right-up-line.svg' +import useDarkMode from './dark-mode' +import { useEffect, useState } from 'react' + +const statusToClass = status => { + switch (status) { + case Status.Enabled: return styles.success + case Status.Disabled: return styles.disabled + case Status.Error: return styles.error + case Status.Warning: return styles.warning + } +} export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnter, onDragEnd, onTouchStart, sourceIndex, targetIndex, index }) { - const { card: { title, badges } } = wallet.def + const [dark] = useDarkMode() + const { card: { title, image } } = wallet.def + const [imgSrc, setImgSrc] = useState(image?.src) - let indicator = styles.disabled - switch (wallet.status) { - case Status.Enabled: - indicator = styles.success - break - default: - indicator = styles.disabled - break - } + useEffect(() => { + if (!imgSrc) return + // wallet.png <-> wallet-dark.png + setImgSrc(dark ? image?.src.replace(/\.([a-z]{3})$/, '-dark.$1') : image?.src) + }, [dark]) return (
- {wallet.status === Status.Enabled && } -
+
+ {wallet.status.any !== Status.Disabled && } + {wallet.support.recv && } + {wallet.support.send && } +
- {title} - - {badges?.map( - badge => { - let style = '' - switch (badge) { - case 'receive': style = styles.receive; break - case 'send': style = styles.send; break - } - return ( - - {badge} - - ) - })} - +
+ {image + ? {title} + : {title}} +
diff --git a/components/wallet-logger.js b/components/wallet-logger.js index b4465a065..7beadeb02 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -174,9 +174,12 @@ export function useWalletLogger (wallet, setLogs) { payment_hash: decoded.tagsObject.payment_hash, description: decoded.tagsObject.description, created_at: new Date(decoded.timestamp * 1000).toISOString(), - expires_at: new Date(decoded.timeExpireDate * 1000).toISOString() + expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(), + // payments should affect wallet status + status: true } } + context.send = true appendLog(wallet, level, message, context) console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message) @@ -290,7 +293,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { if (hasMore) { setLoading(true) const result = await loadLogsPage(page + 1, logsPerPage, wallet?.def) - _setLogs(prevLogs => [...prevLogs, ...result.data]) + _setLogs(prevLogs => uniqueSort([...prevLogs, ...result.data])) setHasMore(result.hasMore) setPage(prevPage => prevPage + 1) setLoading(false) diff --git a/fragments/notifications.js b/fragments/notifications.js index ce588ccc1..0915ccdf1 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -174,6 +174,8 @@ export const NOTIFICATIONS = gql` nostr comment lud18Data + actionType + forwardedSats } } ... on Invoicification { @@ -185,8 +187,8 @@ export const NOTIFICATIONS = gql` earnedSats withdrawl { autoWithdraw - p2p satsFeePaid + forwardedActionType } } ... on Reminder { diff --git a/fragments/users.js b/fragments/users.js index 1eaae2582..bc4893fb3 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -50,6 +50,7 @@ ${STREAK_FIELDS} disableFreebies vaultKeyHash walletsUpdatedAt + proxyReceive } optional { isContributor @@ -111,6 +112,7 @@ export const SETTINGS_FIELDS = gql` apiKey } apiKeyEnabled + proxyReceive } }` diff --git a/fragments/wallet.js b/fragments/wallet.js index c9cdd65ce..fe1f543b3 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -21,6 +21,7 @@ export const INVOICE_FIELDS = gql` actionType actionError confirmedPreimage + forwardedSats }` export const INVOICE_FULL = gql` @@ -57,6 +58,7 @@ export const WITHDRAWL = gql` status autoWithdraw preimage + forwardedActionType } }` diff --git a/jsconfig.json b/jsconfig.json index 9b5c18ac9..d441d291a 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -2,30 +2,9 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/api/*": [ - "api/*" - ], - "@/lib/*": [ - "lib/*" - ], - "@/fragments/*": [ - "fragments/*" - ], - "@/pages/*": [ - "pages/*" - ], - "@/components/*": [ - "components/*" - ], - "@/wallets/*": [ - "wallets/*" - ], - "@/styles/*": [ - "styles/*" - ], - "@/svgs/*": [ - "svgs/*" - ], + "@/*": [ + "./*" + ] } } } \ No newline at end of file diff --git a/lib/apollo.js b/lib/apollo.js index d72a035d5..6e2eeab1a 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -49,7 +49,8 @@ function getClient (uri) { 'ItemActPaidAction', 'PollVotePaidAction', 'SubPaidAction', - 'DonatePaidAction' + 'DonatePaidAction', + 'ReceivePaidAction' ], Notification: [ 'Reply', diff --git a/lib/constants.js b/lib/constants.js index ad3272360..4e734355b 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -3,6 +3,12 @@ export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs'] export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs') +export const PAID_ACTION_PAYMENT_METHODS = { + FEE_CREDIT: 'FEE_CREDIT', + PESSIMISTIC: 'PESSIMISTIC', + OPTIMISTIC: 'OPTIMISTIC', + P2P: 'P2P' +} export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING'] export const NOFOLLOW_LIMIT = 1000 export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener' @@ -53,8 +59,6 @@ export const USER_ID = { } export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn] export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn, USER_ID.saloon] -export const ANON_INV_PENDING_LIMIT = 1000 -export const ANON_BALANCE_LIMIT_MSATS = 0 // disable export const MAX_POLL_NUM_CHOICES = 10 export const MIN_POLL_NUM_CHOICES = 2 export const POLL_COST = 1 @@ -74,10 +78,11 @@ export const SSR = typeof window === 'undefined' export const MAX_FORWARDS = 5 export const LND_PATHFINDING_TIMEOUT_MS = 30000 export const LNURLP_COMMENT_MAX_LENGTH = 1000 +// https://github.com/lightning/bolts/issues/236 +export const MAX_INVOICE_DESCRIPTION_LENGTH = 640 export const RESERVED_MAX_USER_ID = 615 export const GLOBAL_SEED = USER_ID.k00b export const FREEBIE_BASE_COST_THRESHOLD = 10 -export const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad] // WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information // From lawyers: north korea, cuba, iran, ukraine, syria diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 6cfc9b448..0504e2989 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -1,14 +1,12 @@ import models from '@/api/models' import lnd from '@/api/lnd' -import { createInvoice } from 'ln-service' import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl' -import serialize from '@/api/resolvers/serial' import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' -import { datePivot } from '@/lib/time' -import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants' -import { validateSchema, lud18PayerDataSchema } from '@/lib/validate' +import { LNURLP_COMMENT_MAX_LENGTH, MAX_INVOICE_DESCRIPTION_LENGTH } from '@/lib/constants' +import { validateSchema, lud18PayerDataSchema, toPositiveBigInt } from '@/lib/validate' import assertGofacYourself from '@/api/resolvers/ofac' +import performPaidAction from '@/api/paidAction' export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => { const user = await models.user.findUnique({ where: { name: username } }) @@ -30,14 +28,16 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa // If there is an amount tag, it MUST be equal to the amount query parameter const eventAmount = note.tags?.find(t => t[0] === 'amount')?.[1] if (schnorr.verify(note.sig, note.id, note.pubkey) && hasPTag && hasETag && (!eventAmount || Number(eventAmount) === Number(amount))) { - description = user.hideInvoiceDesc ? undefined : 'zap' + description = 'zap' descriptionHash = createHash('sha256').update(noteStr).digest('hex') } else { res.status(400).json({ status: 'ERROR', reason: 'invalid NIP-57 note' }) return } } else { - description = user.hideInvoiceDesc ? undefined : `Funding @${username} on stacker.news` + description = `Paying @${username} on stacker.news` + description += comment ? `: ${comment}` : '.' + description = description.slice(0, MAX_INVOICE_DESCRIPTION_LENGTH) descriptionHash = lnurlPayDescriptionHashForUser(username) } @@ -45,8 +45,11 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' }) } - if (comment && comment.length > LNURLP_COMMENT_MAX_LENGTH) { - return res.status(400).json({ status: 'ERROR', reason: `comment cannot exceed ${LNURLP_COMMENT_MAX_LENGTH} characters in length` }) + if (comment?.length > LNURLP_COMMENT_MAX_LENGTH) { + return res.status(400).json({ + status: 'ERROR', + reason: `comment cannot exceed ${LNURLP_COMMENT_MAX_LENGTH} characters in length` + }) } let parsedPayerData @@ -55,7 +58,10 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa parsedPayerData = JSON.parse(payerData) } catch (err) { console.error('failed to parse payerdata', err) - return res.status(400).json({ status: 'ERROR', reason: 'Invalid JSON supplied for payerdata parameter' }) + return res.status(400).json({ + status: 'ERROR', + reason: 'Invalid JSON supplied for payerdata parameter' + }) } try { @@ -71,27 +77,20 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa } // generate invoice - const expiresAt = datePivot(new Date(), { minutes: 5 }) - const invoice = await createInvoice({ + const { invoice } = await performPaidAction('RECEIVE', { + msats: toPositiveBigInt(amount), description, - description_hash: descriptionHash, - lnd, - mtokens: amount, - expires_at: expiresAt - }) + descriptionHash, + comment: comment || '', + lud18Data: parsedPayerData + }, { models, lnd, me: user }) - await serialize( - models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.secret}::TEXT, ${invoice.request}, - ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description}, - ${comment || null}, ${parsedPayerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER, - ${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`, - { models } - ) + if (!invoice?.bolt11) throw new Error('could not generate invoice') return res.status(200).json({ - pr: invoice.request, + pr: invoice.bolt11, routes: [], - verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.id}` + verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.hash}` }) } catch (error) { console.log(error) diff --git a/pages/api/lnurlp/[username]/verify/[hash].js b/pages/api/lnurlp/[username]/verify/[hash].js index 6168fcd66..f580131f1 100644 --- a/pages/api/lnurlp/[username]/verify/[hash].js +++ b/pages/api/lnurlp/[username]/verify/[hash].js @@ -6,8 +6,13 @@ export default async ({ query: { hash } }, res) => { if (!inv) { return res.status(404).json({ status: 'ERROR', reason: 'not found' }) } - const settled = inv.confirmedAt - return res.status(200).json({ status: 'OK', settled: !!settled, preimage: settled ? inv.preimage : null, pr: inv.bolt11 }) + const settled = !!inv.confirmedAt + return res.status(200).json({ + status: 'OK', + settled, + preimage: settled ? inv.preimage : null, + pr: inv.bolt11 + }) } catch (err) { console.log('error', err) return res.status(500).json({ status: 'ERROR', reason: 'internal server error' }) diff --git a/pages/settings/index.js b/pages/settings/index.js index 6ed1dfaeb..d799c432e 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -158,7 +158,8 @@ export default function Settings ({ ssrData }) { hideWalletBalance: settings?.hideWalletBalance, diagnostics: settings?.diagnostics, hideIsContributor: settings?.hideIsContributor, - noReferralLinks: settings?.noReferralLinks + noReferralLinks: settings?.noReferralLinks, + proxyReceive: settings?.proxyReceive }} schema={settingsSchema} onSubmit={async ({ @@ -332,7 +333,22 @@ export default function Settings ({ ssrData }) { label='I find or lose cowboy essentials (e.g. cowboy hat)' name='noteCowboyHat' /> -
privacy
+
wallet
+ proxy deposits to attached wallets + +
    +
  • Forward deposits directly to your attached wallets if they will cause your balance to exceed your auto-withdraw threshold
  • +
  • Payments will be wrapped by the SN node to preserve your wallet's privacy
  • +
  • This will incur in a 10% fee
  • +
+
+
+ } + name='proxyReceive' + groupClassName='mb-0' + /> hide invoice descriptions @@ -353,6 +369,7 @@ export default function Settings ({ ssrData }) { groupClassName='mb-0' /> autodelete withdrawal invoices @@ -367,8 +384,12 @@ export default function Settings ({ ssrData }) {
} name='autoDropBolt11s' - groupClassName='mb-0' /> + hide my wallet balance} + name='hideWalletBalance' + /> +
privacy
hide me from top stackers} name='hideFromTopUsers' @@ -379,11 +400,6 @@ export default function Settings ({ ssrData }) { name='hideCowboyHat' groupClassName='mb-0' /> - hide my wallet balance} - name='hideWalletBalance' - groupClassName='mb-0' - /> hide my bookmarks from other stackers} name='hideBookmarks' diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 891307ce2..cbaa01730 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -67,10 +67,16 @@ export default function WalletSettings () { } }, [wallet.def]) + const { card: { image, title, subtitle } } = wallet?.def || { card: {} } + return ( -

{wallet?.def.card.title}

-
{wallet?.def.card.subtitle}
+ {image + ? typeof image === 'object' + ? {title} + : {title} + :

{title}

} +
{subtitle}
} diff --git a/pages/withdrawals/[id].js b/pages/withdrawals/[id].js index bcdca56a9..43c0b84b6 100644 --- a/pages/withdrawals/[id].js +++ b/pages/withdrawals/[id].js @@ -16,7 +16,8 @@ import { gql } from 'graphql-tag' import { useShowModal } from '@/components/modal' import { DeleteConfirm } from '@/components/delete' import { getGetServerSideProps } from '@/api/ssrApollo' - +import { Badge } from 'react-bootstrap' +import styles from '@/components/invoice.module.css' // force SSR to include CSP nonces export const getServerSideProps = getGetServerSideProps({ query: null }) @@ -68,7 +69,11 @@ function LoadWithdrawl () { let variant = 'default' switch (data.withdrawl.status) { case 'CONFIRMED': - status = `sent ${numWithUnits(data.withdrawl.satsPaid, { abbreviate: false })} with ${numWithUnits(data.withdrawl.satsFeePaid, { abbreviate: false })} in routing fees` + if (data.withdrawl.forwardedActionType) { + status = <>{`forwarded ${numWithUnits(data.withdrawl.satsPaid, { abbreviate: false })}`} p2p + } else { + status = `sent ${numWithUnits(data.withdrawl.satsPaid, { abbreviate: false })} with ${numWithUnits(data.withdrawl.satsFeePaid, { abbreviate: false })} in routing fees` + } variant = 'confirmed' break case 'INSUFFICIENT_BALANCE': diff --git a/prisma/migrations/20241111095156_receive_paid_action/migration.sql b/prisma/migrations/20241111095156_receive_paid_action/migration.sql new file mode 100644 index 000000000..928861440 --- /dev/null +++ b/prisma/migrations/20241111095156_receive_paid_action/migration.sql @@ -0,0 +1,13 @@ +-- AlterEnum +ALTER TYPE "InvoiceActionType" ADD VALUE 'RECEIVE'; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "proxyReceive" BOOLEAN NOT NULL DEFAULT false; + +DROP FUNCTION IF EXISTS create_invoice; + +-- Add unique index for Withdrawl table +-- to prevent multiple pending withdrawls with the same hash +CREATE UNIQUE INDEX "Withdrawl_hash_key_null_status" +ON "Withdrawl" (hash) +WHERE status IS NULL OR status = 'CONFIRMED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da9d6e9da..dbe3d44e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -140,6 +140,7 @@ model User { vaultKeyHash String @default("") walletsUpdatedAt DateTime? vaultEntries VaultEntry[] @relation("VaultEntries") + proxyReceive Boolean @default(false) @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -864,6 +865,7 @@ enum InvoiceActionType { TERRITORY_UPDATE TERRITORY_BILLING TERRITORY_UNARCHIVE + RECEIVE } enum InvoiceActionState { diff --git a/public/wallets/blink-dark.svg b/public/wallets/blink-dark.svg new file mode 100644 index 000000000..b62d73f30 --- /dev/null +++ b/public/wallets/blink-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/wallets/blink.svg b/public/wallets/blink.svg new file mode 100644 index 000000000..01daddddf --- /dev/null +++ b/public/wallets/blink.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/wallets/cln-dark.svg b/public/wallets/cln-dark.svg new file mode 100644 index 000000000..4a51b36b9 --- /dev/null +++ b/public/wallets/cln-dark.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/cln.svg b/public/wallets/cln.svg new file mode 100644 index 000000000..0e92b7e4e --- /dev/null +++ b/public/wallets/cln.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/lnbits-dark.svg b/public/wallets/lnbits-dark.svg new file mode 100644 index 000000000..531207910 --- /dev/null +++ b/public/wallets/lnbits-dark.svg @@ -0,0 +1,13 @@ + + Group 6 + + + + + + + + + + + \ No newline at end of file diff --git a/public/wallets/lnbits.svg b/public/wallets/lnbits.svg new file mode 100644 index 000000000..97a2ec172 --- /dev/null +++ b/public/wallets/lnbits.svg @@ -0,0 +1,13 @@ + + Group 6 + + + + + + + + + + + \ No newline at end of file diff --git a/public/wallets/lnd-dark.png b/public/wallets/lnd-dark.png new file mode 100644 index 000000000..169d27721 Binary files /dev/null and b/public/wallets/lnd-dark.png differ diff --git a/public/wallets/lnd.png b/public/wallets/lnd.png new file mode 100644 index 000000000..169d27721 Binary files /dev/null and b/public/wallets/lnd.png differ diff --git a/public/wallets/phoenixd-dark.png b/public/wallets/phoenixd-dark.png new file mode 100644 index 000000000..93aa93366 Binary files /dev/null and b/public/wallets/phoenixd-dark.png differ diff --git a/public/wallets/phoenixd.png b/public/wallets/phoenixd.png new file mode 100644 index 000000000..59d1c7ccc Binary files /dev/null and b/public/wallets/phoenixd.png differ diff --git a/scripts/genwallet.sh b/scripts/genwallet.sh index d471d670b..e14609ecc 100644 --- a/scripts/genwallet.sh +++ b/scripts/genwallet.sh @@ -58,7 +58,6 @@ $(todo) export const card = { title: '$wallet', subtitle: '', - badges: [] } $(todo) @@ -73,7 +72,7 @@ EOF # create client.js cat > wallets/$wallet/client.js < wallets/$wallet/server.js < \ No newline at end of file diff --git a/svgs/arrow-right-up-line.svg b/svgs/arrow-right-up-line.svg new file mode 100644 index 000000000..93d2f337f --- /dev/null +++ b/svgs/arrow-right-up-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wallets/README.md b/wallets/README.md index 49059c9d2..9ad23f8ab 100644 --- a/wallets/README.md +++ b/wallets/README.md @@ -23,13 +23,13 @@ A _server.js_ file is only required for wallets that support receiving by exposi > Every wallet must have a client.js file (even if it does not support paying invoices) because every wallet is imported on the client. This is not the case on the server. On the client, wallets are imported via > > ```js -> import wallet from 'wallets//client' +> import wallet from '@/wallets//client' > ``` > > vs > > ```js -> import wallet from 'wallets//server' +> import wallet from '@/wallets//server' > ``` > > on the server. @@ -37,7 +37,7 @@ A _server.js_ file is only required for wallets that support receiving by exposi > To have access to the properties that can be shared between client and server, server.js and client.js always reexport everything in index.js with a line like this: > > ```js -> export * from 'wallets/' +> export * from '@/wallets/' > ``` > > If a wallet does not support paying invoices, this is all that client.js of this wallet does. The reason for this structure is to make sure the client does not import dependencies that can only be imported on the server and would thus break the build. @@ -152,9 +152,9 @@ The card title. The subtitle that is shown below the title if you enter the configuration form of a wallet. -- `badges: string[]` +- `image: { src: string, ... }` -The badges that are shown inside the card. +The image props that will be used to show an image inside the card. Should contain at least the `src` property. ### client.js @@ -181,7 +181,7 @@ The first argument is the [BOLT11 payment request](https://github.com/lightning/ > > ```js > // wallets//client.js -> export * from 'wallets/' +> export * from '@/wallets/' > ``` > > where `` is the wallet directory name. @@ -191,13 +191,13 @@ The first argument is the [BOLT11 payment request](https://github.com/lightning/ > > ```diff > // wallets/client.js -> import * as nwc from 'wallets/nwc/client' -> import * as lnbits from 'wallets/lnbits/client' -> import * as lnc from 'wallets/lnc/client' -> import * as lnAddr from 'wallets/lightning-address/client' -> import * as cln from 'wallets/cln/client' -> import * as lnd from 'wallets/lnd/client' -> + import * as newWallet from 'wallets//client' +> import * as nwc from '@/wallets/nwc/client' +> import * as lnbits from '@/wallets/lnbits/client' +> import * as lnc from '@/wallets/lnc/client' +> import * as lnAddr from '@/wallets/lightning-address/client' +> import * as cln from '@/wallets/cln/client' +> import * as lnd from '@/wallets/lnd/client' +> + import * as newWallet from '@/wallets//client' > > - export default [nwc, lnbits, lnc, lnAddr, cln, lnd] > + export default [nwc, lnbits, lnc, lnAddr, cln, lnd, newWallet] @@ -225,7 +225,7 @@ Again, like `testSendPayment`, the first argument is the wallet configuration th > > ```js > // wallets//server.js -> export * from 'wallets/' +> export * from '@/wallets/' > ``` > > where `` is the wallet directory name. @@ -235,10 +235,10 @@ Again, like `testSendPayment`, the first argument is the wallet configuration th > > ```diff > // wallets/server.js -> import * as lnd from 'wallets/lnd/server' -> import * as cln from 'wallets/cln/server' -> import * as lnAddr from 'wallets/lightning-address/server' -> + import * as newWallet from 'wallets//client' +> import * as lnd from '@/wallets/lnd/server' +> import * as cln from '@/wallets/cln/server' +> import * as lnAddr from '@/wallets/lightning-address/server' +> + import * as newWallet from '@/wallets//client' > > - export default [lnd, cln, lnAddr] > + export default [lnd, cln, lnAddr, newWallet] diff --git a/wallets/blink/client.js b/wallets/blink/client.js index fd438a6b9..8779d64ae 100644 --- a/wallets/blink/client.js +++ b/wallets/blink/client.js @@ -1,5 +1,5 @@ -import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from 'wallets/blink/common' -export * from 'wallets/blink' +import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' +export * from '@/wallets/blink' export async function testSendPayment ({ apiKey, currency }, { logger }) { logger.info('trying to fetch ' + currency + ' wallet') diff --git a/wallets/blink/index.js b/wallets/blink/index.js index 42a2efc53..d870592ce 100644 --- a/wallets/blink/index.js +++ b/wallets/blink/index.js @@ -1,5 +1,5 @@ import { string } from '@/lib/yup' -import { galoyBlinkDashboardUrl } from 'wallets/blink/common' +import { galoyBlinkDashboardUrl } from '@/wallets/blink/common' export const name = 'blink' export const walletType = 'BLINK' @@ -67,5 +67,5 @@ export const fields = [ export const card = { title: 'Blink', subtitle: 'use [Blink](https://blink.sv/) for payments', - badges: ['send', 'receive'] + image: { src: '/wallets/blink.svg' } } diff --git a/wallets/blink/server.js b/wallets/blink/server.js index 30163560a..937a7ebad 100644 --- a/wallets/blink/server.js +++ b/wallets/blink/server.js @@ -1,7 +1,7 @@ import { withTimeout } from '@/lib/time' -import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from 'wallets/blink/common' +import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' import { msatsToSats } from '@/lib/format' -export * from 'wallets/blink' +export * from '@/wallets/blink' export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) { const scopes = await getScopes(apiKeyRecv) diff --git a/wallets/client.js b/wallets/client.js index 96a68ca8f..8bd44698f 100644 --- a/wallets/client.js +++ b/wallets/client.js @@ -1,11 +1,11 @@ -import * as nwc from 'wallets/nwc/client' -import * as lnbits from 'wallets/lnbits/client' -import * as lnc from 'wallets/lnc/client' -import * as lnAddr from 'wallets/lightning-address/client' -import * as cln from 'wallets/cln/client' -import * as lnd from 'wallets/lnd/client' -import * as webln from 'wallets/webln/client' -import * as blink from 'wallets/blink/client' -import * as phoenixd from 'wallets/phoenixd/client' +import * as nwc from '@/wallets/nwc/client' +import * as lnbits from '@/wallets/lnbits/client' +import * as lnc from '@/wallets/lnc/client' +import * as lnAddr from '@/wallets/lightning-address/client' +import * as cln from '@/wallets/cln/client' +import * as lnd from '@/wallets/lnd/client' +import * as webln from '@/wallets/webln/client' +import * as blink from '@/wallets/blink/client' +import * as phoenixd from '@/wallets/phoenixd/client' export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd] diff --git a/wallets/cln/client.js b/wallets/cln/client.js index d9192b05a..97b542b38 100644 --- a/wallets/cln/client.js +++ b/wallets/cln/client.js @@ -1 +1 @@ -export * from 'wallets/cln' +export * from '@/wallets/cln' diff --git a/wallets/cln/index.js b/wallets/cln/index.js index 14525f97a..dc8523d14 100644 --- a/wallets/cln/index.js +++ b/wallets/cln/index.js @@ -63,5 +63,5 @@ export const fields = [ export const card = { title: 'CLN', subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)', - badges: ['receive'] + image: { src: '/wallets/cln.svg' } } diff --git a/wallets/cln/server.js b/wallets/cln/server.js index 9371e5cfd..53b958b18 100644 --- a/wallets/cln/server.js +++ b/wallets/cln/server.js @@ -1,6 +1,6 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln' -export * from 'wallets/cln' +export * from '@/wallets/cln' export const testCreateInvoice = async ({ socket, rune, cert }) => { return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }) diff --git a/wallets/common.js b/wallets/common.js index f85015ca8..c36d70aa8 100644 --- a/wallets/common.js +++ b/wallets/common.js @@ -1,8 +1,10 @@ -import walletDefs from 'wallets/client' +import walletDefs from '@/wallets/client' export const Status = { Enabled: 'Enabled', - Disabled: 'Disabled' + Disabled: 'Disabled', + Error: 'Error', + Warning: 'Warning' } export function getWalletByName (name) { @@ -89,12 +91,24 @@ function isReceiveConfigured ({ def, config }) { return fields.length > 0 && checkFields({ fields, config }) } +export function supportsSend ({ def, config }) { + return !!def.sendPayment +} + +export function supportsReceive ({ def, config }) { + return def.fields.some(f => f.serverOnly) +} + export function canSend ({ def, config }) { - return !!def.sendPayment && isSendConfigured({ def, config }) + return ( + supportsSend({ def, config }) && + isSendConfigured({ def, config }) && + (def.requiresConfig || config?.enabled) + ) } export function canReceive ({ def, config }) { - return def.fields.some(f => f.serverOnly) && isReceiveConfigured({ def, config }) + return supportsReceive({ def, config }) && isReceiveConfigured({ def, config }) } export function siftConfig (fields, config) { @@ -161,3 +175,31 @@ export async function saveWalletLocally (name, config, userId) { const storageKey = getStorageKey(name, userId) window.localStorage.setItem(storageKey, JSON.stringify(config)) } + +export const statusFromLog = (wallet, logs) => { + if (wallet.status.any === Status.Disabled) return wallet + + // override status depending on if there have been warnings or errors in the logs recently + // find first log from which we can derive status (logs are sorted by recent first) + const walletLogs = logs.filter(l => l.wallet === wallet.def.name) + const sendLevel = walletLogs.find(l => l.context?.status && l.context?.send)?.level + const recvLevel = walletLogs.find(l => l.context?.status && l.context?.recv)?.level + + const levelToStatus = (level) => { + switch (level?.toLowerCase()) { + case 'ok': + case 'success': return Status.Enabled + case 'error': return Status.Error + case 'warn': return Status.Warning + } + } + + return { + ...wallet, + status: { + ...wallet.status, + send: levelToStatus(sendLevel) || wallet.status.send, + recv: levelToStatus(recvLevel) || wallet.status.recv + } + } +} diff --git a/wallets/config.js b/wallets/config.js index 13011613e..6227605c4 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -54,7 +54,7 @@ export function useWalletConfigurator (wallet) { if (transformedConfig) { serverConfig = Object.assign(serverConfig, transformedConfig) } - } else { + } else if (wallet.def.requiresConfig) { throw new Error('configuration must be able to send or receive') } diff --git a/wallets/index.js b/wallets/index.js index 42a782474..c2e83eed0 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -3,11 +3,11 @@ import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet' import { SSR } from '@/lib/constants' import { useApolloClient, useMutation, useQuery } from '@apollo/client' import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common' +import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally, canReceive, supportsReceive, supportsSend, statusFromLog } from './common' import useVault from '@/components/vault/use-vault' -import { useWalletLogger } from '@/components/wallet-logger' +import { useWalletLogger, useWalletLogs } from '@/components/wallet-logger' import { decode as bolt11Decode } from 'bolt11' -import walletDefs from 'wallets/client' +import walletDefs from '@/wallets/client' import { generateMutation } from './graphql' import { formatSats } from '@/lib/format' @@ -45,7 +45,7 @@ function useLocalWallets () { // listen for changes to any wallet config in local storage // from any window with the same origin const handleStorage = (event) => { - if (event.key.startsWith(getStorageKey(''))) { + if (event.key?.startsWith(getStorageKey(''))) { loadWallets() } } @@ -67,6 +67,7 @@ export function WalletsProvider ({ children }) { const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY) const [serverWallets, setServerWallets] = useState([]) const client = useApolloClient() + const { logs } = useWalletLogs() const { data, refetch } = useQuery(WALLETS, SSR ? {} : { nextFetchPolicy: 'cache-and-network' }) @@ -111,7 +112,10 @@ export function WalletsProvider ({ children }) { const merged = {} for (const wallet of [...walletDefsOnly, ...localWallets, ...serverWallets]) { merged[wallet.def.name] = { - def: wallet.def, + def: { + ...wallet.def, + requiresConfig: wallet.def.fields.length > 0 + }, config: { ...merged[wallet.def.name]?.config, ...Object.fromEntries( @@ -128,8 +132,21 @@ export function WalletsProvider ({ children }) { // sort by priority, then add status field return Object.values(merged) .sort(walletPrioritySort) - .map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled })) - }, [serverWallets, localWallets]) + .map(w => { + return { + ...w, + support: { + recv: supportsReceive(w), + send: supportsSend(w) + }, + status: { + any: w.config?.enabled && isConfigured(w) ? Status.Enabled : Status.Disabled, + send: w.config?.enabled && canSend(w) ? Status.Enabled : Status.Disabled, + recv: w.config?.enabled && canReceive(w) ? Status.Enabled : Status.Disabled + } + } + }).map(w => statusFromLog(w, logs)) + }, [serverWallets, localWallets, logs]) const settings = useMemo(() => { return { diff --git a/wallets/lightning-address/client.js b/wallets/lightning-address/client.js index 004c4e762..9c6b469e0 100644 --- a/wallets/lightning-address/client.js +++ b/wallets/lightning-address/client.js @@ -1 +1 @@ -export * from 'wallets/lightning-address' +export * from '@/wallets/lightning-address' diff --git a/wallets/lightning-address/index.js b/wallets/lightning-address/index.js index e4c16bc1a..e2bb7e520 100644 --- a/wallets/lightning-address/index.js +++ b/wallets/lightning-address/index.js @@ -22,6 +22,5 @@ export const fields = [ export const card = { title: 'lightning address', - subtitle: 'autowithdraw to a lightning address', - badges: ['receive'] + subtitle: 'autowithdraw to a lightning address' } diff --git a/wallets/lightning-address/server.js b/wallets/lightning-address/server.js index 3dd931211..ae6d8f1dd 100644 --- a/wallets/lightning-address/server.js +++ b/wallets/lightning-address/server.js @@ -1,7 +1,7 @@ import { msatsSatsFloor } from '@/lib/format' import { lnAddrOptions } from '@/lib/lnurl' -export * from 'wallets/lightning-address' +export * from '@/wallets/lightning-address' export const testCreateInvoice = async ({ address }) => { return await createInvoice({ msats: 1000 }, { address }) diff --git a/wallets/lnbits/client.js b/wallets/lnbits/client.js index f0feb08f0..d8afca4c3 100644 --- a/wallets/lnbits/client.js +++ b/wallets/lnbits/client.js @@ -1,6 +1,6 @@ import { assertContentTypeJson } from '@/lib/url' -export * from 'wallets/lnbits' +export * from '@/wallets/lnbits' export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) { logger.info('trying to fetch wallet') diff --git a/wallets/lnbits/index.js b/wallets/lnbits/index.js index edcadeada..492086f77 100644 --- a/wallets/lnbits/index.js +++ b/wallets/lnbits/index.js @@ -55,5 +55,5 @@ export const fields = [ export const card = { title: 'LNbits', subtitle: 'use [LNbits](https://lnbits.com/) for payments', - badges: ['send', 'receive'] + image: { src: '/wallets/lnbits.svg' } } diff --git a/wallets/lnbits/server.js b/wallets/lnbits/server.js index 6dd97ec9c..2fe0b204b 100644 --- a/wallets/lnbits/server.js +++ b/wallets/lnbits/server.js @@ -3,7 +3,7 @@ import { getAgent } from '@/lib/proxy' import { assertContentTypeJson } from '@/lib/url' import fetch from 'cross-fetch' -export * from 'wallets/lnbits' +export * from '@/wallets/lnbits' export async function testCreateInvoice ({ url, invoiceKey }) { return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }) diff --git a/wallets/lnc/client.js b/wallets/lnc/client.js index 89a7dd883..03f041528 100644 --- a/wallets/lnc/client.js +++ b/wallets/lnc/client.js @@ -1,7 +1,7 @@ import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' import { bolt11Tags } from '@/lib/bolt11' import { Mutex } from 'async-mutex' -export * from 'wallets/lnc' +export * from '@/wallets/lnc' async function disconnect (lnc, logger) { if (lnc) { @@ -38,11 +38,11 @@ export async function testSendPayment (credentials, { logger }) { logger.info('connecting ...') await lnc.connect() - logger.ok('connected') + logger.info('connected') logger.info('validating permissions ...') await validateNarrowPerms(lnc) - logger.ok('permissions ok') + logger.info('permissions ok') return lnc.credentials.credentials } finally { diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js index 395762bf6..c039678bd 100644 --- a/wallets/lnc/index.js +++ b/wallets/lnc/index.js @@ -60,6 +60,5 @@ export const fields = [ export const card = { title: 'LNC', - subtitle: 'use Lightning Node Connect for LND payments', - badges: ['send', 'budgetable'] + subtitle: 'use Lightning Node Connect for LND payments' } diff --git a/wallets/lnd/client.js b/wallets/lnd/client.js index 2aeb5534e..e8e85d29b 100644 --- a/wallets/lnd/client.js +++ b/wallets/lnd/client.js @@ -1 +1 @@ -export * from 'wallets/lnd' +export * from '@/wallets/lnd' diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js index e1d6395cf..dc55aa844 100644 --- a/wallets/lnd/index.js +++ b/wallets/lnd/index.js @@ -50,5 +50,5 @@ export const fields = [ export const card = { title: 'LND', subtitle: 'autowithdraw to your Lightning Labs node', - badges: ['receive'] + image: { src: '/wallets/lnd.png' } } diff --git a/wallets/lnd/server.js b/wallets/lnd/server.js index 8e260984f..6a9a71088 100644 --- a/wallets/lnd/server.js +++ b/wallets/lnd/server.js @@ -3,7 +3,7 @@ import { authenticatedLndGrpc } from '@/lib/lnd' import { createInvoice as lndCreateInvoice } from 'ln-service' import { TOR_REGEXP } from '@/lib/url' -export * from 'wallets/lnd' +export * from '@/wallets/lnd' export const testCreateInvoice = async ({ cert, macaroon, socket }) => { return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket }) diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js index 19d8d9963..b7dc13fad 100644 --- a/wallets/nwc/client.js +++ b/wallets/nwc/client.js @@ -1,5 +1,5 @@ -import { nwcCall, supportedMethods } from 'wallets/nwc' -export * from 'wallets/nwc' +import { nwcCall, supportedMethods } from '@/wallets/nwc' +export * from '@/wallets/nwc' export async function testSendPayment ({ nwcUrl }, { logger }) { const timeout = 15_000 diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index 49794d926..5bab23009 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -30,8 +30,7 @@ export const fields = [ export const card = { title: 'NWC', - subtitle: 'use Nostr Wallet Connect for payments', - badges: ['send', 'receive', 'budgetable'] + subtitle: 'use Nostr Wallet Connect for payments' } export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } = {}) { diff --git a/wallets/nwc/server.js b/wallets/nwc/server.js index ce2220872..9a8b06e12 100644 --- a/wallets/nwc/server.js +++ b/wallets/nwc/server.js @@ -1,6 +1,6 @@ import { withTimeout } from '@/lib/time' -import { nwcCall, supportedMethods } from 'wallets/nwc' -export * from 'wallets/nwc' +import { nwcCall, supportedMethods } from '@/wallets/nwc' +export * from '@/wallets/nwc' export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { const timeout = 15_000 diff --git a/wallets/phoenixd/client.js b/wallets/phoenixd/client.js index 14f1159b6..a482b2e2d 100644 --- a/wallets/phoenixd/client.js +++ b/wallets/phoenixd/client.js @@ -1,4 +1,4 @@ -export * from 'wallets/phoenixd' +export * from '@/wallets/phoenixd' export async function testSendPayment (config, { logger }) { // TODO: diff --git a/wallets/phoenixd/index.js b/wallets/phoenixd/index.js index 7bbec9ebb..ad7f19ee1 100644 --- a/wallets/phoenixd/index.js +++ b/wallets/phoenixd/index.js @@ -38,5 +38,5 @@ export const fields = [ export const card = { title: 'phoenixd', subtitle: 'use [phoenixd](https://phoenix.acinq.co/server) for payments', - badges: ['send', 'receive'] + image: { src: '/wallets/phoenixd.png' } } diff --git a/wallets/phoenixd/server.js b/wallets/phoenixd/server.js index e946c2ff0..3bc00c4fc 100644 --- a/wallets/phoenixd/server.js +++ b/wallets/phoenixd/server.js @@ -1,6 +1,6 @@ import { msatsToSats } from '@/lib/format' -export * from 'wallets/phoenixd' +export * from '@/wallets/phoenixd' export async function testCreateInvoice ({ url, secondaryPassword }) { return await createInvoice( diff --git a/wallets/server.js b/wallets/server.js index c08f04476..9ee2e71ef 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -1,18 +1,18 @@ // import server side wallets -import * as lnd from 'wallets/lnd/server' -import * as cln from 'wallets/cln/server' -import * as lnAddr from 'wallets/lightning-address/server' -import * as lnbits from 'wallets/lnbits/server' -import * as nwc from 'wallets/nwc/server' -import * as phoenixd from 'wallets/phoenixd/server' -import * as blink from 'wallets/blink/server' +import * as lnd from '@/wallets/lnd/server' +import * as cln from '@/wallets/cln/server' +import * as lnAddr from '@/wallets/lightning-address/server' +import * as lnbits from '@/wallets/lnbits/server' +import * as nwc from '@/wallets/nwc/server' +import * as phoenixd from '@/wallets/phoenixd/server' +import * as blink from '@/wallets/blink/server' // we import only the metadata of client side wallets -import * as lnc from 'wallets/lnc' -import * as webln from 'wallets/webln' +import * as lnc from '@/wallets/lnc' +import * as webln from '@/wallets/webln' import { walletLogger } from '@/api/resolvers/wallet' -import walletDefs from 'wallets/server' +import walletDefs from '@/wallets/server' import { parsePaymentRequest } from 'ln-service' import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' @@ -27,28 +27,11 @@ const MAX_PENDING_INVOICES_PER_WALLET = 25 export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) { // get the wallets in order of priority - const wallets = await models.wallet.findMany({ - where: { userId, enabled: true }, - include: { - user: true - }, - orderBy: [ - { priority: 'asc' }, - // use id as tie breaker (older wallet first) - { id: 'asc' } - ] - }) + const wallets = await getInvoiceableWallets(userId, { models }) msats = toPositiveNumber(msats) - for (const wallet of wallets) { - const w = walletDefs.find(w => w.walletType === wallet.type) - - const config = wallet.wallet - if (!canReceive({ def: w, config })) { - continue - } - + for (const { def, wallet } of wallets) { const logger = walletLogger({ wallet, models }) try { @@ -61,8 +44,8 @@ export async function createInvoice (userId, { msats, description, descriptionHa let invoice try { invoice = await walletCreateInvoice( + { wallet, def }, { msats, description, descriptionHash, expiry }, - { ...w, userId, createInvoice: w.createInvoice }, { logger, models }) } catch (err) { throw new Error('failed to create invoice: ' + err.message) @@ -128,37 +111,33 @@ export async function createWrappedInvoice (userId, } } -async function walletCreateInvoice ( - { - msats, - description, - descriptionHash, - expiry = 360 - }, - { - userId, - walletType, - walletField, - createInvoice - }, - { logger, models }) { - const wallet = await models.wallet.findFirst({ - where: { - userId, - type: walletType - }, +export async function getInvoiceableWallets (userId, { models }) { + const wallets = await models.wallet.findMany({ + where: { userId, enabled: true }, include: { - [walletField]: true, user: true - } + }, + orderBy: [ + { priority: 'asc' }, + // use id as tie breaker (older wallet first) + { id: 'asc' } + ] }) - const config = wallet[walletField] + const walletsWithDefs = wallets.map(wallet => { + const w = walletDefs.find(w => w.walletType === wallet.type) + return { wallet, def: w } + }) - if (!wallet || !config) { - throw new Error('wallet not found') - } + return walletsWithDefs.filter(({ def, wallet }) => canReceive({ def, config: wallet.wallet })) +} +async function walletCreateInvoice ({ wallet, def }, { + msats, + description, + descriptionHash, + expiry = 360 +}, { logger, models }) { // check for pending withdrawals const pendingWithdrawals = await models.withdrawl.count({ where: { @@ -185,14 +164,14 @@ async function walletCreateInvoice ( } return await withTimeout( - createInvoice( + def.createInvoice( { msats, description: wallet.user.hideInvoiceDesc ? undefined : description, descriptionHash, expiry }, - config, + wallet.wallet, { logger } ), 10_000) } diff --git a/wallets/webln/client.js b/wallets/webln/client.js index fd66dd02f..630b2bf5a 100644 --- a/wallets/webln/client.js +++ b/wallets/webln/client.js @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { SSR } from '@/lib/constants' -export * from 'wallets/webln' +export * from '@/wallets/webln' export const sendPayment = async (bolt11) => { if (typeof window.webln === 'undefined') { diff --git a/wallets/webln/index.js b/wallets/webln/index.js index e59f51bc1..cce91750e 100644 --- a/wallets/webln/index.js +++ b/wallets/webln/index.js @@ -12,6 +12,5 @@ export const fields = [] export const card = { title: 'WebLN', - subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments', - badges: ['send'] + subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments' } diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index fe9f21a65..c2105d021 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -1,6 +1,6 @@ import { msatsSatsFloor, msatsToSats, satsToMsats } from '@/lib/format' import { createWithdrawal } from '@/api/resolvers/wallet' -import { createInvoice } from 'wallets/server' +import { createInvoice } from '@/wallets/server' export async function autoWithdraw ({ data: { id }, models, lnd }) { const user = await models.user.findUnique({ where: { id } }) diff --git a/worker/paidAction.js b/worker/paidAction.js index bd5e209dc..8e9fdbd4d 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -11,14 +11,18 @@ import { getInvoice, getPayment, parsePaymentRequest, payViaPaymentRequest, settleHodlInvoice } from 'ln-service' -import { MIN_SETTLEMENT_CLTV_DELTA } from 'wallets/wrap' +import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' // aggressive finalization retry options const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 } -async function transitionInvoice (jobName, { invoiceId, fromState, toState, transition, invoice }, { models, lnd, boss }) { +async function transitionInvoice (jobName, + { invoiceId, fromState, toState, transition, invoice, onUnexpectedError }, + { models, lnd, boss } +) { console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`) + let dbInvoice try { const currentDbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } }) console.log('invoice is in state', currentDbInvoice.actionState) @@ -47,7 +51,7 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran } // grab optimistic concurrency lock and the invoice - const dbInvoice = await tx.invoice.update({ + dbInvoice = await tx.invoice.update({ include, where: { id: invoiceId, @@ -100,6 +104,7 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran } console.error('unexpected error', e) + onUnexpectedError?.({ error: e, dbInvoice, models, boss }) await boss.send( jobName, { invoiceId }, @@ -110,31 +115,35 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran } async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) { - try { - const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id } - const context = { tx, cost: BigInt(lndInvoice.received_mtokens) } - context.sybilFeePercent = await paidActions[dbInvoice.actionType].getSybilFeePercent?.(args, context) - - const result = await paidActions[dbInvoice.actionType].perform(args, context) - await tx.invoice.update({ - where: { id: dbInvoice.id }, - data: { - actionResult: result, - actionError: null - } - }) - } 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 store action error', e)) - boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS) - .catch(e => console.error('failed to finalize', e)) - throw e + const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id } + const context = { + tx, + cost: BigInt(lndInvoice.received_mtokens), + me: dbInvoice.user } + const sybilFeePercent = await paidActions[dbInvoice.actionType].getSybilFeePercent?.(args, context) + + const result = await paidActions[dbInvoice.actionType].perform(args, { ...context, sybilFeePercent }) + await tx.invoice.update({ + where: { id: dbInvoice.id }, + data: { + actionResult: result, + actionError: null + } + }) +} + +// if we experience an unexpected error when holding an invoice, we need aggressively attempt to cancel it +// store the error in the invoice, nonblocking and outside of this tx, finalizing immediately +function onHeldInvoiceError ({ error, dbInvoice, models, boss }) { + models.invoice.update({ + where: { id: dbInvoice.id }, + data: { + actionError: error.message + } + }).catch(e => console.error('failed to store action error', e)) + boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS) + .catch(e => console.error('failed to finalize', e)) } export async function paidActionPaid ({ data: { invoiceId, ...args }, models, lnd, boss }) { @@ -147,9 +156,17 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln throw new Error('invoice is not confirmed') } - await paidActions[dbInvoice.actionType].onPaid?.({ invoice: dbInvoice }, { models, tx, lnd }) + const updateFields = { + confirmedAt: new Date(lndInvoice.confirmed_at), + confirmedIndex: lndInvoice.confirmed_index, + msatsReceived: BigInt(lndInvoice.received_mtokens) + } + + await paidActions[dbInvoice.actionType].onPaid?.({ + invoice: { ...dbInvoice, ...updateFields } + }, { models, tx, lnd }) - // any paid action is eligible for a cowboy hat streak + // most paid actions are eligible for a cowboy hat streak await tx.$executeRaw` INSERT INTO pgboss.job (name, data) VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))` @@ -162,11 +179,7 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln ('checkStreak', jsonb_build_object('id', ${dbInvoice.invoiceForward.withdrawl.userId}, 'type', 'HORSE'))` } - return { - confirmedAt: new Date(lndInvoice.confirmed_at), - confirmedIndex: lndInvoice.confirmed_index, - msatsReceived: BigInt(lndInvoice.received_mtokens) - } + return updateFields }, ...args }, { models, lnd, boss }) @@ -237,6 +250,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode } } }, + onUnexpectedError: onHeldInvoiceError, ...args }, { models, lnd, boss }) @@ -281,9 +295,12 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a // settle the invoice, allowing us to transition to PAID await settleHodlInvoice({ secret: payment.secret, lnd }) + // the amount we paid includes the fee so we need to subtract it to get the amount received + const received = Number(payment.mtokens) - Number(payment.fee_mtokens) + const logger = walletLogger({ wallet: dbInvoice.invoiceForward.wallet, models }) logger.ok( - `↙ payment received: ${formatSats(msatsToSats(payment.mtokens))}`, + `↙ payment received: ${formatSats(msatsToSats(received))}`, { bolt11, preimage: payment.secret @@ -403,6 +420,7 @@ export async function paidActionHeld ({ data: { invoiceId, ...args }, models, ln msatsReceived: BigInt(lndInvoice.received_mtokens) } }, + onUnexpectedError: onHeldInvoiceError, ...args }, { models, lnd, boss }) } diff --git a/worker/streak.js b/worker/streak.js index e38a3d874..c6f1b5c10 100644 --- a/worker/streak.js +++ b/worker/streak.js @@ -99,7 +99,7 @@ function getStreakQuery (type, userId) { FROM "Invoice" JOIN "InvoiceForward" ON "Invoice".id = "InvoiceForward"."invoiceId" WHERE ("Invoice"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} - AND "Invoice"."actionState" = 'PAID' + AND "Invoice"."actionState" = 'PAID' AND "Invoice"."actionType" = 'ZAP' ${userId ? Prisma.sql`AND "Invoice"."userId" = ${userId}` : Prisma.empty} GROUP BY "Invoice"."userId" HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${GUN_STREAK_THRESHOLD}` @@ -112,7 +112,7 @@ function getStreakQuery (type, userId) { JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId" JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id WHERE ("Withdrawl"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} - AND "Invoice"."actionState" = 'PAID' + AND "Invoice"."actionState" = 'PAID' AND "Invoice"."actionType" = 'ZAP' ${userId ? Prisma.sql`AND "Withdrawl"."userId" = ${userId}` : Prisma.empty} GROUP BY "Withdrawl"."userId" HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${HORSE_STREAK_THRESHOLD}` diff --git a/worker/territory.js b/worker/territory.js index d7a7f479f..e687b6125 100644 --- a/worker/territory.js +++ b/worker/territory.js @@ -1,6 +1,7 @@ import lnd from '@/api/lnd' import performPaidAction from '@/api/paidAction' import serialize from '@/api/resolvers/serial' +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { nextBillingWithGrace } from '@/lib/territory' import { datePivot } from '@/lib/time' @@ -36,7 +37,12 @@ export async function territoryBilling ({ data: { subName }, boss, models }) { try { const { result } = await performPaidAction('TERRITORY_BILLING', - { name: subName }, { models, me: sub.user, lnd, forceFeeCredits: true }) + { name: subName }, { + models, + me: sub.user, + lnd, + forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT + }) if (!result) { throw new Error('not enough fee credits to auto-renew territory') } diff --git a/worker/wallet.js b/worker/wallet.js index 138022cdd..4e15ce8ce 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -144,10 +144,8 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd return await paidActionPaid({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) } - // NOTE: confirm invoice prevents double confirmations (idempotent) - // ALSO: is_confirmed and is_held are mutually exclusive - // that is, a hold invoice will first be is_held but not is_confirmed - // and once it's settled it will be is_confirmed but not is_held + // XXX we need to keep this to allow production to migrate to new paidAction flow + // once all non-paidAction receive invoices are migrated, we can remove this const [[{ confirm_invoice: code }]] = await serialize([ models.$queryRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`, models.invoice.update({ where: { hash }, data: { confirmedIndex: inv.confirmed_index } }) @@ -171,26 +169,6 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd } return await paidActionHeld({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) } - // First query makes sure that after payment, JIT invoices are settled - // within 60 seconds or they will be canceled to minimize risk of - // force closures or wallets banning us. - // Second query is basically confirm_invoice without setting confirmed_at - // and without setting the user balance - // those will be set when the invoice is settled by user action - const expiresAt = new Date(Math.min(dbInv.expiresAt, datePivot(new Date(), { seconds: 60 }))) - return await serialize([ - models.$queryRaw` - INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) - VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${hash}), 21, true, ${expiresAt})`, - models.invoice.update({ - where: { hash }, - data: { - msatsReceived: Number(inv.received_mtokens), - expiresAt, - isHeld: true - } - }) - ], { models }) } if (inv.is_canceled) { @@ -309,8 +287,9 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo notifyWithdrawal(dbWdrwl.userId, wdrwl) const { request: bolt11, secret: preimage } = wdrwl.payment + logger?.ok( - `↙ payment received: ${formatSats(msatsToSats(Number(wdrwl.payment.mtokens)))}`, + `↙ payment received: ${formatSats(msatsToSats(paid))}`, { bolt11, preimage, @@ -414,7 +393,7 @@ export async function checkPendingDeposits (args) { const pendingDeposits = await models.invoice.findMany({ where: { confirmedAt: null, cancelled: false } }) for (const d of pendingDeposits) { try { - await checkInvoice({ data: { hash: d.hash }, ...args }) + await checkInvoice({ ...args, data: { hash: d.hash } }) await sleep(10) } catch { console.error('error checking invoice', d.hash) @@ -427,7 +406,7 @@ export async function checkPendingWithdrawals (args) { const pendingWithdrawals = await models.withdrawl.findMany({ where: { status: null } }) for (const w of pendingWithdrawals) { try { - await checkWithdrawal({ data: { hash: w.hash }, ...args }) + await checkWithdrawal({ ...args, data: { hash: w.hash } }) await sleep(10) } catch (err) { console.error('error checking withdrawal', w.hash, err) diff --git a/worker/weeklyPosts.js b/worker/weeklyPosts.js index ca96c73f4..275b23bab 100644 --- a/worker/weeklyPosts.js +++ b/worker/weeklyPosts.js @@ -1,12 +1,17 @@ import performPaidAction from '@/api/paidAction' -import { USER_ID } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { datePivot } from '@/lib/time' import gql from 'graphql-tag' export async function autoPost ({ data: item, models, apollo, lnd, boss }) { return await performPaidAction('ITEM_CREATE', { ...item, subName: 'meta', userId: USER_ID.sn, apiKey: true }, - { models, me: { id: USER_ID.sn }, lnd, forceFeeCredits: true }) + { + models, + me: { id: USER_ID.sn }, + lnd, + forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT + }) } export async function weeklyPost (args) { @@ -47,5 +52,10 @@ export async function payWeeklyPostBounty ({ data: { id }, models, apollo, lnd } await performPaidAction('ZAP', { id: winner.id, sats: item.bounty }, - { models, me: { id: USER_ID.sn }, lnd, forceFeeCredits: true }) + { + models, + me: { id: USER_ID.sn }, + lnd, + forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT + }) }