Skip to content

Commit

Permalink
Merge branch 'stackernews:master' into fix_video_loading_safari
Browse files Browse the repository at this point in the history
  • Loading branch information
Soxasora authored Nov 16, 2024
2 parents e16f87c + 79ada2a commit 22f494f
Show file tree
Hide file tree
Showing 54 changed files with 584 additions and 372 deletions.
1 change: 1 addition & 0 deletions api/paidAction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 21 additions & 27 deletions api/paidAction/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time'
import { PAID_ACTION_PAYMENT_METHODS, 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'
Expand All @@ -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,
Expand All @@ -27,7 +30,8 @@ export const paidActions = {
TERRITORY_UPDATE,
TERRITORY_BILLING,
TERRITORY_UNARCHIVE,
DONATE
DONATE,
RECEIVE
}

export default async function performPaidAction (actionType, args, incomingContext) {
Expand All @@ -52,8 +56,7 @@ export default async function performPaidAction (actionType, args, incomingConte
}
const context = {
...contextWithMe,
cost: await paidAction.getCost(args, contextWithMe),
sybilFeePercent: await paidAction.getSybilFeePercent?.(args, contextWithMe)
cost: await paidAction.getCost(args, contextWithMe)
}

// special case for zero cost actions
Expand Down Expand Up @@ -183,19 +186,25 @@ async function beginPessimisticAction (actionType, args, 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, sybilFeePercent, me } = incomingContext
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 userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
const contextWithSybilFeePercent = {
...incomingContext,
sybilFeePercent
}

const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, contextWithSybilFeePercent)
if (!userId) {
throw new NonInvoiceablePeerError()
}

await assertBelowMaxPendingInvoices(incomingContext)
await assertBelowMaxPendingInvoices(contextWithSybilFeePercent)

const description = await paidActions[actionType].describe(args, incomingContext)
const description = await paidActions[actionType].describe(args, contextWithSybilFeePercent)
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: cost,
feePercent: sybilFeePercent,
Expand All @@ -204,7 +213,7 @@ async function performP2PAction (actionType, args, incomingContext) {
}, { models, me, lnd })

const context = {
...incomingContext,
...contextWithSybilFeePercent,
invoiceArgs: {
bolt11: invoice,
wrappedBolt11: wrappedInvoice,
Expand Down Expand Up @@ -282,23 +291,6 @@ export async function retryPaidAction (actionType, args, incomingContext) {
}

const INVOICE_EXPIRE_SECS = 600
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100

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 class NonInvoiceablePeerError extends Error {
constructor () {
Expand All @@ -314,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')
Expand Down
65 changes: 65 additions & 0 deletions api/paidAction/lib/assert.js
Original file line number Diff line number Diff line change
@@ -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))
}`
)
}
}
84 changes: 84 additions & 0 deletions api/paidAction/receive.js
Original file line number Diff line number Diff line change
@@ -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}))`
}
14 changes: 5 additions & 9 deletions api/paidAction/zap.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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

Expand All @@ -18,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 () {
Expand Down
2 changes: 2 additions & 0 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -1033,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,
Expand Down Expand Up @@ -1282,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,
Expand Down
27 changes: 19 additions & 8 deletions api/resolvers/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,27 +217,38 @@ 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})`
)
}

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})`
)
Expand Down
2 changes: 2 additions & 0 deletions api/resolvers/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down
Loading

0 comments on commit 22f494f

Please sign in to comment.