diff --git a/api/lnd/index.js b/api/lnd/index.js index cc0906888f..e31212c07b 100644 --- a/api/lnd/index.js +++ b/api/lnd/index.js @@ -2,8 +2,6 @@ import { cachedFetcher } from '@/lib/fetch' import { toPositiveNumber } from '@/lib/format' import { authenticatedLndGrpc } from '@/lib/lnd' import { getIdentity, getHeight, getWalletInfo, getNode, getPayment, parsePaymentRequest } from 'ln-service' -import { datePivot } from '@/lib/time' -import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' const lnd = global.lnd || authenticatedLndGrpc({ cert: process.env.LND_CERT, @@ -189,8 +187,7 @@ export async function getPaymentOrNotSent ({ id, lnd, createdAt }) { try { return await getPayment({ id, lnd }) } catch (err) { - if (err[1] === 'SentPaymentNotFound' && - createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) { + if (err[1] === 'SentPaymentNotFound') { // if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it return { notSent: true, is_failed: true } } else { diff --git a/api/paidAction/territoryUpdate.js b/api/paidAction/territoryUpdate.js index 75e1c6c4a3..7997b8cb9b 100644 --- a/api/paidAction/territoryUpdate.js +++ b/api/paidAction/territoryUpdate.js @@ -62,6 +62,7 @@ export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx } }) } + // TODO: this is nasty await throwOnExpiredUploads(data.uploadIds, { tx }) if (data.uploadIds.length > 0) { await tx.upload.updateMany({ diff --git a/api/payIn/README.md b/api/payIn/README.md new file mode 100644 index 0000000000..a325880763 --- /dev/null +++ b/api/payIn/README.md @@ -0,0 +1,371 @@ +# Paid Actions + +Paid actions are actions that require payments to perform. Given that we support several payment flows, some of which require more than one round of communication either with LND or the client, and several paid actions, we have this plugin-like interface to easily add new paid actions. + +
+ internals + +All paid action progress, regardless of flow, is managed using a state machine that's transitioned by the invoice progress and payment progress (in the case of p2p paid action). Below is the full state machine for paid actions: + +```mermaid +stateDiagram-v2 + [*] --> PENDING + PENDING --> PAID + PENDING --> CANCELING + PENDING --> FAILED + PAID --> [*] + CANCELING --> FAILED + FAILED --> RETRYING + FAILED --> [*] + RETRYING --> [*] + [*] --> PENDING_HELD + PENDING_HELD --> HELD + PENDING_HELD --> FORWARDING + PENDING_HELD --> CANCELING + PENDING_HELD --> FAILED + HELD --> PAID + HELD --> CANCELING + HELD --> FAILED + FORWARDING --> FORWARDED + FORWARDING --> FAILED_FORWARD + FORWARDED --> PAID + FAILED_FORWARD --> CANCELING + FAILED_FORWARD --> FAILED +``` +
+ +## Payment Flows + +There are three payment flows: + +### Fee credits +The stacker has enough fee credits to pay for the action. This is the simplest flow and is similar to a normal request. + +### Optimistic +The optimistic flow is useful for actions that require immediate feedback to the client, but don't require the action to be immediately visible to everyone else. + +For paid actions that support it, if the stacker doesn't have enough fee credits, we store the action in a `PENDING` state on the server, which is visible only to the stacker, then return a payment request to the client. The client then pays the invoice however and whenever they wish, and the server monitors payment progress. If the payment succeeds, the action is executed fully becoming visible to everyone and is marked as `PAID`. Otherwise, the action is marked as `FAILED`, the client is notified the payment failed and the payment can be retried. + +
+ Internals + +Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress. + +```mermaid +stateDiagram-v2 + [*] --> PENDING + PENDING --> PAID + PENDING --> CANCELING + PENDING --> FAILED + PAID --> [*] + CANCELING --> FAILED + FAILED --> RETRYING + FAILED --> [*] + RETRYING --> [*] +``` +
+ +### Pessimistic +For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without performing the action and only storing the action's arguments. After the client pays the invoice, the server performs the action with original arguments. Pessimistic actions require the payment to complete before being visible to them and everyone else. + +Internally, pessimistic flows use hold invoices. If the action doesn't succeed, the payment is cancelled and it's as if the payment never happened (ie it's a lightning native refund mechanism). + +
+ Internals + +Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps. + +```mermaid +stateDiagram-v2 + PAID --> [*] + CANCELING --> FAILED + FAILED --> [*] + [*] --> PENDING_HELD + PENDING_HELD --> HELD + PENDING_HELD --> CANCELING + PENDING_HELD --> FAILED + HELD --> PAID + HELD --> CANCELING + HELD --> FAILED +``` +
+ +### Table of existing paid actions and their supported flows + +| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects | reward sats | p2p direct | +| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ | ----------- | ---------- | +| zaps | x | x | x | x | x | x | x | | | +| posts | x | x | x | x | x | | x | x | | +| comments | x | x | x | x | x | | x | x | | +| downzaps | x | x | | | x | | x | x | | +| poll votes | x | x | | | x | | | x | | +| territory actions | x | | x | | x | | | x | | +| donations | x | | x | x | x | | | x | | +| update posts | x | | x | | x | | x | x | | +| update comments | x | | x | | x | | x | x | | +| receive | | x | | | x | x | x | | x | +| buy fee credits | | | x | | x | | | x | | +| invite gift | 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. + +This works by requesting an invoice from the recipient's wallet and reusing the payment hash in a hold invoice paid to SN (to collect the sybil fee) which we serve to the sender. When the sender pays this wrapped invoice, we forward our own money to the recipient, who then reveals the preimage to us, allowing us to settle the wrapped invoice and claim the sender's funds. This effectively does what a lightning node does when forwarding a payment but allows us to do it at the application layer. + +
+ Internals + + Internally, p2p wrapped payments make use of the same paid action state machine but it's transitioned by both the incoming invoice payment progress *and* the outgoing invoice payment progress. + +```mermaid +stateDiagram-v2 + PAID --> [*] + CANCELING --> FAILED + FAILED --> RETRYING + FAILED --> [*] + RETRYING --> [*] + [*] --> PENDING_HELD + PENDING_HELD --> FORWARDING + PENDING_HELD --> CANCELING + PENDING_HELD --> FAILED + FORWARDING --> FORWARDED + FORWARDING --> FAILED_FORWARD + FORWARDED --> PAID + FAILED_FORWARD --> CANCELING + FAILED_FORWARD --> FAILED +``` +
+ +## Paid Action Interface + +Each paid action is implemented in its own file in the `paidAction` directory. Each file exports a module with the following properties: + +### Boolean flags +- `anonable`: can be performed anonymously + +### 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 + +All functions have the following signature: `function(args: Object, context: Object): Promise` + +- `getCost`: returns the cost of the action in msats as a `BigInt` +- `perform`: performs the action + - returns: an object with the result of the action as defined in the `graphql` schema + - if the action supports optimism and an `invoiceId` is provided, the action should be performed optimistically + - any action data that needs to be hidden while it's pending, should store in its rows a `PENDING` state along with its `invoiceId` + - it can optionally store in the invoice with the `invoiceId` the `actionId` to be able to link the action with the invoice regardless of retries +- `onPaid`: called when the action is paid + - if the action does not support optimism, this function is optional + - this function should be used to mark the rows created in `perform` as `PAID` and perform critical side effects of the action (like denormalizations) +- `nonCriticalSideEffects`: called after the action is paid to run any side effects whose failure does not affect the action's execution + - this function is always optional + - it's passed the result of the action (or the action's paid invoice) and the current context + - this is where things like push notifications should be handled +- `onFail`: called when the action fails + - if the action does not support optimism, this function is optional + - this function should be used to mark the rows created in `perform` as `FAILED` +- `retry`: called when the action is retried with any new invoice information + - return: an object with the result of the action as defined in the `graphql` schema (same as `perform`) + - this function is called when an optimistic action is retried + - it's passed the original `invoiceId` and the `newInvoiceId` + - this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING` +- `getInvoiceablePeer`: returns the userId of the peer that's capable of generating an invoice so they can be paid for the action + - this is only used for p2p wrapped zaps currently +- `describe`: returns a description as a string of the action + - for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description +- `getSybilFeePercent` (required if `getInvoiceablePeer` is implemented): returns the action sybil fee percent as a `BigInt` (eg. 30n for 30%) + +#### Function arguments + +`args` contains the arguments for the action as defined in the `graphql` schema. If the action is optimistic or pessimistic, `args` will contain an `invoiceId` field which can be stored alongside the paid action's data. If this is a call to `retry`, `args` will contain the original `invoiceId` and `newInvoiceId` fields. + +`context` contains the following fields: +- `me`: the user performing the action (undefined if anonymous) +- `cost`: the cost of the action in msats as a `BigInt` +- `sybilFeePercent`: the sybil fee percent as a `BigInt` (eg. 30n for 30%) +- `tx`: the current transaction (for anything that needs to be done atomically with the payment) +- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment) +- `lnd`: the current lnd client + +## Recording Cowboy Credits + +To avoid adding sats and credits together everywhere to show an aggregate sat value, in most cases we denormalize a `sats` field that carries the "sats value", the combined sats + credits of something, and a `credits` field that carries only the earned `credits`. For example, the `Item` table has an `msats` field that carries the sum of the `mcredits` and `msats` earned and a `mcredits` field that carries the value of the `mcredits` earned. So, the sats value an item earned is `item.msats` BUT the real sats earned is `item.msats - item.mcredits`. + +The ONLY exception to this are for the `users` table where we store a stacker's rewards sats and credits balances separately. + +## `IMPORTANT: transaction isolation` + +We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies). + +### This is a big deal +1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that. +2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction: + - independent statements + - `WITH` queries (CTEs) in the same statement + - subqueries in the same statement + +### How to handle it +1. take row level locks on the rows you read, using something like a `SELECT ... FOR UPDATE` statement + - NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read. + - read about row level locks available in postgres: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS +2. check that the data you read is still valid before writing it back to the database i.e. optimistic concurrency control + - NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read. +3. avoid having to read data from one row to modify the data of another row all together + +### Example + +Let's say you are aggregating total sats for an item from a table `zaps` and updating the total sats for that item in another table `item_zaps`. Two 100 sat zaps are requested for the same item at the same time in two concurrent transactions. The total sats for the item should be 200, but because of the way `read committed` works, the following statements lead to a total sats of 100: + +*the statements here are listed in the order they are executed, but each transaction is happening concurrently* + +#### Incorrect + +```sql +-- transaction 1 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1; +-- total_sats is 100 +-- transaction 2 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1; +-- total_sats is still 100, because transaction 1 hasn't committed yet +-- transaction 1 +UPDATE item_zaps SET sats = total_sats WHERE item_id = 1; +-- sets sats to 100 +-- transaction 2 +UPDATE item_zaps SET sats = total_sats WHERE item_id = 1; +-- sets sats to 100 +COMMIT; +-- transaction 1 +COMMIT; +-- item_zaps.sats is 100, but we would expect it to be 200 +``` + +Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions don't know to exist yet. + +#### Subqueries are still incorrect + +```sql +-- transaction 1 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1; +-- item_zaps.sats is 100 +-- transaction 2 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1; +-- item_zaps.sats is still 100, because transaction 1 hasn't committed yet +-- transaction 1 +COMMIT; +-- transaction 2 +COMMIT; +-- item_zaps.sats is 100, but we would expect it to be 200 +``` + +Note that while the `UPDATE` transaction 2's update statement will block until transaction 1 commits, the subquery is computed before it blocks and is not re-evaluated after the block. + +#### Correct + +```sql +-- transaction 1 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +-- transaction 2 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +-- transaction 1 +UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1; +-- transaction 2 +UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1; +COMMIT; +-- transaction 1 +COMMIT; +-- item_zaps.sats is 200 +``` + +The above works because `UPDATE` takes a lock on the rows it's updating, so transaction 2 will block until transaction 1 commits, and once transaction 2 is unblocked, it will re-evaluate the `sats` value of the row it's updating. + +#### More resources +- https://stackoverflow.com/questions/61781595/postgres-read-commited-doesnt-re-read-updated-row?noredirect=1#comment109279507_61781595 +- https://www.cybertec-postgresql.com/en/transaction-anomalies-with-select-for-update/ + +From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED): +> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client. + +From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350): +> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows. + +## `IMPORTANT: deadlocks` + +Deadlocks can occur when two transactions are waiting for each other to release locks. This can happen when two transactions lock rows in different orders whether explicit or implicit. + +If both transactions lock the rows in the same order, the deadlock is avoided. + +### Incorrect + +```sql +-- transaction 1 +BEGIN; +UPDATE users set msats = msats + 1 WHERE id = 1; +-- transaction 2 +BEGIN; +UPDATE users set msats = msats + 1 WHERE id = 2; +-- transaction 1 (blocks here until transaction 2 commits) +UPDATE users set msats = msats + 1 WHERE id = 2; +-- transaction 2 (blocks here until transaction 1 commits) +UPDATE users set msats = msats + 1 WHERE id = 1; +-- deadlock occurs because neither transaction can proceed to here +``` + +In practice, this most often occurs when selecting multiple rows for update in different orders. Recently, we had a deadlock when spliting zaps to multiple users. The solution was to select the rows for update in the same order. + +### Incorrect + +```sql +WITH forwardees AS ( + SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats + FROM "ItemForward" + WHERE "itemId" = $2::INTEGER +), +UPDATE users + SET + msats = users.msats + forwardees.msats, + "stackedMsats" = users."stackedMsats" + forwardees.msats + FROM forwardees + WHERE users.id = forwardees."userId"; +``` + +If forwardees are selected in a different order in two concurrent transactions, e.g. (1,2) in tx 1 and (2,1) in tx 2, a deadlock can occur. To avoid this, always select rows for update in the same order. + +### Correct + +We fixed the deadlock by selecting the forwardees in the same order in these transactions. + +```sql +WITH forwardees AS ( + SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats + FROM "ItemForward" + WHERE "itemId" = $2::INTEGER + ORDER BY "userId" ASC +), +UPDATE users + SET + msats = users.msats + forwardees.msats, + "stackedMsats" = users."stackedMsats" + forwardees.msats + FROM forwardees + WHERE users.id = forwardees."userId"; +``` + +### More resources + +- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS diff --git a/api/payIn/errors.js b/api/payIn/errors.js new file mode 100644 index 0000000000..4b116e3faf --- /dev/null +++ b/api/payIn/errors.js @@ -0,0 +1,7 @@ +export class PayInFailureReasonError extends Error { + constructor (message, payInFailureReason) { + super(message) + this.name = 'PayInFailureReasonError' + this.payInFailureReason = payInFailureReason + } +} diff --git a/api/payIn/index.js b/api/payIn/index.js new file mode 100644 index 0000000000..4608e5093c --- /dev/null +++ b/api/payIn/index.js @@ -0,0 +1,340 @@ +import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, USER_ID } from '@/lib/constants' +import { Prisma } from '@prisma/client' +import { payViaPaymentRequest } from 'ln-service' +import lnd from '../lnd' +import payInTypeModules from './types' +import { msatsToSats } from '@/lib/format' +import { payInBolt11Prospect, payInBolt11WrapProspect } from './lib/payInBolt11' +import { isPessimistic, isWithdrawal } from './lib/is' +import { PAY_IN_INCLUDE, payInCreate } from './lib/payInCreate' +import { NoReceiveWalletError, payOutBolt11Replacement } from './lib/payOutBolt11' +import { payInClone } from './lib/payInPrisma' +import { createHmac } from '../resolvers/wallet' + +export default async function pay (payInType, payInArgs, { models, me }) { + try { + const payInModule = payInTypeModules[payInType] + + console.group('payIn', payInType, payInArgs) + + if (!payInModule) { + throw new Error(`Invalid payIn type ${payInType}`) + } + + if (!me && !payInModule.anonable) { + throw new Error('You must be logged in to perform this action') + } + + // TODO: need to double check all old usage of !me for detecting anon users + me ??= { id: USER_ID.anon } + + const payIn = await payInModule.getInitial(models, payInArgs, { me }) + return await begin(models, payIn, payInArgs, { me }) + } catch (e) { + console.error('payIn failed', e) + throw e + } finally { + console.groupEnd() + } +} + +// we lock all users in the payIn in order to avoid deadlocks with other payIns +// that might be competing to update the same users, e.g. two users simultaneously zapping each other +// https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE +// alternative approaches: +// 1. do NOT lock all users, but use NOWAIT on users locks so that we can catch AND retry transactions that fail with a deadlock error +// 2. issue onPaid in a separate transaction, so that payInCustodialTokens and payOutCustodialTokens cannot be interleaved +async function obtainRowLevelLocks (tx, payInInitial) { + console.log('obtainRowLevelLocks', payInInitial) + const payOutUserIds = [...new Set(payInInitial.payOutCustodialTokens.map(t => t.userId)).add(payInInitial.userId)] + await tx.$executeRaw`SELECT * FROM users WHERE id IN (${Prisma.join(payOutUserIds)}) ORDER BY id ASC FOR UPDATE` +} + +async function begin (models, payInInitial, payInArgs, { me }) { + const { payIn, result, mCostRemaining } = await models.$transaction(async tx => { + await obtainRowLevelLocks(tx, payInInitial) + const { payIn, mCostRemaining } = await payInCreate(tx, payInInitial, payInArgs, { me }) + + // if it's pessimistic, we don't perform the action until the invoice is held + if (payIn.pessimisticEnv) { + return { + payIn, + mCostRemaining + } + } + + // if it's optimistic or already paid, we perform the action + const result = await onBegin(tx, payIn.id, payInArgs) + + // if it's already paid, we run onPaid and do payOuts in the same transaction + if (payIn.payInState === 'PAID') { + await onPaid(tx, payIn.id, payInArgs) + return { + payIn, + result, + mCostRemaining: 0n + } + } + + // TODO: create a job for this + tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ('checkPayIn', jsonb_build_object('payInId', ${payIn.id}::INTEGER), now() + INTERVAL '30 seconds', 1000)` + return { + payIn, + result, + mCostRemaining + } + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + + return await afterBegin(models, { payIn, result, mCostRemaining }, { me }) +} + +export async function onBegin (tx, payInId, payInArgs, benefactorResult) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { beneficiaries: true } }) + if (!payIn) { + throw new Error('PayIn not found') + } + + const result = await payInTypeModules[payIn.payInType].onBegin?.(tx, payIn.id, payInArgs, benefactorResult) + + for (const beneficiary of payIn.beneficiaries) { + await onBegin(tx, beneficiary.id, payInArgs, result) + } + + return result +} + +async function afterBegin (models, { payIn, result, mCostRemaining }, { me }) { + console.log('afterBegin', result, mCostRemaining) + async function afterInvoiceCreation ({ payInState, payInBolt11 }) { + const updatedPayIn = await models.payIn.update({ + where: { + id: payIn.id, + payInState: { in: ['PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP'] } + }, + data: { + payInState, + payInStateChangedAt: new Date(), + payInBolt11: { + create: payInBolt11 + } + }, + include: PAY_IN_INCLUDE + }) + // the HMAC is only returned during invoice creation + // this makes sure that only the person who created this invoice + // has access to the HMAC + updatedPayIn.payInBolt11.hmac = createHmac(updatedPayIn.payInBolt11.hash) + return { ...updatedPayIn, result: result ? { ...result, payIn: updatedPayIn } : undefined } + } + + try { + if (payIn.payInState === 'PAID') { + payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payIn.id).catch(console.error) + } else if (payIn.payInState === 'PENDING_INVOICE_CREATION') { + const payInBolt11 = await payInBolt11Prospect(models, payIn, + { msats: mCostRemaining, description: await payInTypeModules[payIn.payInType].describe(models, payIn.id) }) + return await afterInvoiceCreation({ + payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING', + payInBolt11 + }) + } else if (payIn.payInState === 'PENDING_INVOICE_WRAP') { + const payInBolt11 = await payInBolt11WrapProspect(models, payIn, + { msats: mCostRemaining, description: await payInTypeModules[payIn.payInType].describe(models, payIn.id) }) + return await afterInvoiceCreation({ + payInState: 'PENDING_HELD', + payInBolt11 + }) + } else if (payIn.payInState === 'PENDING_WITHDRAWAL') { + const { mtokens } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + payViaPaymentRequest({ + lnd, + request: payIn.payOutBolt11.bolt11, + max_fee: msatsToSats(mtokens), + pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, + confidence: LND_PATHFINDING_TIME_PREF_PPM + }).catch(console.error) + } else { + throw new Error('Invalid payIn begin state') + } + } catch (e) { + models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ( + 'payInFailed', + jsonb_build_object( + 'payInId', ${payIn.id}::INTEGER, + 'payInFailureReason', ${e.payInFailureReason ?? 'EXECUTION_FAILED'}), + now(), 1000)`.catch(console.error) + throw e + } + + return { ...payIn, result: result ? { ...result, payIn } : undefined } +} + +export async function onFail (tx, payInId) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payInCustodialTokens: true, beneficiaries: true } }) + if (!payIn) { + throw new Error('PayIn not found') + } + + // refund the custodial tokens + for (const payInCustodialToken of payIn.payInCustodialTokens) { + await tx.$executeRaw` + UPDATE users + SET msats = msats + ${payInCustodialToken.custodialTokenType === 'SATS' ? payInCustodialToken.mtokens : 0}, + mcredits = mcredits + ${payInCustodialToken.custodialTokenType === 'CREDITS' ? payInCustodialToken.mtokens : 0} + WHERE id = ${payIn.userId}` + } + + await payInTypeModules[payIn.payInType].onFail?.(tx, payInId) + for (const beneficiary of payIn.beneficiaries) { + await onFail(tx, beneficiary.id) + } +} + +export async function onPaid (tx, payInId) { + const payIn = await tx.payIn.findUnique({ + where: { id: payInId }, + include: { + // payOutCustodialTokens are ordered by userId, so that we can lock all users FOR UPDATE in order + // https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE + payOutCustodialTokens: { + orderBy: { userId: 'asc' } + }, + payOutBolt11: true, + beneficiaries: true, + pessimisticEnv: true + } + }) + if (!payIn) { + throw new Error('PayIn not found') + } + + for (const payOut of payIn.payOutCustodialTokens) { + // if the payOut is not for a user, it's a system payOut + if (!payOut.userId) { + continue + } + + await tx.$executeRaw` + WITH outuser AS ( + UPDATE users + SET msats = users.msats + ${payOut.custodialTokenType === 'SATS' ? payOut.mtokens : 0}, + "stackedMsats" = users."stackedMsats" + ${!isWithdrawal(payIn) ? payOut.mtokens : 0}, + mcredits = users.mcredits + ${payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0}, + "stackedMcredits" = users."stackedMcredits" + ${!isWithdrawal(payIn) && payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0} + WHERE users.id = ${payOut.userId} + RETURNING mcredits as "mcreditsAfter", msats as "msatsAfter" + ) + UPDATE "PayOutCustodialToken" + SET "mtokensAfter" = CASE WHEN "custodialTokenType" = 'SATS' THEN outuser."msatsAfter" ELSE outuser."mcreditsAfter" END + FROM outuser + WHERE "id" = ${payOut.id}` + } + + if (!isWithdrawal(payIn)) { + if (payIn.payOutBolt11) { + await tx.$queryRaw` + UPDATE users + SET "stackedMsats" = "stackedMsats" + ${payIn.payOutBolt11.msats} + WHERE id = ${payIn.payOutBolt11.userId}` + } + + // most paid actions are eligible for a cowboy hat streak + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data) + VALUES ('checkStreak', jsonb_build_object('id', ${payIn.userId}, 'type', 'COWBOY_HAT'))` + } + + const payInModule = payInTypeModules[payIn.payInType] + await payInModule.onPaid?.(tx, payInId) + for (const beneficiary of payIn.beneficiaries) { + await onPaid(tx, beneficiary.id) + } +} + +export async function onPaidSideEffects (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { beneficiaries: true } }) + if (!payIn) { + throw new Error('PayIn not found') + } + + await payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payInId) + for (const beneficiary of payIn.beneficiaries) { + await onPaidSideEffects(models, beneficiary.id) + } +} + +export async function retry (payInId, { models, me }) { + try { + const include = { payOutCustodialTokens: true, payOutBolt11: true } + const where = { id: payInId, userId: me.id, payInState: 'FAILED', successorId: null } + + const payInFailed = await models.payIn.findFirst({ + where, + include: { ...include, beneficiaries: { include } } + }) + if (!payInFailed) { + throw new Error('PayIn with id ' + payInId + ' not found') + } + if (isWithdrawal(payInFailed)) { + throw new Error('Withdrawal payIns cannot be retried') + } + if (isPessimistic(payInFailed, { me })) { + throw new Error('Pessimistic payIns cannot be retried') + } + + // TODO: if we can't produce a new payOutBolt11, we need to update the payOuts to use + // custodial tokens instead ... or don't clone the payIn, and just start from scratch + let payOutBolt11 + if (payInFailed.payOutBolt11) { + try { + payOutBolt11 = await payOutBolt11Replacement(models, payInFailed.genesisId ?? payInFailed.id, payInFailed.payOutBolt11) + } catch (e) { + console.error('payOutBolt11Replacement failed', e) + if (!(e instanceof NoReceiveWalletError)) { + throw e + } + } + } + + const { payIn, result, mCostRemaining } = await models.$transaction(async tx => { + const payInInitial = payInClone({ ...payInFailed, payOutBolt11 }) + await obtainRowLevelLocks(tx, payInInitial) + const { payIn, mCostRemaining } = await payInCreate(tx, payInInitial, undefined, { me }) + + // use an optimistic lock on successorId on the payIn + const rows = await tx.$queryRaw`UPDATE "PayIn" SET "successorId" = ${payIn.id} WHERE "id" = ${payInFailed.id} AND "successorId" IS NULL RETURNING *` + if (rows.length === 0) { + throw new Error('PayIn with id ' + payInFailed.id + ' is already being retried') + } + + // run the onRetry hook for the payIn and its beneficiaries + const result = await payInTypeModules[payIn.payInType].onRetry?.(tx, payInFailed.id, payIn.id) + for (const beneficiary of payIn.beneficiaries) { + await payInTypeModules[beneficiary.payInType].onRetry?.(tx, beneficiary.id, payIn.id) + } + + // if it's already paid, we run onPaid and do payOuts in the same transaction + if (payIn.payInState === 'PAID') { + await onPaid(tx, payIn.id, { me }) + return { + payIn, + result, + mCostRemaining: 0n + } + } + + return { + payIn, + result, + mCostRemaining + } + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + + return await afterBegin(models, { payIn, result, mCostRemaining }, { me }) + } catch (e) { + console.error('retry failed', e) + throw e + } +} diff --git a/api/payIn/lib/assert.js b/api/payIn/lib/assert.js new file mode 100644 index 0000000000..72900f20b6 --- /dev/null +++ b/api/payIn/lib/assert.js @@ -0,0 +1,15 @@ +const MAX_PENDING_PAY_IN_BOLT_11_PER_USER = 100 + +export async function assertBelowMaxPendingPayInBolt11s (models, userId) { + const pendingBolt11s = await models.payInBolt11.count({ + where: { + userId, + confirmedAt: null, + cancelledAt: null + } + }) + + if (pendingBolt11s >= MAX_PENDING_PAY_IN_BOLT_11_PER_USER) { + throw new Error('You have too many pending paid actions, cancel some or wait for them to expire') + } +} diff --git a/api/payIn/lib/is.js b/api/payIn/lib/is.js new file mode 100644 index 0000000000..36c77d2a14 --- /dev/null +++ b/api/payIn/lib/is.js @@ -0,0 +1,44 @@ +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import payInTypeModules from '../types' + +export const PAY_IN_RECEIVER_FAILURE_REASONS = [ + 'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE', + 'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY', + 'INVOICE_WRAPPING_FAILED_UNKNOWN', + 'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW', + 'INVOICE_FORWARDING_FAILED' +] + +export function isPessimistic (payIn, { me }) { + const payInModule = payInTypeModules[payIn.payInType] + return !me || me.id === USER_ID.anon || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) +} + +export function isPayableWithCredits (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) +} + +export function isInvoiceable (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) || + payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) || + payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) +} + +export function isP2P (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) +} + +export function isProxyPayment (payIn) { + return payIn.payInType === 'PROXY_PAYMENT' +} + +export function isWithdrawal (payIn) { + return payIn.payInType === 'WITHDRAWAL' || payIn.payInType === 'AUTO_WITHDRAWAL' +} + +export function isReceiverFailure (payInFailureReason) { + return PAY_IN_RECEIVER_FAILURE_REASONS.includes(payInFailureReason) +} diff --git a/api/payIn/lib/item.js b/api/payIn/lib/item.js new file mode 100644 index 0000000000..f4222fab8f --- /dev/null +++ b/api/payIn/lib/item.js @@ -0,0 +1,115 @@ +import { USER_ID } from '@/lib/constants' +import { deleteReminders, getDeleteAt, getRemindAt } from '@/lib/item' +import { parseInternalLinks } from '@/lib/url' + +export async function getSub (models, { subName, parentId }) { + if (!subName && !parentId) { + return null + } + + if (parentId) { + const [sub] = await models.$queryRaw` + SELECT "Sub".* + FROM "Item" i + LEFT JOIN "Item" r ON r.id = i."rootId" + JOIN "Sub" ON "Sub".name = COALESCE(r."subName", i."subName") + WHERE i.id = ${Number(parentId)}` + + return sub + } + + return await models.sub.findUnique({ where: { name: subName } }) +} + +// ltree is unsupported in Prisma, so we have to query it manually (FUCK!) +export async function getItemResult (tx, { id }) { + return (await tx.$queryRaw` + SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" + FROM "Item" WHERE id = ${id}::INTEGER` + )[0] +} + +export async function getMentions (tx, { text, userId }) { + const mentionPattern = /\B@[\w_]+/gi + const names = text.match(mentionPattern)?.map(m => m.slice(1)) + if (names?.length > 0) { + const users = await tx.user.findMany({ + where: { + name: { + in: names + }, + id: { + not: userId || USER_ID.anon + } + } + }) + return users.map(user => ({ userId: user.id })) + } + return [] +} + +export const getItemMentions = async (tx, { text, userId }) => { + const linkPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+[a-zA-Z0-9/?=]*`, 'gi') + const refs = text.match(linkPattern)?.map(m => { + try { + const { itemId, commentId } = parseInternalLinks(m) + return Number(commentId || itemId) + } catch (err) { + return null + } + }).filter(r => !!r) + + if (refs?.length > 0) { + const referee = await tx.item.findMany({ + where: { + id: { in: refs }, + userId: { not: userId || USER_ID.anon } + } + }) + return referee.map(r => ({ refereeId: r.id })) + } + + return [] +} + +export async function performBotBehavior (tx, { text, id, userId = USER_ID.anon }) { + // delete any existing deleteItem or reminder jobs for this item + id = Number(id) + await tx.$queryRaw` + DELETE FROM pgboss.job + WHERE name = 'deleteItem' + AND data->>'id' = ${id}::TEXT + AND state <> 'completed'` + await deleteReminders({ id, userId, models: tx }) + + if (text) { + const deleteAt = getDeleteAt(text) + if (deleteAt) { + await tx.$queryRaw` + INSERT INTO pgboss.job (name, data, startafter, keepuntil) + VALUES ( + 'deleteItem', + jsonb_build_object('id', ${id}::INTEGER), + ${deleteAt}::TIMESTAMP WITH TIME ZONE, + ${deleteAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')` + } + + const remindAt = getRemindAt(text) + if (remindAt) { + await tx.$queryRaw` + INSERT INTO pgboss.job (name, data, startafter, keepuntil) + VALUES ( + 'reminder', + jsonb_build_object('itemId', ${id}::INTEGER, 'userId', ${userId}::INTEGER), + ${remindAt}::TIMESTAMP WITH TIME ZONE, + ${remindAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')` + await tx.reminder.create({ + data: { + userId, + itemId: Number(id), + remindAt + } + }) + } + } +} diff --git a/api/payIn/lib/payInBolt11.js b/api/payIn/lib/payInBolt11.js new file mode 100644 index 0000000000..1aac70ae0c --- /dev/null +++ b/api/payIn/lib/payInBolt11.js @@ -0,0 +1,58 @@ +import { datePivot } from '@/lib/time' +import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' +import lnd from '@/api/lnd' +import { wrapBolt11 } from '@/wallets/server' +import { PayInFailureReasonError } from '../errors' + +const INVOICE_EXPIRE_SECS = 600 + +function payInBolt11FromBolt11 (bolt11, preimage) { + const decodedBolt11 = parsePaymentRequest({ request: bolt11 }) + const expiresAt = new Date(decodedBolt11.expires_at) + const msatsRequested = BigInt(decodedBolt11.mtokens) + return { + hash: decodedBolt11.id, + bolt11, + msatsRequested, + expiresAt, + preimage + } +} + +export async function payInBolt11Prospect (models, payIn, { msats, description }) { + try { + const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice + const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) + const invoice = await createLNDinvoice({ + description: payIn.user.hideInvoiceDesc ? undefined : description, + mtokens: String(msats), + expires_at: expiresAt, + lnd + }) + + return payInBolt11FromBolt11(invoice.request, invoice.secret) + } catch (e) { + console.error('failed to create invoice', e) + throw new PayInFailureReasonError('Invoice creation failed', 'INVOICE_CREATION_FAILED') + } +} + +export async function payInBolt11WrapProspect (models, payIn, { msats, description }) { + try { + const { mtokens: maxRoutingFeeMsats } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + const bolt11 = await wrapBolt11({ + msats, + maxRoutingFeeMsats, + bolt11: payIn.payOutBolt11.bolt11, + hideInvoiceDesc: payIn.user.hideInvoiceDesc, + description + }) + return payInBolt11FromBolt11(bolt11) + } catch (e) { + console.error('failed to wrap invoice', e) + if (e instanceof PayInFailureReasonError) { + throw e + } + throw new PayInFailureReasonError('Invoice wrapping failed', 'INVOICE_WRAPPING_FAILED_UNKNOWN') + } +} diff --git a/api/payIn/lib/payInCreate.js b/api/payIn/lib/payInCreate.js new file mode 100644 index 0000000000..8212129d27 --- /dev/null +++ b/api/payIn/lib/payInCreate.js @@ -0,0 +1,78 @@ +import { assertBelowMaxPendingPayInBolt11s } from './assert' +import { isInvoiceable, isPessimistic, isWithdrawal } from './is' +import { getCostBreakdown, getPayInCustodialTokens } from './payInCustodialTokens' +import { payInPrismaCreate } from './payInPrisma' + +export const PAY_IN_INCLUDE = { + payInCustodialTokens: true, + payOutBolt11: true, + payInBolt11: true, + pessimisticEnv: true, + user: true, + payOutCustodialTokens: true, + beneficiaries: true +} + +// TODO: before we create, validate the payIn such that +// 1. the payIn amounts are enough to cover the payOuts +// ... and other invariants are met +export async function payInCreate (tx, payInProspect, payInArgs, { me }) { + const { mCostRemaining, mP2PCost, payInCustodialTokens } = await getPayInCosts(tx, payInProspect, { me }) + const payInState = await getPayInState(payInProspect, { mCostRemaining, mP2PCost }) + if (!isWithdrawal(payInProspect) && payInState !== 'PAID') { + await assertBelowMaxPendingPayInBolt11s(tx, payInProspect.userId) + } + console.log('payInProspect', payInProspect) + const payIn = await tx.payIn.create({ + data: { + ...payInPrismaCreate({ + ...payInProspect, + payInState, + payInStateChangedAt: new Date(), + payInCustodialTokens + }), + pessimisticEnv: { + create: isPessimistic(payInProspect, { me }) && !isWithdrawal(payInProspect) && payInState !== 'PAID' ? { args: payInArgs } : undefined + } + }, + include: PAY_IN_INCLUDE + }) + console.log('payIn', payIn) + return { payIn, mCostRemaining } +} + +async function getPayInCosts (tx, payIn, { me }) { + const { mP2PCost, mCustodialCost } = getCostBreakdown(payIn) + const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, payIn, { me }) + console.log('payInCustodialTokens', payInCustodialTokens) + const mCustodialPaid = payInCustodialTokens.reduce((acc, token) => acc + token.mtokens, 0n) + + return { + mP2PCost, + mCustodialCost, + mCustodialPaid, + // TODO: how to deal with < 1000msats? + mCostRemaining: mCustodialCost - mCustodialPaid + mP2PCost, + payInCustodialTokens + } +} + +async function getPayInState (payIn, { mCostRemaining, mP2PCost }) { + if (mCostRemaining > 0n) { + if (!isInvoiceable(payIn)) { + throw new Error('Insufficient funds') + } + + if (mP2PCost > 0n) { + return 'PENDING_INVOICE_WRAP' + } else { + return 'PENDING_INVOICE_CREATION' + } + } + + if (isWithdrawal(payIn)) { + return 'PENDING_WITHDRAWAL' + } + + return 'PAID' +} diff --git a/api/payIn/lib/payInCustodialTokens.js b/api/payIn/lib/payInCustodialTokens.js new file mode 100644 index 0000000000..ea7e316c7c --- /dev/null +++ b/api/payIn/lib/payInCustodialTokens.js @@ -0,0 +1,75 @@ +import { isP2P, isPayableWithCredits, isProxyPayment } from './is' +import { USER_ID } from '@/lib/constants' + +export async function getPayInCustodialTokens (tx, mCustodialCost, payIn, { me }) { + const payInCustodialTokens = [] + + if (!me || me.id === USER_ID.anon || mCustodialCost <= 0n) { + return payInCustodialTokens + } + + // we always want to return mcreditsBefore, even if we don't spend any credits + const mCreditPayable = isPayableWithCredits(payIn) ? mCustodialCost : 0n + const [{ mcreditsSpent, mcreditsAfter }] = await tx.$queryRaw` + UPDATE users + SET mcredits = CASE + WHEN users.mcredits >= ${mCreditPayable} THEN users.mcredits - ${mCreditPayable} + ELSE users.mcredits - ((users.mcredits / 1000) * 1000) + END + FROM (SELECT id, mcredits FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING before.mcredits - users.mcredits as "mcreditsSpent", users.mcredits as "mcreditsAfter"` + if (mcreditsSpent > 0n) { + payInCustodialTokens.push({ + custodialTokenType: 'CREDITS', + mtokens: mcreditsSpent, + mtokensAfter: mcreditsAfter + }) + } + mCustodialCost -= mcreditsSpent + + const [{ msatsSpent, msatsAfter }] = await tx.$queryRaw` + UPDATE users + SET msats = CASE + WHEN users.msats >= ${mCustodialCost} THEN users.msats - ${mCustodialCost} + ELSE users.msats - ((users.msats / 1000) * 1000) + END + FROM (SELECT id, msats FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING before.msats - users.msats as "msatsSpent", users.msats as "msatsAfter"` + if (msatsSpent > 0n) { + payInCustodialTokens.push({ + custodialTokenType: 'SATS', + mtokens: msatsSpent, + mtokensAfter: msatsAfter + }) + } + + return payInCustodialTokens +} + +function getP2PCost (payIn) { + // proxy payments are only ever paid for with sats + if (isProxyPayment(payIn)) { + return payIn.mcost + } + if (isP2P(payIn)) { + return payIn.payOutBolt11?.msats ?? 0n + } + return 0n +} + +function getTotalCost (payIn) { + const { beneficiaries = [] } = payIn + return payIn.mcost + beneficiaries.reduce((acc, b) => acc + b.mcost, 0n) +} + +export function getCostBreakdown (payIn) { + const mP2PCost = getP2PCost(payIn) + const mCustodialCost = getTotalCost(payIn) - mP2PCost + + return { + mP2PCost, + mCustodialCost + } +} diff --git a/api/payIn/lib/payInPrisma.js b/api/payIn/lib/payInPrisma.js new file mode 100644 index 0000000000..69fc459798 --- /dev/null +++ b/api/payIn/lib/payInPrisma.js @@ -0,0 +1,86 @@ +export function payInPrismaCreate (payIn) { + console.log('payInPrismaCreate', payIn) + const result = {} + + if (Array.isArray(payIn.beneficiaries)) { + payIn.beneficiaries = payIn.beneficiaries.map(beneficiary => { + if (beneficiary.payOutBolt11) { + throw new Error('Beneficiary payOutBolt11 not supported') + } + if (beneficiary.beneficiaries) { + throw new Error('Beneficiary beneficiaries not supported') + } + return { + ...beneficiary, + payInState: payIn.payInState, + payInStateChangedAt: payIn.payInStateChangedAt + } + }) + } + + // for each key in payIn, if the value is an object, recursively call payInPrismaCreate on the value + // if the value is an array, recursively call payInPrismaCreate on each element of the array + // if the value is not an object or array, add the key and value to the result + for (const key in payIn) { + console.log('key', key, typeof payIn[key]) + if (Array.isArray(payIn[key])) { + result[key] = { create: payIn[key].map(item => payInPrismaCreate(item)) } + } else if (isPlainObject(payIn[key])) { + result[key] = { create: payInPrismaCreate(payIn[key]) } + } else if (payIn[key] !== undefined) { + result[key] = payIn[key] + } + } + + return result +} + +// from the top level PayIn and beneficiaries, we just want mcost, payIntype, userId, genesisId and arrays and objects nested within +// from the nested arrays and objects, we want anything but the payInId +// do all of it recursively + +export function payInClone (payIn) { + const result = { + mcost: payIn.mcost, + payInType: payIn.payInType, + userId: payIn.userId, + genesisId: payIn.genesisId ?? payIn.id + } + for (const key in payIn) { + console.log('payInClone', key, typeof payIn[key], payIn[key]) + if (Array.isArray(payIn[key])) { + if (key === 'beneficiaries') { + result[key] = payIn[key].map(beneficiary => payInClone(beneficiary)) + } else { + result[key] = payIn[key].map(item => payInCloneNested(item)) + } + } else if (isPlainObject(payIn[key])) { + result[key] = payInCloneNested(payIn[key]) + } + } + return result +} + +// this assumes that any other nested object only has a payInId or id that should be ignored +function payInCloneNested (payInNested) { + const result = {} + for (const key in payInNested) { + if (Array.isArray(payInNested[key])) { + result[key] = payInNested[key].map(item => payInCloneNested(item)) + } else if (isPlainObject(payInNested[key])) { + result[key] = payInCloneNested(payInNested[key]) + } else if (key !== 'payInId' && key !== 'id') { + result[key] = payInNested[key] + } + } + return result +} + +function isPlainObject (value) { + if (typeof value !== 'object' || value === null) { + return false + } + + const prototype = Object.getPrototypeOf(value) + return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(Symbol.toStringTag in value) && !(Symbol.iterator in value) +} diff --git a/api/payIn/lib/payOutBolt11.js b/api/payIn/lib/payOutBolt11.js new file mode 100644 index 0000000000..940720fc86 --- /dev/null +++ b/api/payIn/lib/payOutBolt11.js @@ -0,0 +1,68 @@ +import { parsePaymentRequest } from 'ln-service' +import { PAY_IN_RECEIVER_FAILURE_REASONS } from './is' +import { createBolt11FromWalletProtocols } from '@/wallets/server/receive' +import { Prisma } from '@prisma/client' + +async function getLeastFailedWalletProtocols (models, { genesisId, userId }) { + return await models.$queryRaw` + WITH "failedWallets" AS ( + SELECT count(*) as "failedCount", "PayOutBolt11"."protocolId" + FROM "PayIn" + JOIN "PayOutBolt11" ON "PayOutBolt11"."payInId" = "PayIn"."id" + WHERE "PayIn"."payInFailureReason" IS NOT NULL AND "PayIn"."genesisId" = ${genesisId} + AND "PayIn"."payInFailureReason" IN (${Prisma.join(PAY_IN_RECEIVER_FAILURE_REASONS.map(r => Prisma.sql`${r}::"PayInFailureReason"`))}) + GROUP BY "PayOutBolt11"."protocolId" + ) + SELECT "WalletProtocol".*, "Wallet"."userId" as "userId" + FROM "WalletProtocol" + JOIN "Wallet" ON "Wallet"."id" = "WalletProtocol"."walletId" + LEFT JOIN "failedWallets" ON "failedWallets"."protocolId" = "WalletProtocol"."id" + WHERE "Wallet"."userId" = ${userId} AND "WalletProtocol"."enabled" = true AND "WalletProtocol"."send" = false + ORDER BY "failedWallets"."failedCount" ASC, "Wallet"."priority" ASC` +} + +async function getWalletProtocols (models, { userId }) { + return await models.$queryRaw` + SELECT "WalletProtocol".*, "Wallet"."userId" as "userId" + FROM "WalletProtocol" + JOIN "Wallet" ON "Wallet"."id" = "WalletProtocol"."walletId" + WHERE "Wallet"."userId" = ${userId} AND "WalletProtocol"."enabled" = true AND "WalletProtocol"."send" = false + ORDER BY "Wallet"."priority" ASC` +} + +export class NoReceiveWalletError extends Error { + constructor (message) { + super(message) + this.name = 'NoReceiveWalletError' + } +} + +async function createPayOutBolt11FromWalletProtocols (walletProtocols, bolt11Args, { payOutType, userId }, { models }) { + for await (const { bolt11, protocol } of createBolt11FromWalletProtocols(walletProtocols, bolt11Args, { models })) { + try { + const invoice = await parsePaymentRequest({ request: bolt11 }) + return { + payOutType, + msats: BigInt(invoice.mtokens), + bolt11, + hash: invoice.id, + userId, + protocolId: protocol.id + } + } catch (err) { + console.error('failed to create pay out bolt11:', err) + } + } + + throw new NoReceiveWalletError('no wallet to receive available') +} + +export async function payOutBolt11Replacement (models, genesisId, { payOutType, userId, msats }) { + const walletProtocols = await getLeastFailedWalletProtocols(models, { genesisId, userId }) + return await createPayOutBolt11FromWalletProtocols(walletProtocols, { msats }, { payOutType, userId }, { models }) +} + +export async function payOutBolt11Prospect (models, bolt11Args, { payOutType, userId }) { + const walletProtocols = await getWalletProtocols(models, { userId }) + return await createPayOutBolt11FromWalletProtocols(walletProtocols, bolt11Args, { payOutType, userId }, { models }) +} diff --git a/api/payIn/lib/payOutCustodialTokens.js b/api/payIn/lib/payOutCustodialTokens.js new file mode 100644 index 0000000000..b73dd9bd3e --- /dev/null +++ b/api/payIn/lib/payOutCustodialTokens.js @@ -0,0 +1,35 @@ +export function getRedistributedPayOutCustodialTokens ({ sub, payOutCustodialTokens = [], payOutBolt11 = { msats: 0n }, mcost }) { + const remainingMtokens = mcost - payOutBolt11.msats - payOutCustodialTokens.reduce((acc, token) => acc + token.mtokens, 0n) + if (remainingMtokens < 0n) { + throw new Error('remaining mtokens is less than 0') + } + + if (remainingMtokens === 0n) { + return [...payOutCustodialTokens] + } + + const payOutCustodialTokensCopy = [...payOutCustodialTokens] + let revenueMtokens = 0n + if (sub) { + revenueMtokens = remainingMtokens * (100n - BigInt(sub.rewardsPct)) / 100n + payOutCustodialTokensCopy.push({ + payOutType: 'TERRITORY_REVENUE', + userId: sub.userId, + mtokens: revenueMtokens, + custodialTokenType: 'SATS', + subPayOutCustodialToken: { + subName: sub.name + } + }) + } + + const rewardMtokens = remainingMtokens - revenueMtokens + payOutCustodialTokensCopy.push({ + payOutType: 'REWARDS_POOL', + userId: null, + mtokens: rewardMtokens, + custodialTokenType: 'SATS' + }) + + return payOutCustodialTokensCopy +} diff --git a/api/payIn/lib/territory.js b/api/payIn/lib/territory.js new file mode 100644 index 0000000000..849ff11c19 --- /dev/null +++ b/api/payIn/lib/territory.js @@ -0,0 +1,27 @@ +import { USER_ID } from '@/lib/constants' + +export const GLOBAL_SEEDS = [USER_ID.k00b, USER_ID.ek] + +export function initialTrust ({ name, userId }) { + const results = GLOBAL_SEEDS.map(id => ({ + subName: name, + userId: id, + zapPostTrust: 1, + subZapPostTrust: 1, + zapCommentTrust: 1, + subZapCommentTrust: 1 + })) + + if (!GLOBAL_SEEDS.includes(userId)) { + results.push({ + subName: name, + userId, + zapPostTrust: 0, + subZapPostTrust: 1, + zapCommentTrust: 0, + subZapCommentTrust: 1 + }) + } + + return results +} diff --git a/api/payIn/transitions.js b/api/payIn/transitions.js new file mode 100644 index 0000000000..7acb7b7e4f --- /dev/null +++ b/api/payIn/transitions.js @@ -0,0 +1,581 @@ +import { datePivot } from '@/lib/time' +import { Prisma } from '@prisma/client' +import { onBegin, onFail, onPaid } from '.' +import { walletLogger } from '@/wallets/server/logger' +import payInTypeModules from './types' +import { getPaymentFailureStatus, getPaymentOrNotSent, hodlInvoiceCltvDetails } from '../lnd' +import { cancelHodlInvoice, parsePaymentRequest, payViaPaymentRequest, settleHodlInvoice, getInvoice } from 'ln-service' +import { toPositiveNumber, formatSats, msatsToSats, toPositiveBigInt, formatMsats } from '@/lib/format' +import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/server/wrap' +import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' +import { notifyWithdrawal } from '@/lib/webPush' +import { PayInFailureReasonError } from './errors' +export const PAY_IN_TERMINAL_STATES = ['PAID', 'FAILED'] +const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 } + +async function transitionPayIn (jobName, data, + { payInId, fromStates, toState, transitionFunc, cancelOnError }, + { invoice, withdrawal, models, boss, lnd }) { + let payIn + + try { + const include = { payInBolt11: true, payInCustodialTokens: true, payOutBolt11: true, pessimisticEnv: true, payOutCustodialTokens: true, beneficiaries: true } + const currentPayIn = await models.payIn.findUnique({ where: { id: payInId }, include }) + + console.group(`${jobName}: transitioning payIn ${payInId} from ${fromStates} to ${toState}`) + console.log('currentPayIn', currentPayIn) + + if (PAY_IN_TERMINAL_STATES.includes(currentPayIn.payInState)) { + console.log('payIn is already in a terminal state, skipping transition') + return + } + + if (!Array.isArray(fromStates)) { + fromStates = [fromStates] + } + + let lndPayInBolt11 + if (currentPayIn.payInBolt11) { + lndPayInBolt11 = invoice ?? await getInvoice({ id: currentPayIn.payInBolt11.hash, lnd }) + } + + let lndPayOutBolt11 + if (currentPayIn.payOutBolt11) { + lndPayOutBolt11 = withdrawal ?? await getPaymentOrNotSent({ id: currentPayIn.payOutBolt11.hash, lnd }) + } + + const transitionedPayIn = await models.$transaction(async tx => { + payIn = await tx.payIn.update({ + where: { + id: payInId, + payInState: { in: fromStates } + }, + data: { + payInState: toState, + payInStateChangedAt: new Date(), + beneficiaries: { + updateMany: { + data: { + payInState: toState, + payInStateChangedAt: new Date() + }, + where: { + benefactorId: payInId + } + } + } + }, + include + }) + + if (!payIn) { + console.log('record not found in our own concurrency check, assuming concurrent worker transitioned it') + return + } + + const updateFields = await transitionFunc({ tx, payIn, lndPayInBolt11, lndPayOutBolt11 }) + + if (updateFields) { + return await tx.payIn.update({ + where: { id: payIn.id }, + data: updateFields, + include + }) + } + + return payIn + }, { + isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, + timeout: 60000 + }) + + if (transitionedPayIn) { + console.log('transition succeeded') + return transitionedPayIn + } + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === 'P2025') { + console.log('record not found, assuming concurrent worker transitioned it') + return + } + if (error.code === 'P2034') { + console.log('write conflict, assuming concurrent worker is transitioning it') + return + } + } + + console.error('unexpected error', error) + if (cancelOnError) { + models.pessimisticEnv.updateMany({ + where: { payInId }, + data: { + error: error.message + } + }).catch(e => console.error('failed to store payIn error', e)) + const reason = error instanceof PayInFailureReasonError + ? error.payInFailureReason + : 'EXECUTION_FAILED' + boss.send('payInCancel', { payInId, payInFailureReason: reason }, FINALIZE_OPTIONS) + .catch(e => console.error('failed to cancel payIn', e)) + } else { + // retry the job + boss.send( + jobName, + data, + { startAfter: datePivot(new Date(), { seconds: 30 }), priority: 1000 }) + .catch(e => console.error('failed to retry payIn', e)) + } + + console.error(`${jobName} failed for payIn ${payInId}: ${error}`) + throw error + } finally { + console.groupEnd() + } +} + +export async function payInWithdrawalPaid ({ data, models, ...args }) { + const { payInId } = data + + const transitionedPayIn = await transitionPayIn('payInWithdrawalPaid', data, { + payInId, + fromStates: 'PENDING_WITHDRAWAL', + toState: 'PAID', + transitionFunc: async ({ tx, payIn, lndPayOutBolt11 }) => { + if (!lndPayOutBolt11.is_confirmed) { + throw new Error('withdrawal is not confirmed') + } + + // refund the routing fee + const { mtokens, id: routingFeeId } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + await tx.payOutCustodialToken.update({ + where: { id: routingFeeId }, + data: { + mtokens: toPositiveBigInt(lndPayOutBolt11.payment.fee_mtokens) + } + }) + if (mtokens - toPositiveBigInt(lndPayOutBolt11.payment.fee_mtokens) > 0) { + await tx.payOutCustodialToken.create({ + data: { + mtokens: mtokens - toPositiveBigInt(lndPayOutBolt11.payment.fee_mtokens), + userId: payIn.userId, + payOutType: 'ROUTING_FEE_REFUND', + custodialTokenType: 'SATS', + payInId: payIn.id + } + }) + } + + await onPaid(tx, payIn.id) + + return { + payOutBolt11: { + update: { + status: 'CONFIRMED', + preimage: lndPayOutBolt11.payment.secret + } + } + } + } + }, { models, ...args }) + + if (transitionedPayIn) { + await notifyWithdrawal(transitionedPayIn) + const { payOutBolt11 } = transitionedPayIn + if (payOutBolt11?.protocolId) { + const logger = walletLogger({ protocolId: payOutBolt11.protocolId, userId: payOutBolt11.userId, models }) + logger?.ok( + `↙ payment received: ${formatSats(msatsToSats(payOutBolt11.msats))}`, { + withdrawalId: payOutBolt11.id + }) + } + } +} + +export async function payInWithdrawalFailed ({ data, models, ...args }) { + const { payInId } = data + let message + const transitionedPayIn = await transitionPayIn('payInWithdrawalFailed', data, { + payInId, + fromStates: 'PENDING_WITHDRAWAL', + toState: 'FAILED', + transitionFunc: async ({ tx, payIn, lndPayOutBolt11 }) => { + if (!lndPayOutBolt11?.is_failed) { + throw new Error('withdrawal is not failed') + } + + await onFail(tx, payIn.id) + + const { status, message: failureMessage } = getPaymentFailureStatus(lndPayOutBolt11) + message = failureMessage + + return { + payInFailureReason: 'WITHDRAWAL_FAILED', + payOutBolt11: { + update: { status } + } + } + } + }, { models, ...args }) + + if (transitionedPayIn) { + const { mtokens } = transitionedPayIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + const { payOutBolt11 } = transitionedPayIn + if (payOutBolt11?.protocolId) { + const logger = walletLogger({ protocolId: payOutBolt11.protocolId, userId: payOutBolt11.userId, models }) + logger?.error(`incoming payment failed: ${message}`, { + bolt11: payOutBolt11.bolt11, + max_fee: formatMsats(mtokens) + }) + } + } +} + +export async function payInPaid ({ data, models, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInPaid', data, { + payInId, + fromStates: ['HELD', 'PENDING', 'FORWARDED'], + toState: 'PAID', + transitionFunc: async ({ tx, payIn, lndPayInBolt11 }) => { + if (!lndPayInBolt11.is_confirmed) { + throw new Error('invoice is not confirmed') + } + + await onPaid(tx, payIn.id) + + return { + payInBolt11: { + update: { + confirmedAt: new Date(lndPayInBolt11.confirmed_at), + confirmedIndex: lndPayInBolt11.confirmed_index, + msatsReceived: BigInt(lndPayInBolt11.received_mtokens) + } + } + } + } + }, { models, ...args }) + + if (transitionedPayIn) { + // run non critical side effects in the background + // after the transaction has been committed + payInTypeModules[transitionedPayIn.payInType] + .nonCriticalSideEffects?.(payInId, { models }) + .catch(console.error) + } +} + +// this performs forward creating the outgoing payment +export async function payInForwarding ({ data, models, boss, lnd, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInForwarding', data, { + payInId, + fromStates: 'PENDING_HELD', + toState: 'FORWARDING', + transitionFunc: async ({ tx, payIn, lndPayInBolt11 }) => { + if (!lndPayInBolt11.is_held) { + throw new Error('invoice is not held') + } + + if (!payIn.payOutBolt11) { + throw new Error('invoice is not associated with a forward') + } + + const { expiryHeight, acceptHeight } = hodlInvoiceCltvDetails(lndPayInBolt11) + const invoice = await parsePaymentRequest({ request: payIn.payOutBolt11.bolt11 }) + // maxTimeoutDelta is the number of blocks left for the outgoing payment to settle + const maxTimeoutDelta = toPositiveNumber(expiryHeight) - toPositiveNumber(acceptHeight) - MIN_SETTLEMENT_CLTV_DELTA + if (maxTimeoutDelta - toPositiveNumber(invoice.cltv_delta) < 0) { + // the payment will certainly fail, so we can + // cancel and allow transition from PENDING[_HELD] -> FAILED + throw new PayInFailureReasonError('invoice has insufficient cltv delta for forward', 'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW') + } + + // if this is a pessimistic action, we want to perform it now + // ... we don't want it to fail after the outgoing payment is in flight + let pessimisticEnv + if (payIn.pessimisticEnv) { + pessimisticEnv = { + update: { + result: await payInTypeModules[payIn.payInType].onBegin?.(tx, payIn.id, payIn.pessimisticEnv.args) + } + } + } + + return { + payInBolt11: { + update: { + msatsReceived: BigInt(lndPayInBolt11.received_mtokens), + expiryHeight, + acceptHeight + } + }, + pessimisticEnv + } + }, + cancelOnError: true + }, { models, boss, lnd, ...args }) + + // only pay if we successfully transitioned which can only happen once + // we can't do this inside the transaction because it isn't necessarily idempotent + if (transitionedPayIn?.payInBolt11 && transitionedPayIn.payOutBolt11) { + const { expiryHeight, acceptHeight } = transitionedPayIn.payInBolt11 + const { mtokens: mtokensFee } = transitionedPayIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + + // give ourselves at least MIN_SETTLEMENT_CLTV_DELTA blocks to settle the incoming payment + const maxTimeoutHeight = toPositiveNumber(toPositiveNumber(expiryHeight) - MIN_SETTLEMENT_CLTV_DELTA) + + console.log('forwarding with max fee', mtokensFee, 'max_timeout_height', maxTimeoutHeight, + 'accept_height', acceptHeight, 'expiry_height', expiryHeight) + + payViaPaymentRequest({ + lnd, + request: transitionedPayIn.payOutBolt11.bolt11, + max_fee_mtokens: String(mtokensFee), + pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, + confidence: LND_PATHFINDING_TIME_PREF_PPM, + max_timeout_height: maxTimeoutHeight + }).catch( + e => { + console.error('failed to forward', e) + boss.send('payInCancel', { payInId, payInFailureReason: 'INVOICE_FORWARDING_FAILED' }, FINALIZE_OPTIONS) + .catch(e => console.error('failed to cancel payIn', e)) + } + ) + } +} + +// this finalizes the forward by settling the incoming invoice after the outgoing payment is confirmed +export async function payInForwarded ({ data, models, lnd, boss, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInForwarded', data, { + payInId, + fromStates: 'FORWARDING', + toState: 'FORWARDED', + transitionFunc: async ({ tx, payIn, lndPayInBolt11, lndPayOutBolt11 }) => { + if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_confirmed)) { + throw new Error('invoice is not held') + } + + if (!lndPayOutBolt11.is_confirmed) { + throw new Error('payment is not confirmed') + } + + const { payment } = lndPayOutBolt11 + + // settle the invoice, allowing us to transition to PAID + await settleHodlInvoice({ secret: payment.secret, lnd }) + + // adjust the routing fee and move the rest to the rewards pool + const { mtokens: mtokensFeeEstimated, id: payOutRoutingFeeId } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + const { id: payOutRewardsPoolId } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'REWARDS_POOL') + + return { + payInBolt11: { + update: { + preimage: payment.secret + } + }, + payOutBolt11: { + update: { + status: 'CONFIRMED', + preimage: payment.secret + } + }, + payOutCustodialTokens: { + update: [ + { + data: { mtokens: BigInt(payment.fee_mtokens) }, + where: { id: payOutRoutingFeeId } + }, + { + data: { mtokens: { increment: (mtokensFeeEstimated - BigInt(payment.fee_mtokens)) } }, + where: { id: payOutRewardsPoolId } + } + ] + } + } + } + }, { models, lnd, boss, ...args }) + + if (transitionedPayIn) { + const { msats, protocolId, userId } = transitionedPayIn.payOutBolt11 + + const logger = walletLogger({ protocolId, userId, models }) + logger.ok( + `↙ payment received: ${formatSats(msatsToSats(Number(msats)))}`, { + payInId: transitionedPayIn.id + }) + } + + return transitionedPayIn +} + +// when the pending forward fails, we need to cancel the incoming invoice +export async function payInFailedForward ({ data, models, lnd, boss, ...args }) { + const { payInId } = data + let message + const transitionedPayIn = await transitionPayIn('payInFailedForward', data, { + payInId, + fromStates: 'FORWARDING', + toState: 'FAILED_FORWARD', + transitionFunc: async ({ tx, payIn, lndPayInBolt11, lndPayOutBolt11 }) => { + if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_cancelled)) { + throw new Error('invoice is not held') + } + + if (!(lndPayOutBolt11.is_failed || lndPayOutBolt11.notSent)) { + throw new Error('payment is not failed') + } + + // cancel to transition to FAILED ... this is really important we do not transition unless this call succeeds + // which once it does succeed will ensure we will try to cancel the held invoice until it actually cancels + await boss.send('payInCancel', { payInId, payInFailureReason: 'INVOICE_FORWARDING_FAILED' }, FINALIZE_OPTIONS) + + const { status, message: failureMessage } = getPaymentFailureStatus(lndPayOutBolt11) + message = failureMessage + + return { + payOutBolt11: { + update: { + status + } + } + } + } + }, { models, lnd, boss, ...args }) + + if (transitionedPayIn) { + const fwd = transitionedPayIn.payOutBolt11 + const logger = walletLogger({ wallet: fwd.wallet, models }) + logger.warn( + `incoming payment failed: ${message}`, { + payInId: transitionedPayIn.id + }) + } + + return transitionedPayIn +} + +export async function payInHeld ({ data, models, lnd, boss, ...args }) { + const { payInId } = data + + return await transitionPayIn('payInHeld', data, { + payInId, + fromStates: 'PENDING_HELD', + toState: 'HELD', + transitionFunc: async ({ tx, payIn, lndPayInBolt11 }) => { + // XXX allow both held and confirmed invoices to do this transition + // because it's possible for a prior settleHodlInvoice to have succeeded but + // timeout and rollback the transaction, leaving the invoice in a pending_held state + if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_confirmed)) { + throw new Error('invoice is not held') + } + + if (payIn.payOutBolt11) { + throw new Error('invoice is associated with a forward') + } + + // make sure settled or cancelled in 60 seconds to minimize risk of force closures + const expiresAt = new Date(Math.min(payIn.payInBolt11.expiresAt, datePivot(new Date(), { seconds: 60 }))) + boss.send('payInCancel', { payInId, payInFailureReason: 'HELD_INVOICE_SETTLED_TOO_SLOW' }, { startAfter: expiresAt, ...FINALIZE_OPTIONS }) + .catch(e => console.error('failed to finalize', e)) + + // if this is a pessimistic action, we want to perform it now + let pessimisticEnv + if (payIn.pessimisticEnv) { + pessimisticEnv = { + update: { + result: await onBegin(tx, payIn.id, payIn.pessimisticEnv.args) + } + } + } + + // settle the invoice, allowing us to transition to PAID + await settleHodlInvoice({ secret: payIn.payInBolt11.preimage, lnd }) + + return { + payInBolt11: { + update: { + msatsReceived: BigInt(lndPayInBolt11.received_mtokens) + } + }, + pessimisticEnv + } + }, + cancelOnError: true + }, { models, lnd, boss, ...args }) +} + +export async function payInCancel ({ data, models, lnd, boss, ...args }) { + const { payInId, payInFailureReason } = data + const transitionedPayIn = await transitionPayIn('payInCancel', data, { + payInId, + fromStates: ['HELD', 'PENDING', 'PENDING_HELD', 'FAILED_FORWARD'], + toState: 'CANCELLED', + transitionFunc: async ({ tx, payIn, lndPayInBolt11 }) => { + if (lndPayInBolt11.is_confirmed) { + throw new Error('invoice is confirmed already') + } + + await cancelHodlInvoice({ id: payIn.payInBolt11.hash, lnd }) + + // transition to FAILED manually so we don't have to wait + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, priority) + VALUES ('payInFailed', jsonb_build_object('payInId', ${payInId}::INTEGER), 100)` + + return { + payInFailureReason: payInFailureReason ?? 'SYSTEM_CANCELLED' + } + } + }, { models, lnd, boss, ...args }) + + if (transitionedPayIn) { + if (transitionedPayIn.payOutBolt11) { + const { wallet, bolt11 } = transitionedPayIn.payOutBolt11 + const logger = walletLogger({ wallet, models }) + const decoded = await parsePaymentRequest({ request: bolt11 }) + logger.info( + `invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { + bolt11, + payInId: transitionedPayIn.id + }) + } + } + + return transitionedPayIn +} + +export async function payInFailed ({ data, models, lnd, boss, ...args }) { + const { payInId, payInFailureReason } = data + return await transitionPayIn('payInFailed', data, { + payInId, + // any of these states can transition to FAILED + fromStates: ['PENDING', 'PENDING_HELD', 'HELD', 'FAILED_FORWARD', 'CANCELLED', 'PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP'], + toState: 'FAILED', + transitionFunc: async ({ tx, payIn, lndPayInBolt11 }) => { + let payInBolt11 + if (lndPayInBolt11) { + if (!lndPayInBolt11.is_canceled) { + throw new Error('invoice is not cancelled') + } + payInBolt11 = { + update: { + cancelledAt: new Date() + } + } + } + + await onFail(tx, payIn.id) + + // TODO: in which cases might we not have this passed by can deduce the error from the payIn/lnd state? + const reason = payInFailureReason ?? payIn.payInFailureReason ?? 'UNKNOWN_FAILURE' + + return { + payInFailureReason: reason, + payInBolt11 + } + } + }, { models, lnd, boss, ...args }) +} diff --git a/api/payIn/types/autoWithdrawal.js b/api/payIn/types/autoWithdrawal.js new file mode 100644 index 0000000000..61bd59ce7a --- /dev/null +++ b/api/payIn/types/autoWithdrawal.js @@ -0,0 +1,33 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, msatsToSats } from '@/lib/format' +import { payOutBolt11Prospect } from '../lib/payOutBolt11' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS +] + +export async function getInitial (models, { msats, maxFeeMsats }, { me }) { + // TODO: description, expiry? + const payOutBolt11 = await payOutBolt11Prospect(models, { msats }, { userId: me?.id, payOutType: 'WITHDRAWAL' }) + return { + payInType: 'AUTO_WITHDRAWAL', + userId: me?.id, + mcost: msats + maxFeeMsats, + payOutBolt11, + payOutCustodialTokens: [ + { + payOutType: 'ROUTING_FEE', + userId: null, + mtokens: maxFeeMsats, + custodialTokenType: 'SATS' + } + ] + } +} + +export async function describe (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { payOutBolt11: true } }) + return `SN: auto-withdraw ${numWithUnits(msatsToSats(payIn.payOutBolt11.msats))}` +} diff --git a/api/payIn/types/boost.js b/api/payIn/types/boost.js new file mode 100644 index 0000000000..a89fa83dcc --- /dev/null +++ b/api/payIn/types/boost.js @@ -0,0 +1,74 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' +import { getItemResult, getSub } from '../lib/item' +import { getRedistributedPayOutCustodialTokens } from '../lib/payOutCustodialTokens' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC +] + +export async function getInitial (models, { sats, id }, { me, sub }) { + if (id) { + const { subName, parentId } = await models.item.findUnique({ where: { id: parseInt(id) } }) + sub = await getSub(models, { subName, parentId }) + } + + const mcost = satsToMsats(sats) + const payOutCustodialTokens = getRedistributedPayOutCustodialTokens({ sub, mcost }) + + // if we have a benefactor, we might not know the itemId until after the payIn is created + // so we create the itemPayIn in onBegin + return { + payInType: 'BOOST', + userId: me?.id, + mcost, + payOutCustodialTokens + } +} + +export async function onRetry (tx, oldPayInId, newPayInId) { + const { itemId, payIn } = await tx.itemPayIn.findUnique({ where: { payInId: oldPayInId }, include: { payIn: true } }) + await tx.itemPayIn.create({ data: { itemId, payInId: newPayInId } }) + const item = await getItemResult(tx, { id: itemId }) + return { id: item.id, path: item.path, sats: msatsToSats(payIn.mcost), act: 'BOOST' } +} + +export async function onBegin (tx, payInId, { sats, id }, benefactorResult) { + id ??= benefactorResult.id + + if (!id) { + throw new Error('item id is required') + } + + const item = await getItemResult(tx, { id }) + await tx.payIn.update({ where: { id: payInId }, data: { itemPayIn: { create: { itemId: item.id } } } }) + + return { id: item.id, path: item.path, sats, act: 'BOOST' } +} + +export async function onPaid (tx, payInId) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { itemPayIn: true } }) + + // increment boost on item + await tx.item.update({ + where: { id: payIn.itemPayIn.itemId }, + data: { + boost: { increment: msatsToSats(payIn.mcost) } + } + }) + + // TODO: expireBoost job needs to be updated to use payIn + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) + VALUES ('expireBoost', jsonb_build_object('id', ${payIn.itemPayIn.itemId}::INTEGER), 21, true, + now() + interval '30 days', now() + interval '40 days')` +} + +export async function describe (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { itemPayIn: true } }) + return `SN: boost #${payIn.itemPayIn.itemId} by ${numWithUnits(msatsToSats(payIn.mcost), { abbreviate: false })}` +} diff --git a/api/payIn/types/buyCredits.js b/api/payIn/types/buyCredits.js new file mode 100644 index 0000000000..b521191a89 --- /dev/null +++ b/api/payIn/types/buyCredits.js @@ -0,0 +1,30 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, satsToMsats, msatsToSats } from '@/lib/format' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { credits }, { me }) { + return { + payInType: 'BUY_CREDITS', + userId: me?.id, + mcost: satsToMsats(credits), + payOutCustodialTokens: [ + { + payOutType: 'BUY_CREDITS', + userId: me.id, + mtokens: satsToMsats(credits), + custodialTokenType: 'CREDITS' + } + ] + } +} + +export async function describe (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId } }) + return `SN: buy ${numWithUnits(msatsToSats(payIn.mcost), { abbreviate: false, unitSingular: 'credit', unitPlural: 'credits' })}` +} diff --git a/api/payIn/types/donate.js b/api/payIn/types/donate.js new file mode 100644 index 0000000000..f3ad6be773 --- /dev/null +++ b/api/payIn/types/donate.js @@ -0,0 +1,26 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' + +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { sats }, { me }) { + return { + payInType: 'DONATE', + userId: me?.id, + mcost: satsToMsats(sats), + payOutCustodialTokens: [ + { payOutType: 'REWARDS_POOL', userId: null, mtokens: satsToMsats(sats), custodialTokenType: 'SATS' } + ] + } +} + +export async function describe (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId } }) + return `SN: donate ${numWithUnits(msatsToSats(payIn.mcost), { abbreviate: false })} to rewards pool` +} diff --git a/api/payIn/types/downZap.js b/api/payIn/types/downZap.js new file mode 100644 index 0000000000..4b8110e1ef --- /dev/null +++ b/api/payIn/types/downZap.js @@ -0,0 +1,94 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { msatsToSats, satsToMsats, numWithUnits } from '@/lib/format' +import { Prisma } from '@prisma/client' +import { getItemResult, getSub } from '../lib/item' +import { getRedistributedPayOutCustodialTokens } from '../lib/payOutCustodialTokens' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC +] + +export async function getInitial (models, { sats, id: itemId }, { me }) { + const item = await models.item.findUnique({ where: { id: parseInt(itemId) } }) + const sub = await getSub(models, { subName: item.subName, parentId: item.parentId }) + + const mcost = satsToMsats(sats) + const payOutCustodialTokens = getRedistributedPayOutCustodialTokens({ sub, mcost }) + + return { + payInType: 'DOWN_ZAP', + userId: me?.id, + mcost, + itemPayIn: { + itemId: parseInt(itemId) + }, + payOutCustodialTokens + } +} + +export async function onRetry (tx, oldPayInId, newPayInId) { + const { itemId, payIn } = await tx.itemPayIn.findUnique({ where: { payInId: oldPayInId }, include: { payIn: true } }) + await tx.itemPayIn.create({ data: { itemId, payInId: newPayInId } }) + const item = await getItemResult(tx, { id: itemId }) + return { id: item.id, path: item.path, sats: msatsToSats(payIn.mcost), act: 'DONT_LIKE_THIS' } +} + +export async function onBegin (tx, payInId, payInArgs) { + const item = await getItemResult(tx, { id: payInArgs.id }) + return { id: item.id, path: item.path, sats: payInArgs.sats, act: 'DONT_LIKE_THIS' } +} + +export async function onPaid (tx, payInId) { + const payIn = await tx.payIn.findUnique({ + where: { id: payInId }, + include: { + itemPayIn: { include: { item: true } }, + payOutBolt11: true + } + }) + + const msats = payIn.mcost + const sats = msatsToSats(msats) + const userId = payIn.userId + const item = payIn.itemPayIn.item + + // denormalize downzaps + await tx.$executeRaw` + WITH territory AS ( + SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName" + FROM "Item" i + LEFT JOIN "Item" r ON r.id = i."rootId" + WHERE i.id = ${item.id}::INTEGER + ), zapper AS ( + SELECT + COALESCE(${item.parentId + ? Prisma.sql`"zapCommentTrust"` + : Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust", + COALESCE(${item.parentId + ? Prisma.sql`"subZapCommentTrust"` + : Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust" + FROM territory + LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName" + AND ust."userId" = ${userId}::INTEGER + ), zap AS ( + INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats") + VALUES (${userId}::INTEGER, ${item.id}::INTEGER, ${sats}::INTEGER) + ON CONFLICT ("itemId", "userId") DO UPDATE + SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now() + RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats + ) + UPDATE "Item" + SET "weightedDownVotes" = "weightedDownVotes" + zapper."zapTrust" * zap.log_sats, + "subWeightedDownVotes" = "subWeightedDownVotes" + zapper."subZapTrust" * zap.log_sats + FROM zap, zapper + WHERE "Item".id = ${item.id}::INTEGER` +} + +export async function describe (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { itemPayIn: true } }) + return `SN: downzap #${payIn.itemPayIn.itemId} for ${numWithUnits(msatsToSats(payIn.mcost), { abbreviate: false })}` +} diff --git a/api/payIn/types/index.js b/api/payIn/types/index.js new file mode 100644 index 0000000000..a20bafed3b --- /dev/null +++ b/api/payIn/types/index.js @@ -0,0 +1,35 @@ +import * as ITEM_CREATE from './itemCreate' +import * as ITEM_UPDATE from './itemUpdate' +import * as ZAP from './zap' +import * as DOWN_ZAP from './downZap' +import * as POLL_VOTE from './pollVote' +import * as TERRITORY_CREATE from './territoryCreate' +import * as TERRITORY_UPDATE from './territoryUpdate' +import * as TERRITORY_BILLING from './territoryBilling' +import * as TERRITORY_UNARCHIVE from './territoryUnarchive' +import * as DONATE from './donate' +import * as BOOST from './boost' +import * as PROXY_PAYMENT from './proxyPayment' +import * as BUY_CREDITS from './buyCredits' +import * as INVITE_GIFT from './inviteGift' +import * as WITHDRAWAL from './withdrawal' +import * as AUTO_WITHDRAWAL from './autoWithdrawal' + +export default { + BUY_CREDITS, + ITEM_CREATE, + ITEM_UPDATE, + ZAP, + DOWN_ZAP, + BOOST, + DONATE, + POLL_VOTE, + INVITE_GIFT, + TERRITORY_CREATE, + TERRITORY_UPDATE, + TERRITORY_BILLING, + TERRITORY_UNARCHIVE, + PROXY_PAYMENT, + WITHDRAWAL, + AUTO_WITHDRAWAL +} diff --git a/api/payIn/types/inviteGift.js b/api/payIn/types/inviteGift.js new file mode 100644 index 0000000000..b5919e5137 --- /dev/null +++ b/api/payIn/types/inviteGift.js @@ -0,0 +1,75 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { notifyInvite } from '@/lib/webPush' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS +] + +export async function getInitial (models, { id, userId }, { me }) { + const invite = await models.invite.findUnique({ where: { id, userId: me.id, revoked: false } }) + if (!invite) { + throw new Error('invite not found') + } + const mcost = satsToMsats(invite.gift) + return { + payInType: 'INVITE_GIFT', + userId: me?.id, + mcost, + payOutCustodialTokens: [ + { + payOutType: 'INVITE_GIFT', + userId, + mtokens: mcost, + custodialTokenType: 'CREDITS' + } + ] + } +} + +// NOTE: the relationship between payIn and invite can be deduced +// from the payOutCustodialTokens.user.inviteId +export async function onBegin (tx, payInId, { id, userId }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId } }) + + const invite = await tx.invite.findUnique({ + where: { id, userId: payIn.userId, revoked: false } + }) + + if (invite.limit && invite.giftedCount >= invite.limit) { + throw new Error('invite limit reached') + } + + // check that user was created in last hour + // check that user did not already redeem an invite + await tx.user.update({ + where: { + id: userId, + inviteId: null, + createdAt: { + gt: new Date(Date.now() - 1000 * 60 * 60) + } + }, + data: { + inviteId: id, + referrerId: payIn.userId + } + }) + + await tx.invite.update({ + where: { id, userId: payIn.userId, revoked: false, ...(invite.limit ? { giftedCount: { lt: invite.limit } } : {}) }, + data: { + giftedCount: { + increment: 1 + } + } + }) +} + +export async function onPaidSideEffects (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId } }) + notifyInvite(payIn.userId) +} diff --git a/api/payIn/types/itemCreate.js b/api/payIn/types/itemCreate.js new file mode 100644 index 0000000000..78a81d2ea6 --- /dev/null +++ b/api/payIn/types/itemCreate.js @@ -0,0 +1,267 @@ +import { ANON_FEE_MULTIPLIER, ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush' +import { getItemMentions, getMentions, performBotBehavior, getSub, getItemResult } from '../lib/item' +import { msatsToSats, satsToMsats } from '@/lib/format' +import { GqlInputError } from '@/lib/error' +import * as BOOST from './boost' +import { getRedistributedPayOutCustodialTokens } from '../lib/payOutCustodialTokens' + +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +const DEFAULT_ITEM_COST = 1000n + +async function getBaseCost (models, { bio, parentId, subName }) { + if (bio) return DEFAULT_ITEM_COST + + const sub = await getSub(models, { subName, parentId }) + + if (parentId && sub?.replyCost) { + return satsToMsats(sub.replyCost) + } + + if (sub?.baseCost) { + return satsToMsats(sub.baseCost) + } + + return DEFAULT_ITEM_COST +} + +async function getCost (models, { subName, parentId, uploadIds, boost = 0, bio }, { me }) { + const baseCost = await getBaseCost(models, { bio, parentId, subName }) + + // cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost + const [{ cost }] = await models.$queryRaw` + SELECT ${baseCost}::INTEGER + * POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me.id}::INTEGER, + ${me.id !== USER_ID.anon && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL)) + * ${me.id !== USER_ID.anon ? 1 : ANON_FEE_MULTIPLIER}::INTEGER + + (SELECT "nUnpaid" * "uploadFeesMsats" + FROM upload_fees(${me.id}::INTEGER, ${uploadIds}::INTEGER[])) as cost` + + // sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon, + // cost must be greater than user's balance, and user has not disabled freebies + const freebie = (parentId || bio) && cost <= baseCost && me.id !== USER_ID.anon && + me.msats < cost && !me.disableFreebies && me.mcredits < cost && boost <= 0 + + return freebie ? BigInt(0) : BigInt(cost) +} + +export async function getInitial (models, args, { me }) { + const mcost = await getCost(models, args, { me }) + const sub = await getSub(models, args) + const payOutCustodialTokens = getRedistributedPayOutCustodialTokens({ sub, mcost }) + + let beneficiaries + if (args.boost > 0) { + beneficiaries = [ + await BOOST.getInitial(models, { sats: args.boost }, { me, sub }) + ] + } + + return { + payInType: 'ITEM_CREATE', + userId: me.id, + mcost, + payOutCustodialTokens, + beneficiaries + } +} + +// TODO: uploads should just have an itemId +export async function onBegin (tx, payInId, args) { + // don't want to double count boost ... it should be a beneficiary + const { invoiceId, parentId, uploadIds = [], boost: _, forwardUsers = [], options: pollOptions = [], ...data } = args + const payIn = await tx.payIn.findUnique({ where: { id: payInId } }) + + const deletedUploads = [] + for (const uploadId of uploadIds) { + if (!await tx.upload.findUnique({ where: { id: uploadId } })) { + deletedUploads.push(uploadId) + } + } + if (deletedUploads.length > 0) { + throw new Error(`upload(s) ${deletedUploads.join(', ')} are expired, consider reuploading.`) + } + + const mentions = await getMentions(tx, { ...args, userId: payIn.userId }) + const itemMentions = await getItemMentions(tx, { ...args, userId: payIn.userId }) + + // start with median vote + if (payIn.userId !== USER_ID.anon) { + const [row] = await tx.$queryRaw`SELECT + COALESCE(percentile_cont(0.5) WITHIN GROUP( + ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + AS median FROM "Item" WHERE "userId" = ${payIn.userId}::INTEGER` + if (row?.median < 0) { + data.weightedDownVotes = -row.median + } + } + + const itemData = { + parentId: parentId ? parseInt(parentId) : null, + ...data, + cost: msatsToSats(payIn.mcost), + itemPayIns: { + create: [{ payInId }] + }, + threadSubscriptions: { + createMany: { + data: [ + { userId: data.userId }, + ...forwardUsers.map(({ userId }) => ({ userId })) + ] + } + }, + itemForwards: { + createMany: { + data: forwardUsers + } + }, + pollOptions: { + createMany: { + data: pollOptions.map(option => ({ option })) + } + }, + itemUploads: { + create: uploadIds.map(id => ({ uploadId: id })) + }, + mentions: { + createMany: { + data: mentions + } + }, + itemReferrers: { + create: itemMentions + } + } + + let item + if (data.bio && payIn.userId !== USER_ID.anon) { + item = (await tx.user.update({ + where: { id: data.userId }, + include: { bio: true }, + data: { + bio: { + create: itemData + } + } + })).bio + } else { + try { + item = await tx.item.create({ data: itemData }) + } catch (err) { + if (err.message.includes('violates exclusion constraint \\"Item_unique_time_constraint\\"')) { + const message = `you already submitted this ${itemData.title ? 'post' : 'comment'}` + throw new GqlInputError(message) + } + throw err + } + } + + await performBotBehavior(tx, { ...item, userId: payIn.userId }) + + return await getItemResult(tx, { id: item.id }) +} + +export async function onRetry (tx, oldPayInId, newPayInId) { + const { itemId } = await tx.itemPayIn.findUnique({ where: { payInId: oldPayInId } }) + await tx.itemPayIn.create({ data: { itemId, payInId: newPayInId } }) + return await getItemResult(tx, { id: itemId }) +} + +export async function onPaid (tx, payInId) { + const { item } = await tx.itemPayIn.findUnique({ where: { payInId }, include: { item: true } }) + if (!item) { + throw new Error('Item not found') + } + + await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ('timestampItem', jsonb_build_object('id', ${item.id}::INTEGER), now() + interval '10 minutes', -2)` + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')` + + if (item.parentId) { + // denormalize ncomments, lastCommentAt for ancestors, and insert into reply table + await tx.$executeRaw` + WITH comment AS ( + SELECT "Item".*, users.trust + FROM "Item" + JOIN users ON "Item"."userId" = users.id + WHERE "Item".id = ${item.id}::INTEGER + ), ancestors AS ( + SELECT "Item".* + FROM "Item", comment + WHERE "Item".path @> comment.path AND "Item".id <> comment.id + ORDER BY "Item".id + ), updated_ancestors AS ( + UPDATE "Item" + SET ncomments = "Item".ncomments + 1, + "lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at), + "nDirectComments" = "Item"."nDirectComments" + + CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END + FROM comment, ancestors + WHERE "Item".id = ancestors.id + RETURNING "Item".* + ) + INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level) + SELECT comment.created_at, comment.updated_at, ancestors.id, ancestors."userId", + comment.id, comment."userId", nlevel(comment.path) - nlevel(ancestors.path) + FROM ancestors, comment` + } +} + +export async function onPaidSideEffects (models, payInId) { + const { item } = await models.itemPayIn.findUnique({ + where: { payInId }, + include: { + item: { + include: { + mentions: true, + itemReferrers: { include: { refereeItem: true } }, + user: true + } + } + } + }) + + if (item.parentId) { + notifyItemParents({ item, models }).catch(console.error) + notifyThreadSubscribers({ models, item }).catch(console.error) + } + for (const { userId } of item.mentions) { + notifyMention({ models, item, userId }).catch(console.error) + } + for (const { refereeItem } of item.itemReferrers) { + notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error) + } + + notifyUserSubscribers({ models, item }).catch(console.error) + notifyTerritorySubscribers({ models, item }).catch(console.error) +} + +export async function describe (models, payInId) { + const itemPayIn = await models.itemPayIn.findUnique({ where: { payInId }, include: { item: true } }) + if (itemPayIn?.item) { + return `SN: create ${itemPayIn.item.parentId ? `reply #${itemPayIn.item.id} to #${itemPayIn.item.parentId}` : `post #${itemPayIn.item.id}`}` + } + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + if (payIn.pessimisticEnv?.args) { + const { subName, parentId, bio } = payIn.pessimisticEnv.args + if (bio) { + return 'SN: create bio' + } + if (parentId) { + return `SN: create reply to #${parentId} in ${subName}` + } + return `SN: create post in ${subName}` + } + return 'SN: create item' +} diff --git a/api/payIn/types/itemUpdate.js b/api/payIn/types/itemUpdate.js new file mode 100644 index 0000000000..5dfb060cce --- /dev/null +++ b/api/payIn/types/itemUpdate.js @@ -0,0 +1,192 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { uploadFees } from '../../resolvers/upload' +import { getItemMentions, getItemResult, getMentions, getSub, performBotBehavior } from '../lib/item' +import { notifyItemMention, notifyMention } from '@/lib/webPush' +import * as BOOST from './boost' +import { getRedistributedPayOutCustodialTokens } from '../lib/payOutCustodialTokens' +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +async function getCost (models, { id, boost = 0, uploadIds, bio }, { me }) { + // the only reason updating items costs anything is when it has new uploads + // or more boost + const old = await models.item.findUnique({ + where: { + id: parseInt(id) + }, + include: { + itemPayIns: { + where: { + payIn: { + payInType: 'ITEM_CREATE', + payInState: 'PAID' + } + } + } + } + }) + + const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) + const cost = BigInt(totalFeesMsats) + + if ((cost > 0 || (boost - old.boost) > 0) && old.itemPayIns.length === 0) { + throw new Error('cannot update item with unpaid invoice') + } + + return cost +} + +export async function getInitial (models, { id, boost = 0, uploadIds, bio, subName }, { me }) { + const old = await models.item.findUnique({ where: { id: parseInt(id) } }) + const mcost = await getCost(models, { id, boost, uploadIds, bio }, { me }) + const sub = await getSub(models, { subName }) + const payOutCustodialTokens = getRedistributedPayOutCustodialTokens({ sub, mcost }) + + let beneficiaries + if (boost - old.boost > 0) { + beneficiaries = [ + await BOOST.getInitial(models, { sats: boost - old.boost, id }, { me }) + ] + } + return { + payInType: 'ITEM_UPDATE', + userId: me?.id, + mcost, + payOutCustodialTokens, + itemPayIn: { itemId: parseInt(id) }, + beneficiaries + } +} + +export async function onBegin (tx, payInId, args) { + const { id, boost: _, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args + + const old = await tx.item.findUnique({ + where: { id: parseInt(id) }, + include: { + threadSubscriptions: true, + mentions: true, + itemForwards: true, + itemReferrers: true, + itemUploads: true + } + }) + + // createMany is the set difference of the new - old + // deleteMany is the set difference of the old - new + // updateMany is the intersection of the old and new + const difference = (a = [], b = [], key = 'userId') => a.filter(x => !b.find(y => y[key] === x[key])) + const intersectionMerge = (a = [], b = [], key) => a.filter(x => b.find(y => y.userId === x.userId)) + .map(x => ({ [key]: x[key], ...b.find(y => y.userId === x.userId) })) + + const mentions = await getMentions(tx, args) + const itemMentions = await getItemMentions(tx, args) + const itemUploads = uploadIds.map(id => ({ uploadId: id })) + + // we put boost in the where clause because we don't want to update the boost + // if it has changed concurrently + await tx.item.update({ + where: { id: parseInt(id) }, + data: { + ...data, + pollOptions: { + createMany: { + data: pollOptions?.map(option => ({ option })) + } + }, + itemUploads: { + create: difference(itemUploads, old.itemUploads, 'uploadId').map(({ uploadId }) => ({ uploadId })), + deleteMany: { + uploadId: { + in: difference(old.itemUploads, itemUploads, 'uploadId').map(({ uploadId }) => uploadId) + } + } + }, + itemForwards: { + deleteMany: { + userId: { + in: difference(old.itemForwards, itemForwards).map(({ userId }) => userId) + } + }, + createMany: { + data: difference(itemForwards, old.itemForwards) + }, + update: intersectionMerge(old.itemForwards, itemForwards, 'id').map(({ id, ...data }) => ({ + where: { id }, + data + })) + }, + threadSubscriptions: { + deleteMany: { + userId: { + in: difference(old.itemForwards, itemForwards).map(({ userId }) => userId) + } + }, + createMany: { + data: difference(itemForwards, old.itemForwards).map(({ userId }) => ({ userId })) + } + }, + mentions: { + deleteMany: { + userId: { + in: difference(old.mentions, mentions).map(({ userId }) => userId) + } + }, + createMany: { + data: difference(mentions, old.mentions) + } + }, + itemReferrers: { + deleteMany: { + refereeId: { + in: difference(old.itemReferrers, itemMentions, 'refereeId').map(({ refereeId }) => refereeId) + } + }, + create: difference(itemMentions, old.itemReferrers, 'refereeId') + } + } + }) + + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) + VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, + now() + interval '5 seconds', now() + interval '1 day')` + + await performBotBehavior(tx, args) + + return getItemResult(tx, { id }) +} + +export async function onPaidSideEffects (models, payInId) { + const { item } = await models.itemPayIn.findUnique({ + where: { payInId }, + include: { + item: { + include: { + mentions: true, + itemReferrers: { include: { refereeItem: true } }, + user: true + } + } + } + }) + // compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits + for (const { userId, createdAt } of item.mentions) { + if (item.updatedAt.getTime() !== createdAt.getTime()) continue + notifyMention({ models, item, userId }).catch(console.error) + } + for (const { refereeItem, createdAt } of item.itemReferrers) { + if (item.updatedAt.getTime() !== createdAt.getTime()) continue + notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error) + } +} + +export async function describe (models, payInId) { + const { item } = await models.itemPayIn.findUnique({ where: { payInId }, include: { item: true } }) + return `SN: update ${item.parentId ? `reply #${item.id} to #${item.parentId}` : `post #${item.id}`}` +} diff --git a/api/payIn/types/pollVote.js b/api/payIn/types/pollVote.js new file mode 100644 index 0000000000..e954db7d00 --- /dev/null +++ b/api/payIn/types/pollVote.js @@ -0,0 +1,70 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { getRedistributedPayOutCustodialTokens } from '../lib/payOutCustodialTokens' +import { GqlInputError } from '@/lib/error' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { id }, { me }) { + const pollOption = await models.pollOption.findUnique({ + where: { id: parseInt(id) }, + include: { item: { include: { sub: true } } } + }) + + const mcost = satsToMsats(pollOption.item.pollCost) + const payOutCustodialTokens = getRedistributedPayOutCustodialTokens({ sub: pollOption.item.sub, mcost }) + + return { + payInType: 'POLL_VOTE', + userId: me?.id, + mcost, + payOutCustodialTokens, + pollVote: { + pollOptionId: pollOption.id, + itemId: pollOption.itemId + }, + itemPayIn: { + itemId: pollOption.itemId + } + } +} + +export async function onBegin (tx, payInId, { id }) { + const { userId } = await tx.payIn.findUnique({ where: { id: payInId } }) + // XXX this is only a sufficient check because of the row locks we + // take for payIns that might race with this one + const meVoted = await tx.payIn.findFirst({ + where: { + userId, + id: { not: payInId }, + payInType: 'POLL_VOTE', + payInState: { in: ['PAID', 'PENDING', 'PENDING_HELD'] }, + itemPayIn: { + item: { + pollOptions: { + some: { + id: Number(id) + } + } + } + } + } + }) + if (meVoted) { + throw new GqlInputError('already voted') + } + // anonymize the vote + await tx.pollVote.updateMany({ where: { payInId }, data: { payInId: null } }) + return { id } +} + +export async function describe (models, payInId) { + const pollVote = await models.pollVote.findUnique({ where: { payInId } }) + return `SN: vote on poll #${pollVote.itemId}` +} diff --git a/api/payIn/types/proxyPayment.js b/api/payIn/types/proxyPayment.js new file mode 100644 index 0000000000..99778d819c --- /dev/null +++ b/api/payIn/types/proxyPayment.js @@ -0,0 +1,80 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { toPositiveBigInt } from '@/lib/format' +import { notifyDeposit } from '@/lib/webPush' +import { payOutBolt11Prospect } from '../lib/payOutBolt11' +import { getRedistributedPayOutCustodialTokens } from '../lib/payOutCustodialTokens' +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.P2P +] + +// 3% to routing fee, 7% to rewards pool, 90% to invoice +export async function getInitial (models, { msats, description, descriptionHash, expiry }, { me }) { + const mcost = toPositiveBigInt(msats) + const proxyPaymentMtokens = mcost * 90n / 100n + const routingFeeMtokens = mcost * 3n / 100n + + // payInBolt11 and payOutBolt11 belong to the same user + const payOutBolt11 = await payOutBolt11Prospect(models, { + msats: proxyPaymentMtokens, + description: me.hideInvoiceDesc ? undefined : description, + descriptionHash, + expiry + }, { payOutType: 'PROXY_PAYMENT', userId: me.id }) + + const payOutCustodialTokens = getRedistributedPayOutCustodialTokens({ + sub: null, + mcost, + payOutCustodialTokens: [ + { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' } + ], + payOutBolt11 + }) + + return { + payInType: 'PROXY_PAYMENT', + userId: me.id, + mcost, + payOutCustodialTokens, + payOutBolt11 + } +} + +// TODO: all of this needs to be updated elsewhere +export async function onBegin (tx, payInId, { comment, lud18Data, noteStr }) { + const data = { + ...(lud18Data && { lud18Data: { create: lud18Data } }), + ...(noteStr && { nostrNote: { create: { note: noteStr } } }), + ...(comment && { comment: { create: { comment } } }) + } + + if (Object.keys(data).length === 0) { + return + } + + await tx.payInBolt11.update({ + where: { payInId }, + data + }) +} + +export async function onPaid (tx, payInId) { + const payInBolt11 = await tx.payInBolt11.findUnique({ where: { payInId } }) + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data) + VALUES ('nip57', jsonb_build_object('hash', ${payInBolt11.hash}))` +} + +export async function onPaidSideEffects (models, payInId) { + const payInBolt11 = await models.payInBolt11.findUnique({ where: { payInId } }) + await notifyDeposit(payInBolt11.userId, payInBolt11) +} + +export async function describe (models, payInId) { + const { user } = await models.payIn.findUnique({ + where: { id: payInId }, + include: { user: true } + }) + return `pay ${user.name}@stacker.news` +} diff --git a/api/payIn/types/territoryBilling.js b/api/payIn/types/territoryBilling.js new file mode 100644 index 0000000000..827ab5660a --- /dev/null +++ b/api/payIn/types/territoryBilling.js @@ -0,0 +1,77 @@ +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 paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { name }, { me }) { + const sub = await models.sub.findUnique({ + where: { + name + } + }) + + const mcost = satsToMsats(TERRITORY_PERIOD_COST(sub.billingType)) + + return { + payInType: 'TERRITORY_BILLING', + userId: me?.id, + mcost, + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' } + ] + } +} + +export async function onBegin (tx, payInId, { name }) { + const sub = await tx.sub.findUnique({ + where: { + name + } + }) + + if (sub.billingType === 'ONCE') { + throw new Error('Cannot bill a ONCE territory') + } + + let billedLastAt = sub.billPaidUntil + let billingCost = sub.billingCost + + // if the sub is archived, they are paying to reactivate it + if (sub.status === 'STOPPED') { + // get non-grandfathered cost and reset their billing to start now + billedLastAt = new Date() + billingCost = TERRITORY_PERIOD_COST(sub.billingType) + } + + const billPaidUntil = nextBilling(billedLastAt, sub.billingType) + + return await tx.sub.update({ + // optimistic concurrency control + // make sure the sub hasn't changed since we fetched it + where: { + ...sub, + postTypes: { + equals: sub.postTypes + } + }, + data: { + billedLastAt, + billPaidUntil, + billingCost, + status: 'ACTIVE', + subPayIn: { create: [{ payInId }] } + } + }) +} + +export async function describe (models, payInId) { + const { sub } = await models.subPayIn.findUnique({ where: { payInId }, include: { sub: true } }) + return `SN: billing for territory ${sub.name}` +} diff --git a/api/payIn/types/territoryCreate.js b/api/payIn/types/territoryCreate.js new file mode 100644 index 0000000000..6a6667a279 --- /dev/null +++ b/api/payIn/types/territoryCreate.js @@ -0,0 +1,76 @@ +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { nextBilling } from '@/lib/territory' +import { initialTrust } from '../lib/territory' +import { throwOnExpiredUploads } from '@/api/resolvers/upload' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { billingType }, { me }) { + const mcost = satsToMsats(TERRITORY_PERIOD_COST(billingType)) + return { + payInType: 'TERRITORY_CREATE', + userId: me?.id, + mcost, + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' } + ] + } +} + +export async function onBegin (tx, payInId, { billingType, ...data }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId } }) + const billingCost = TERRITORY_PERIOD_COST(billingType) + const billedLastAt = new Date() + const billPaidUntil = nextBilling(billedLastAt, billingType) + + await throwOnExpiredUploads(data.uploadIds, { tx }) + if (data.uploadIds.length > 0) { + await tx.upload.updateMany({ + where: { + id: { in: data.uploadIds } + }, + data: { + paid: true + } + }) + } + delete data.uploadIds + + const sub = await tx.sub.create({ + data: { + ...data, + billedLastAt, + billPaidUntil, + billingCost, + billingType, + rankingType: 'WOT', + userId: payIn.userId, + subPayIn: { + create: [{ payInId }] + }, + SubSubscription: { + create: { + userId: payIn.userId + } + } + } + }) + + await tx.userSubTrust.createMany({ + data: initialTrust({ name: sub.name, userId: sub.userId }) + }) + + return sub +} + +export async function describe (models, payInId) { + const { sub } = await models.subPayIn.findUnique({ where: { payInId }, include: { sub: true } }) + return `SN: create territory ${sub.name}` +} diff --git a/api/payIn/types/territoryUnarchive.js b/api/payIn/types/territoryUnarchive.js new file mode 100644 index 0000000000..bef2ce5dce --- /dev/null +++ b/api/payIn/types/territoryUnarchive.js @@ -0,0 +1,108 @@ +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { nextBilling } from '@/lib/territory' +import { initialTrust } from '../lib/territory' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { billingType }, { me }) { + const mcost = satsToMsats(TERRITORY_PERIOD_COST(billingType)) + return { + payInType: 'TERRITORY_UNARCHIVE', + userId: me?.id, + mcost, + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' } + ] + } +} + +export async function onBegin (tx, payInId, { name, billingType, ...data }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId } }) + const sub = await tx.sub.findUnique({ + where: { + name + } + }) + + data.billingCost = TERRITORY_PERIOD_COST(billingType) + + // we never want to bill them again if they are changing to ONCE + if (billingType === 'ONCE') { + data.billPaidUntil = null + data.billingAutoRenew = false + } + + data.billedLastAt = new Date() + data.billPaidUntil = nextBilling(data.billedLastAt, billingType) + data.status = 'ACTIVE' + data.userId = payIn.userId + + if (sub.userId !== payIn.userId) { + try { + // this will throw if this transfer has already happened + await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: payIn.userId } }) + // this will throw if the prior user has already unsubscribed + await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } }) + } catch (e) { + console.error(e) + } + } + + await tx.subSubscription.upsert({ + where: { + userId_subName: { + userId: payIn.userId, + subName: name + } + }, + update: { + userId: payIn.userId, + subName: name + }, + create: { + userId: payIn.userId, + subName: name + } + }) + + const updatedSub = await tx.sub.update({ + data: { + ...data, + billingType, + subPayIn: { create: [{ payInId }] } + }, + // optimistic concurrency control + // make sure none of the relevant fields have changed since we fetched the sub + where: { + ...sub, + postTypes: { + equals: sub.postTypes + } + } + }) + + const trust = initialTrust({ name: updatedSub.name, userId: updatedSub.userId }) + for (const t of trust) { + await tx.userSubTrust.upsert({ + where: { + userId_subName: { userId: t.userId, subName: t.subName } + }, + update: t, + create: t + }) + } + + return updatedSub +} + +export async function describe (models, payInId) { + const { sub } = await models.subPayIn.findUnique({ where: { payInId }, include: { sub: true } }) + return `SN: unarchive territory ${sub.name}` +} diff --git a/api/payIn/types/territoryUpdate.js b/api/payIn/types/territoryUpdate.js new file mode 100644 index 0000000000..3379e3d463 --- /dev/null +++ b/api/payIn/types/territoryUpdate.js @@ -0,0 +1,96 @@ +import { throwOnExpiredUploads } from '@/api/resolvers/upload' +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 paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { oldName, billingType }, { me }) { + const oldSub = await models.sub.findUnique({ + where: { + name: oldName + } + }) + + const mcost = satsToMsats(proratedBillingCost(oldSub, billingType) ?? 0) + + return { + payInType: 'TERRITORY_UPDATE', + userId: me?.id, + mcost, + payOutCustodialTokens: mcost > 0n ? [{ payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' }] : [] + } +} + +export async function onBegin (tx, payInId, { oldName, billingType, ...data }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId } }) + const oldSub = await tx.sub.findUnique({ + where: { + name: oldName + } + }) + + data.billingCost = TERRITORY_PERIOD_COST(billingType) + + // we never want to bill them again if they are changing to ONCE + if (billingType === 'ONCE') { + data.billPaidUntil = null + data.billingAutoRenew = false + } + + // if they are changing to YEARLY, bill them in a year + // if they are changing to MONTHLY from YEARLY, do nothing + if (oldSub.billingType === 'MONTHLY' && billingType === 'YEARLY') { + data.billPaidUntil = datePivot(new Date(oldSub.billedLastAt), { years: 1 }) + } + + // if this billing change makes their bill paid up, set them to active + if (data.billPaidUntil === null || data.billPaidUntil >= new Date()) { + data.status = 'ACTIVE' + } + + // TODO: this is nasty + await throwOnExpiredUploads(data.uploadIds, { tx }) + if (data.uploadIds.length > 0) { + await tx.upload.updateMany({ + where: { + id: { in: data.uploadIds } + }, + data: { + paid: true + } + }) + } + delete data.uploadIds + + return await tx.sub.update({ + data: { + ...data, + subPayIn: { + create: [{ payInId }] + } + }, + where: { + // optimistic concurrency control + // make sure none of the relevant fields have changed since we fetched the sub + ...oldSub, + postTypes: { + equals: oldSub.postTypes + }, + name: oldName, + userId: payIn.userId + } + }) +} + +export async function describe (models, payInId) { + const { sub } = await models.subPayIn.findUnique({ where: { payInId }, include: { sub: true } }) + return `SN: update territory billing ${sub.name}` +} diff --git a/api/payIn/types/withdrawal.js b/api/payIn/types/withdrawal.js new file mode 100644 index 0000000000..3674ea75a4 --- /dev/null +++ b/api/payIn/types/withdrawal.js @@ -0,0 +1,39 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { parsePaymentRequest } from 'ln-service' +import { satsToMsats, numWithUnits, msatsToSats } from '@/lib/format' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS +] + +export async function getInitial (models, { bolt11, maxFee, protocolId }, { me }) { + const decodedBolt11 = parsePaymentRequest({ request: bolt11 }) + return { + payInType: 'WITHDRAWAL', + userId: me?.id, + mcost: BigInt(decodedBolt11.mtokens) + satsToMsats(maxFee), + payOutBolt11: { + payOutType: 'WITHDRAWAL', + msats: BigInt(decodedBolt11.mtokens), + bolt11, + hash: decodedBolt11.id, + userId: me.id, + protocolId + }, + payOutCustodialTokens: [ + { + payOutType: 'ROUTING_FEE', + userId: null, + mtokens: satsToMsats(maxFee), + custodialTokenType: 'SATS' + } + ] + } +} + +export async function describe (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { payOutBolt11: true } }) + return `SN: withdraw ${numWithUnits(msatsToSats(payIn.payOutBolt11.msats))}` +} diff --git a/api/payIn/types/zap.js b/api/payIn/types/zap.js new file mode 100644 index 0000000000..672722f850 --- /dev/null +++ b/api/payIn/types/zap.js @@ -0,0 +1,219 @@ +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' +import { notifyZapped } from '@/lib/webPush' +import { Prisma } from '@prisma/client' +import { payOutBolt11Prospect } from '../lib/payOutBolt11' +import { getItemResult, getSub } from '../lib/item' +import { getRedistributedPayOutCustodialTokens } from '../lib/payOutCustodialTokens' + +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.P2P, + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +async function tryP2P (models, { id, sats, hasSendWallet }, { me }) { + if (me.id !== USER_ID.anon) { + const zapper = await models.user.findUnique({ where: { id: me.id } }) + // if the zap is dust, or if me doesn't have a send wallet but has enough sats/credits to pay for it + // then we don't invoice the peer + if (sats < zapper?.sendCreditsBelowSats || + (!hasSendWallet && (zapper.mcredits + zapper.msats >= satsToMsats(sats)))) { + return false + } + } + + const item = await models.item.findUnique({ + where: { id: parseInt(id) }, + include: { + itemForwards: true, + user: true + } + }) + + // bios, forwards, or dust don't get sats + if (item.bio || item.itemForwards.length > 0 || sats < item.user.receiveCreditsBelowSats) { + return false + } + + return true +} + +// 70% to the receiver(s) +// if sub, 21% to the territory founder +// if p2p, 6% to rewards pool, 3% to routing fee +// if not p2p, all 9% to rewards pool +// if not sub +// if p2p, 27% to rewards pool, 3% to routing fee +// if not p2p, all 30% to rewards pool +export async function getInitial (models, payInArgs, { me }) { + const { subName, parentId, itemForwards, userId } = await models.item.findUnique({ where: { id: parseInt(payInArgs.id) }, include: { itemForwards: true, user: true } }) + const sub = await getSub(models, { subName, parentId }) + const mcost = satsToMsats(payInArgs.sats) + let payOutBolt11 + + const zapMtokens = mcost * 70n / 100n + const payOutCustodialTokensProspects = [] + + const p2p = await tryP2P(models, payInArgs, { me }) + if (p2p) { + try { + // 3% to routing fee + const routingFeeMtokens = mcost * 3n / 100n + // TODO: description, expiry? + payOutBolt11 = await payOutBolt11Prospect(models, { msats: zapMtokens }, { userId, payOutType: 'ZAP' }) + payOutCustodialTokensProspects.push({ payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }) + } catch (err) { + console.error('failed to create user invoice:', err) + } + } + + if (!payOutBolt11) { + if (itemForwards.length > 0) { + for (const f of itemForwards) { + payOutCustodialTokensProspects.push({ payOutType: 'ZAP', userId: f.userId, mtokens: zapMtokens * BigInt(f.pct) / 100n, custodialTokenType: 'CREDITS' }) + } + } + const remainingZapMtokens = zapMtokens - payOutCustodialTokensProspects.filter(t => t.payOutType === 'ZAP').reduce((acc, t) => acc + t.mtokens, 0n) + payOutCustodialTokensProspects.push({ payOutType: 'ZAP', userId, mtokens: remainingZapMtokens, custodialTokenType: 'CREDITS' }) + } + + // what's left goes to the rewards pool + const payOutCustodialTokens = getRedistributedPayOutCustodialTokens({ sub, mcost, payOutCustodialTokens: payOutCustodialTokensProspects, payOutBolt11 }) + + return { + payInType: 'ZAP', + userId: me.id, + mcost, + itemPayIn: { itemId: parseInt(payInArgs.id) }, + payOutCustodialTokens, + payOutBolt11 + } +} + +export async function onBegin (tx, payInId, payInArgs) { + const item = await getItemResult(tx, { id: payInArgs.id }) + return { id: item.id, path: item.path, sats: payInArgs.sats, act: 'TIP' } +} + +export async function onRetry (tx, oldPayInId, newPayInId) { + const { itemId, payIn } = await tx.itemPayIn.findUnique({ where: { payInId: oldPayInId }, include: { payIn: true } }) + await tx.itemPayIn.create({ data: { itemId, payInId: newPayInId } }) + const item = await getItemResult(tx, { id: itemId }) + return { id: item.id, path: item.path, sats: msatsToSats(payIn.mcost), act: 'TIP' } +} + +export async function onPaid (tx, payInId) { + const payIn = await tx.payIn.findUnique({ + where: { id: payInId }, + include: { + itemPayIn: { include: { item: true } }, + payOutBolt11: true + } + }) + + const msats = payIn.mcost + const sats = msatsToSats(msats) + const userId = payIn.userId + const item = payIn.itemPayIn.item + + // perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt + // NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking + await tx.$queryRaw` + WITH territory AS ( + SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName" + FROM "Item" i + LEFT JOIN "Item" r ON r.id = i."rootId" + WHERE i.id = ${item.id}::INTEGER + ), zapper AS ( + SELECT + COALESCE(${item.parentId + ? Prisma.sql`"zapCommentTrust"` + : Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust", + COALESCE(${item.parentId + ? Prisma.sql`"subZapCommentTrust"` + : Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust" + FROM territory + LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName" + AND ust."userId" = ${userId}::INTEGER + ), zap AS ( + INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats") + VALUES (${userId}::INTEGER, ${item.id}::INTEGER, ${sats}::INTEGER) + ON CONFLICT ("itemId", "userId") DO UPDATE + SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now() + RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote, + LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats + ), item_zapped AS ( + UPDATE "Item" + SET + "weightedVotes" = "weightedVotes" + zapper."zapTrust" * zap.log_sats, + "subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats, + upvotes = upvotes + zap.first_vote, + msats = "Item".msats + ${msats}::BIGINT, + mcredits = "Item".mcredits + ${payIn.payOutBolt11 ? 0n : msats}::BIGINT, + "lastZapAt" = now() + FROM zap, zapper + WHERE "Item".id = ${item.id}::INTEGER + RETURNING "Item".*, zapper."zapTrust" * zap.log_sats as "weightedVote" + ), ancestors AS ( + SELECT "Item".* + FROM "Item", item_zapped + WHERE "Item".path @> item_zapped.path AND "Item".id <> item_zapped.id + ORDER BY "Item".id + ) + UPDATE "Item" + SET "weightedComments" = "Item"."weightedComments" + item_zapped."weightedVote", + "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT, + "commentMcredits" = "Item"."commentMcredits" + ${payIn.payOutBolt11 ? 0n : msats}::BIGINT + FROM item_zapped, ancestors + WHERE "Item".id = ancestors.id` + + // record potential bounty payment + // NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust + // we won't miss a zap that aggregates into a bounty payment, regardless of the order of updates + await tx.$executeRaw` + WITH bounty AS ( + SELECT root.id, "ItemUserAgg"."zapSats" >= root.bounty AS paid, "ItemUserAgg"."itemId" AS target + FROM "ItemUserAgg" + JOIN "Item" ON "Item".id = "ItemUserAgg"."itemId" + LEFT JOIN "Item" root ON root.id = "Item"."rootId" + WHERE "ItemUserAgg"."userId" = ${userId}::INTEGER + AND "ItemUserAgg"."itemId" = ${item.id}::INTEGER + AND root."userId" = ${userId}::INTEGER + AND root.bounty IS NOT NULL + ) + UPDATE "Item" + SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL) + FROM bounty + WHERE "Item".id = bounty.id AND bounty.paid` +} + +export async function onPaidSideEffects (models, payInId) { + const payIn = await models.payIn.findUnique({ + where: { id: payInId }, + include: { itemPayIn: { include: { item: true } } } + }) + // avoid duplicate notifications with the same zap amount + // by checking if there are any other pending acts on the item + const pendingZaps = await models.itemPayIn.count({ + where: { + itemId: payIn.itemPayIn.itemId, + payIn: { + payInType: 'ZAP', + createdAt: { + gt: payIn.createdAt + } + } + } + }) + if (pendingZaps === 0) notifyZapped({ models, item: payIn.itemPayIn.item }).catch(console.error) +} + +export async function describe (models, payInId) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { itemPayIn: true } }) + return `SN: zap ${numWithUnits(msatsToSats(payIn.mcost), { abbreviate: false })} #${payIn.itemPayIn.itemId}` +} diff --git a/api/payingAction/index.js b/api/payingAction/index.js deleted file mode 100644 index cdc6db5cdf..0000000000 --- a/api/payingAction/index.js +++ /dev/null @@ -1,64 +0,0 @@ -import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' -import { msatsToSats, satsToMsats, toPositiveBigInt } from '@/lib/format' -import { Prisma } from '@prisma/client' -import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service' - -// paying actions are completely distinct from paid actions -// and there's only one paying action: send -// ... still we want the api to at least be similar -export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) { - try { - console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId) - - if (!me) { - throw new Error('You must be logged in to perform this action') - } - - const decoded = await parsePaymentRequest({ request: bolt11 }) - const cost = toPositiveBigInt(toPositiveBigInt(decoded.mtokens) + satsToMsats(maxFee)) - - console.log('cost', cost) - - const withdrawal = await models.$transaction(async tx => { - await tx.user.update({ - where: { - id: me.id - }, - data: { msats: { decrement: cost } } - }) - - return await tx.withdrawl.create({ - data: { - hash: decoded.id, - bolt11, - msatsPaying: toPositiveBigInt(decoded.mtokens), - msatsFeePaying: satsToMsats(maxFee), - userId: me.id, - protocolId, - autoWithdraw: !!protocolId - } - }) - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) - - payViaPaymentRequest({ - lnd, - request: withdrawal.bolt11, - max_fee: msatsToSats(withdrawal.msatsFeePaying), - pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, - confidence: LND_PATHFINDING_TIME_PREF_PPM - }).catch(console.error) - - return withdrawal - } catch (e) { - if (e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { - throw new Error('insufficient funds') - } - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - throw new Error('you cannot withdraw to the same invoice twice') - } - console.error('performPayingAction failed', e) - throw e - } finally { - console.groupEnd() - } -} diff --git a/api/resolvers/index.js b/api/resolvers/index.js index 65794f8e59..6a559de5c6 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -20,6 +20,7 @@ import chainFee from './chainFee' import { GraphQLScalarType, Kind } from 'graphql' import { createIntScalar } from 'graphql-scalar' import paidAction from './paidAction' +import payIn from './payIn' const date = new GraphQLScalarType({ name: 'Date', @@ -48,6 +49,49 @@ const date = new GraphQLScalarType({ } }) +function isSafeInteger (val) { + return val <= Number.MAX_SAFE_INTEGER && val >= Number.MIN_SAFE_INTEGER +} + +function serializeBigInt (value) { + if (isSafeInteger(value)) { + return Number(value) + } + return value.toString() +} + +const bigint = new GraphQLScalarType({ + name: 'BigInt', + description: 'BigInt custom scalar type', + serialize (value) { + if (typeof value === 'bigint') { + return serializeBigInt(value) + } else if (typeof value === 'string') { + const bigint = BigInt(value) + if (bigint.toString() === value) { + return serializeBigInt(bigint) + } + } + throw Error('GraphQL BigInt Scalar serializer expected a `bigint` object got `' + typeof value + '` ' + value) + }, + parseValue (value) { + const bigint = BigInt(value.toString()) + if (bigint.toString() === value.toString()) { + return bigint + } + + throw new Error('GraphQL BigInt Scalar parser expected a `number` or `string` got `' + typeof value + '` ' + value) + }, + parseLiteral (ast) { + const bigint = BigInt(ast.value) + if (bigint.toString() === ast.value.toString()) { + return bigint + } + + throw new Error('GraphQL BigInt Scalar parser expected a `number` or `string` got `' + typeof ast.value + '` ' + ast.value) + } +}) + const limit = createIntScalar({ name: 'Limit', description: 'Limit custom scalar type', @@ -56,4 +100,4 @@ const limit = createIntScalar({ export default [user, item, message, walletV1, walletV2, lnurl, notifications, invite, sub, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, - { JSONObject }, { Date: date }, { Limit: limit }, paidAction] + { JSONObject }, { Date: date }, { Limit: limit }, { BigInt: bigint }, paidAction, payIn] diff --git a/api/resolvers/item.js b/api/resolvers/item.js index a34fc8226b..8265a6bfab 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -22,11 +22,11 @@ import { datePivot, whenRange } from '@/lib/time' import { uploadIdsFromText } from './upload' import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' -import performPaidAction from '../paidAction' import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { verifyHmac } from './wallet' import { parse } from 'tldts' import { shuffleArray } from '@/lib/rand' +import pay from '../payIn' function commentsOrderByClause (me, models, sort) { const sharedSortsArray = [] @@ -39,7 +39,7 @@ function commentsOrderByClause (me, models, sort) { if (sort === 'recent') { return `ORDER BY ${sharedSorts}, ("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC, - COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC` + COALESCE(("payIn"->>'payInStateChangedAt')::timestamp(3), "Item".created_at) DESC, "Item".id DESC` } if (sort === 'hot') { @@ -63,11 +63,11 @@ async function comments (me, models, item, sort, cursor) { const decodedCursor = decodeCursor(cursor) const offset = decodedCursor.offset + const filter = ` ("Item"."parentId" <> $1 OR "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3)) ` // XXX what a mess let comments if (me) { - const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) AND ("Item"."parentId" <> $1 OR "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3)) ` if (item.ncomments > FULL_COMMENTS_THRESHOLD) { const [{ item_comments_zaprank_with_me_limited: limitedComments }] = await models.$queryRawUnsafe( 'SELECT item_comments_zaprank_with_me_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8, $9)', @@ -80,7 +80,6 @@ async function comments (me, models, item, sort, cursor) { comments = fullComments } } else { - const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID') AND ("Item"."parentId" <> $1 OR "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3)) ` if (item.ncomments > FULL_COMMENTS_THRESHOLD) { const [{ item_comments_limited: limitedComments }] = await models.$queryRawUnsafe( 'SELECT item_comments_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6, $7)', @@ -106,6 +105,7 @@ export async function getItem (parent, { id }, { me, models }) { query: ` ${SELECT} FROM "Item" + ${payInJoinFilter(me)} ${whereClause( '"Item".id = $1', activeOrMine(me) @@ -121,6 +121,7 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m query: ` ${SELECT} FROM "Item" + ${payInJoinFilter(me)} LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" ${whereClause( '"parentId" IS NULL', @@ -165,22 +166,31 @@ export function joinHotScoreView (me, models) { export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args) { if (!me) { return await models.$queryRawUnsafe(` - SELECT "Item".*, to_json(users.*) as user, to_jsonb("Sub".*) as sub + SELECT "Item".*, to_json(users.*) as user, to_jsonb("Sub".*) as sub, to_jsonb("PayIn".*) as "payIn" FROM ( ${query} ) "Item" JOIN users ON "Item"."userId" = users.id LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" + LEFT JOIN LATERAL ( + SELECT "PayIn".* + FROM "ItemPayIn" + JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" AND "PayIn"."payInType" = 'ITEM_CREATE' + WHERE "ItemPayIn"."itemId" = "Item".id AND "PayIn"."payInState" = 'PAID' + ORDER BY "PayIn"."created_at" DESC + LIMIT 1 + ) "PayIn" ON "PayIn".id IS NOT NULL ${orderBy}`, ...args) } else { return await models.$queryRawUnsafe(` SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user, - COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", - COALESCE("ItemAct"."meMcredits", 0) as "meMcredits", COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", - COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark", + COALESCE("MeItemPayIn"."meMsats", 0) as "meMsats", COALESCE("MeItemPayIn"."mePendingMsats", 0) as "mePendingMsats", + COALESCE("MeItemPayIn"."meMcredits", 0) as "meMcredits", COALESCE("MeItemPayIn"."mePendingMcredits", 0) as "mePendingMcredits", + COALESCE("MeItemPayIn"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward", to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL) - || jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub + || jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub, + to_jsonb("PayIn".*) || jsonb_build_object('payInStateChangedAt', "PayIn"."payInStateChangedAt" AT TIME ZONE 'UTC') as "payIn" FROM ( ${query} ) "Item" @@ -194,18 +204,26 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, .. LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id} LEFT JOIN LATERAL ( SELECT "itemId", - sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMsats", - sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMcredits", - sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "mePendingMsats", - sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND "InvoiceForward".id IS NULL AND (act = 'FEE' OR act = 'TIP')) AS "mePendingMcredits", - sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND act = 'DONT_LIKE_THIS') AS "meDontLikeMsats" - FROM "ItemAct" - LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" - LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" - WHERE "ItemAct"."userId" = ${me.id} - AND "ItemAct"."itemId" = "Item".id - GROUP BY "ItemAct"."itemId" - ) "ItemAct" ON true + sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> 'FAILED' AND "PayOutBolt11".id IS NOT NULL AND "PayIn"."payInType" = 'ZAP') AS "meMsats", + sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> 'FAILED' AND "PayOutBolt11".id IS NULL AND "PayIn"."payInType" = 'ZAP') AS "meMcredits", + sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" = 'PENDING' AND "PayOutBolt11".id IS NOT NULL AND "PayIn"."payInType" = 'ZAP') AS "mePendingMsats", + sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" = 'PENDING' AND "PayOutBolt11".id IS NULL AND "PayIn"."payInType" = 'ZAP') AS "mePendingMcredits", + sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> 'FAILED' AND "PayIn"."payInType" = 'DOWN_ZAP') AS "meDontLikeMsats" + FROM "ItemPayIn" + JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" + LEFT JOIN "PayOutBolt11" ON "PayOutBolt11"."payInId" = "PayIn"."id" + WHERE "PayIn"."userId" = ${me.id} + AND "ItemPayIn"."itemId" = "Item".id + GROUP BY "ItemPayIn"."itemId" + ) "MeItemPayIn" ON true + LEFT JOIN LATERAL ( + SELECT "PayIn".* + FROM "ItemPayIn" + JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" AND "PayIn"."payInType" = 'ITEM_CREATE' + WHERE "ItemPayIn"."itemId" = "Item".id AND ("PayIn"."userId" = ${me.id} OR "PayIn"."payInState" = 'PAID') + ORDER BY "PayIn"."created_at" DESC + LIMIT 1 + ) "PayIn" ON "PayIn".id IS NOT NULL ${orderBy}`, ...args) } } @@ -213,25 +231,45 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, .. const relationClause = (type) => { let clause = '' switch (type) { - case 'comments': - clause += ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = root."subName" ' - break case 'bookmarks': clause += ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" LEFT JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName") ' break + case 'comments': case 'outlawed': case 'borderland': case 'freebies': case 'all': clause += ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName") ' break - default: + default: // posts which are their own root clause += ' FROM "Item" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" ' } return clause } +export const payInJoinFilter = me => { + if (me) { + return ` JOIN LATERAL ( + SELECT "PayIn".* + FROM "ItemPayIn" + JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" AND "PayIn"."payInType" = 'ITEM_CREATE' + WHERE "ItemPayIn"."itemId" = "Item".id AND ("PayIn"."userId" = ${me.id} OR "PayIn"."payInState" = 'PAID') + ORDER BY "PayIn"."created_at" DESC + LIMIT 1 + ) "PayIn" ON "PayIn".id IS NOT NULL ` + } + + return ` JOIN LATERAL ( + SELECT "PayIn".* + FROM "ItemPayIn" + JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" AND "PayIn"."payInType" = 'ITEM_CREATE' + WHERE "ItemPayIn"."itemId" = "Item".id AND "PayIn"."payInState" = 'PAID' + ORDER BY "PayIn"."created_at" DESC + LIMIT 1 + ) "PayIn" ON "PayIn".id IS NOT NULL ` +} + const selectClause = (type) => type === 'bookmarks' ? `${SELECT}, "Bookmark"."created_at" as "bookmarkCreatedAt"` : SELECT @@ -249,9 +287,8 @@ function whenClause (when, table) { export const activeOrMine = (me) => { return me - ? [`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id})`, - `("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})`] - : ['("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\')', '"Item".status <> \'STOPPED\''] + ? `("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})` + : '"Item".status <> \'STOPPED\'' } export const muteClause = me => @@ -409,6 +446,7 @@ export default { query: ` ${selectClause(type)} ${relationClause(type)} + ${payInJoinFilter(me)} ${whereClause( `"${table}"."userId" = $3`, activeOrMine(me), @@ -429,6 +467,7 @@ export default { query: ` ${SELECT} ${relationClause(type)} + ${payInJoinFilter(me)} ${whereClause( '"Item".created_at <= $1', '"Item"."deletedAt" IS NULL', @@ -438,10 +477,10 @@ export default { typeClause(type), muteClause(me) )} - ORDER BY COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC + ORDER BY COALESCE("PayIn"."payInStateChangedAt", "Item".created_at) DESC OFFSET $2 LIMIT $3`, - orderBy: 'ORDER BY COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC' + orderBy: 'ORDER BY COALESCE("PayIn"."payInStateChangedAt", "Item".created_at) DESC' }, decodedCursor.time, decodedCursor.offset, limit, ...subArr) break case 'top': @@ -451,6 +490,7 @@ export default { query: ` ${selectClause(type)} ${relationClause(type)} + ${payInJoinFilter(me)} ${whereClause( '"Item"."deletedAt" IS NULL', type === 'posts' && '"Item"."subName" IS NOT NULL', @@ -510,6 +550,7 @@ export default { THEN rank() OVER (ORDER BY boost DESC, created_at ASC) ELSE rank() OVER (ORDER BY created_at DESC) END AS rank FROM "Item" + ${payInJoinFilter(me)} ${whereClause( '"parentId" IS NULL', '"Item"."deletedAt" IS NULL', @@ -540,6 +581,7 @@ export default { ) FROM "Item" JOIN "Pin" ON "Item"."pinId" = "Pin".id + ${payInJoinFilter(me)} ${whereClause( '"pinId" IS NOT NULL', '"parentId" IS NULL', @@ -561,6 +603,7 @@ export default { FROM "Item" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" ${joinHotScoreView(me, models)} + ${payInJoinFilter(me)} ${whereClause( // in home (sub undefined), filter out global pinned items since we inject them later sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)', @@ -659,7 +702,8 @@ export default { query: ` ${SELECT} FROM "Item" - WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID') + ${payInJoinFilter(me)} + WHERE url ~* $1 ORDER BY created_at DESC LIMIT 3` }, similar) @@ -706,11 +750,7 @@ export default { status: 'ACTIVE', deletedAt: null, outlawed: false, - parentId: null, - OR: [ - { invoiceActionState: 'PAID' }, - { invoiceActionState: null } - ] + parentId: null } if (id) { where.id = { not: Number(id) } @@ -748,6 +788,7 @@ export default { query: ` ${SELECT} FROM "Item" + ${payInJoinFilter(me)} -- comments can be nested, so we need to get all comments that are descendants of the root ${whereClause( '"Item".path <@ (SELECT path FROM "Item" WHERE id = $1 AND "Item"."lastCommentAt" > $2)', @@ -957,8 +998,7 @@ export default { if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) } else { - item = await createItem(parent, item, { me, models, lnd }) - return item + return await createItem(parent, item, { me, models, lnd }) } }, updateNoteId: async (parent, { id, noteId }, { me, models }) => { @@ -978,26 +1018,35 @@ export default { throw new GqlAuthenticationError() } - return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd }) + return await pay('POLL_VOTE', { id }, { me, models }) }, act: async (parent, { id, sats, act = 'TIP', hasSendWallet }, { me, models, lnd, headers }) => { assertApiKeyNotPermitted({ me }) await validateSchema(actSchema, { sats, act }) await assertGofacYourself({ models, headers }) - const [item] = await models.$queryRawUnsafe(` - ${SELECT} - FROM "Item" - WHERE id = $1`, Number(id)) + const item = await models.item.findUnique({ + where: { id: Number(id) }, + include: { + itemPayIns: { + where: { + payIn: { + payInType: 'ITEM_CREATE', + payInState: 'PAID' + } + } + } + } + }) + + if (item.itemPayIns.length === 0) { + throw new GqlInputError('cannot act on unpaid item') + } if (item.deletedAt) { throw new GqlInputError('item is deleted') } - if (item.invoiceActionState && item.invoiceActionState !== 'PAID') { - throw new GqlInputError('cannot act on unpaid item') - } - // disallow self tips except anons if (me && ['TIP', 'DONT_LIKE_THIS'].includes(act)) { if (Number(item.userId) === Number(me.id)) { @@ -1014,11 +1063,11 @@ export default { } if (act === 'TIP') { - return await performPaidAction('ZAP', { id, sats, hasSendWallet }, { me, models, lnd }) + return await pay('ZAP', { id, sats, hasSendWallet }, { me, models }) } else if (act === 'DONT_LIKE_THIS') { - return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd }) + return await pay('DOWN_ZAP', { id, sats }, { me, models }) } else if (act === 'BOOST') { - return await performPaidAction('BOOST', { id, sats }, { me, models, lnd }) + return await pay('BOOST', { id, sats }, { me, models }) } else { throw new GqlInputError('unknown act') } @@ -1075,21 +1124,25 @@ export default { return result } }, - 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, - actionState: itemAct.invoiceActionState - } - } - return null - } - }, Item: { - invoicePaidAt: async (item, args, { models }) => { - return item.invoicePaidAtUTC ?? item.invoicePaidAt + payIn: async (item, args, { models }) => { + if (typeof item.payIn !== 'undefined') { + return item.payIn + } + + // TODO: very inefficient on a relative basis, so if need be we can: + // 1. denormalize payInId that created the item to it + // 2. add this to the getItemMeta query (done) + const payIn = await models.payIn.findFirst({ + where: { + itemPayIn: { + itemId: item.id + }, + payInType: 'ITEM_CREATE', + successorId: null + } + }) + return payIn }, sats: async (item, args, { models, me }) => { if (me?.id === item.userId) { @@ -1164,12 +1217,10 @@ export default { } const options = await models.$queryRaw` - SELECT "PollOption".id, option, - (count("PollVote".id) - FILTER(WHERE "PollVote"."invoiceActionState" IS NULL - OR "PollVote"."invoiceActionState" = 'PAID'))::INTEGER as count + SELECT "PollOption".id, option, count("PollVote".id)::INTEGER as count FROM "PollOption" LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id + LEFT JOIN "PayIn" on "PayIn"."id" = "PollVote"."payInId" AND "PayIn"."payInState" = 'PAID' WHERE "PollOption"."itemId" = ${item.id} GROUP BY "PollOption".id ORDER BY "PollOption".id ASC @@ -1177,15 +1228,17 @@ export default { const poll = {} if (me) { - const meVoted = await models.pollBlindVote.findFirst({ + const meVoted = await models.payIn.findFirst({ where: { userId: me.id, - itemId: item.id + payInType: 'POLL_VOTE', + payInState: 'PAID', + itemPayIn: { + itemId: item.id + } } }) poll.meVoted = !!meVoted - poll.meInvoiceId = meVoted?.invoiceId - poll.meInvoiceActionState = meVoted?.invoiceActionState } else { poll.meVoted = false } @@ -1244,28 +1297,23 @@ export default { return msatsToSats(BigInt(item.meMsats) + BigInt(item.meMcredits)) } - const { _sum: { msats } } = await models.itemAct.aggregate({ + const { _sum: { mcost } } = await models.payIn.aggregate({ _sum: { - msats: true + mcost: true }, where: { - itemId: Number(item.id), + itemPayIn: { + itemId: Number(item.id) + }, + payInType: 'ZAP', userId: me.id, - invoiceActionState: { + payInState: { not: 'FAILED' - }, - OR: [ - { - act: 'TIP' - }, - { - act: 'FEE' - } - ] + } } }) - return (msats && msatsToSats(msats)) || 0 + return (mcost && msatsToSats(mcost)) || 0 }, meCredits: async (item, args, { me, models }) => { if (!me) return 0 @@ -1273,31 +1321,26 @@ export default { return msatsToSats(item.meMcredits) } - const { _sum: { msats } } = await models.itemAct.aggregate({ + const { _sum: { mcost } } = await models.payIn.aggregate({ _sum: { - msats: true + mcost: true }, where: { - itemId: Number(item.id), + payInType: 'ZAP', userId: me.id, - invoiceActionState: { + payInState: { not: 'FAILED' }, - invoice: { - invoiceForward: { is: null } + payOutBolt11: { + is: null }, - OR: [ - { - act: 'TIP' - }, - { - act: 'FEE' - } - ] + itemPayIn: { + itemId: Number(item.id) + } } }) - return (msats && msatsToSats(msats)) || 0 + return (mcost && msatsToSats(mcost)) || 0 }, meDontLikeSats: async (item, args, { me, models }) => { if (!me) return 0 @@ -1305,21 +1348,23 @@ export default { return msatsToSats(item.meDontLikeMsats) } - const { _sum: { msats } } = await models.itemAct.aggregate({ + const { _sum: { mcost } } = await models.payIn.aggregate({ _sum: { - msats: true + mcost: true }, where: { - itemId: Number(item.id), + payInType: 'DOWN_ZAP', userId: me.id, - act: 'DONT_LIKE_THIS', - invoiceActionState: { + payInState: { not: 'FAILED' + }, + itemPayIn: { + itemId: Number(item.id) } } }) - return (msats && msatsToSats(msats)) || 0 + return (mcost && msatsToSats(mcost)) || 0 }, meBookmark: async (item, args, { me, models }) => { if (!me) return false @@ -1381,25 +1426,11 @@ export default { ${SELECT} FROM "Item" ${whereClause( - '"Item".id = $1', - `("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})` - )}` + '"Item".id = $1')}` }, Number(item.rootId)) 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, - actionState: item.invoiceActionState, - confirmedAt: item.invoicePaidAtUTC ?? item.invoicePaidAt - } - } - - return null - }, parent: async (item, args, { models }) => { if (!item.parentId) { return null @@ -1447,7 +1478,27 @@ export default { export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ...item }, { me, models, lnd }) => { // update iff this item belongs to me - const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { invoice: true, sub: true } }) + const old = await models.item.findUnique({ + where: { id: Number(item.id) }, + include: { + itemPayIns: { + where: { + payIn: { + payInType: 'ITEM_CREATE', + payInState: 'PAID' + } + }, + include: { + payIn: { + include: { + payInBolt11: true + } + } + } + }, + sub: true + } + }) if (old.deletedAt) { throw new GqlInputError('item is deleted') @@ -1461,8 +1512,9 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. const adminEdit = ADMIN_ITEMS.includes(old.id) && SN_ADMIN_IDS.includes(meId) // anybody can edit with valid hash+hmac let hmacEdit = false - if (old.invoice?.hash && hash && hmac) { - hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac) + const payIn = old.itemPayIns[0]?.payIn + if (payIn?.payInBolt11?.hash && hash && hmac) { + hmacEdit = payIn.payInBolt11.hash === hash && verifyHmac(hash, hmac) } // ownership permission check const ownerEdit = authorEdit || adminEdit || hmacEdit @@ -1486,7 +1538,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. // edits are only allowed for own items within 10 minutes // but forever if an admin is editing an "admin item", it's their bio or a job const myBio = user.bioId === old.id - const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { seconds: ITEM_EDIT_SECONDS }) + const timer = Date.now() < datePivot(new Date(payIn?.payInStateChangedAt ?? old.createdAt), { seconds: ITEM_EDIT_SECONDS }) const canEdit = (timer && ownerEdit) || adminEdit || myBio || isJob(old) if (!canEdit) { throw new GqlInputError('item can no longer be edited') @@ -1512,10 +1564,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. // never change author of item item.userId = old.userId - const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd }) - - resultItem.comments = [] - return resultItem + return await pay('ITEM_UPDATE', item, { models, me, lnd }) } export const createItem = async (parent, { forward, ...item }, { me, models, lnd }) => { @@ -1534,8 +1583,8 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd } if (item.parentId) { - const parent = await models.item.findUnique({ where: { id: parseInt(item.parentId) } }) - if (parent.invoiceActionState && parent.invoiceActionState !== 'PAID') { + const parent = await models.itemPayIn.findFirst({ where: { itemId: parseInt(item.parentId), payIn: { payInType: 'ITEM_CREATE', payInState: 'PAID' } } }) + if (!parent) { throw new GqlInputError('cannot comment on unpaid item') } } @@ -1543,10 +1592,7 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd // mark item as created with API key item.apiKey = me?.apiKey - const resultItem = await performPaidAction('ITEM_CREATE', item, { models, me, lnd }) - - resultItem.comments = [] - return resultItem + return await pay('ITEM_CREATE', item, { models, me, lnd }) } export const getForwardUsers = async (models, forward) => { diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 1b1ec7ea1f..9e813c0d35 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,11 +1,11 @@ import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor' -import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item' +import { getItem, filterClause, whereClause, muteClause, activeOrMine, payInJoinFilter } from './item' import { getInvoice, getWithdrawl } from './wallet' import { pushSubscriptionSchema, validateSchema } from '@/lib/validate' import { sendPushSubscriptionReply } from '@/lib/webPush' import { getSub } from './sub' import { GqlAuthenticationError, GqlInputError } from '@/lib/error' -import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants' +import { getPayIn } from './payIn' export default { Query: { @@ -165,6 +165,7 @@ export default { FROM ( ${itemDrivenQueries.map(q => `(${q})`).join(' UNION ALL ')} ) as "Item" + ${payInJoinFilter(me)} ${whereClause( '"Item".created_at < $2', '"Item"."deletedAt" IS NULL', @@ -369,33 +370,46 @@ export default { LIMIT ${LIMIT})` ) + // queries.push( + // `(SELECT "Invoice".id::text, + // CASE + // WHEN + // "Invoice"."paymentAttempt" < ${WALLET_MAX_RETRIES} + // AND "Invoice"."userCancel" = false + // AND "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}' + // THEN "Invoice"."cancelledAt" + interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}' + // ELSE "Invoice"."updated_at" + // END AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type + // FROM "Invoice" + // WHERE "Invoice"."userId" = $1 + // AND "Invoice"."updated_at" < $2 + // AND "Invoice"."actionState" = 'FAILED' + // AND ( + // -- this is the inverse of the filter for automated retries + // "Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES} + // OR "Invoice"."userCancel" = true + // OR "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}' + // ) + // AND ( + // "Invoice"."actionType" = 'ITEM_CREATE' OR + // "Invoice"."actionType" = 'ZAP' OR + // "Invoice"."actionType" = 'DOWN_ZAP' OR + // "Invoice"."actionType" = 'POLL_VOTE' OR + // "Invoice"."actionType" = 'BOOST' + // ) + // ORDER BY "sortTime" DESC + // LIMIT ${LIMIT})` + // ) + queries.push( - `(SELECT "Invoice".id::text, - CASE - WHEN - "Invoice"."paymentAttempt" < ${WALLET_MAX_RETRIES} - AND "Invoice"."userCancel" = false - AND "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}' - THEN "Invoice"."cancelledAt" + interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}' - ELSE "Invoice"."updated_at" - END AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type - FROM "Invoice" - WHERE "Invoice"."userId" = $1 - AND "Invoice"."updated_at" < $2 - AND "Invoice"."actionState" = 'FAILED' - AND ( - -- this is the inverse of the filter for automated retries - "Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES} - OR "Invoice"."userCancel" = true - OR "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}' - ) - AND ( - "Invoice"."actionType" = 'ITEM_CREATE' OR - "Invoice"."actionType" = 'ZAP' OR - "Invoice"."actionType" = 'DOWN_ZAP' OR - "Invoice"."actionType" = 'POLL_VOTE' OR - "Invoice"."actionType" = 'BOOST' - ) + `(SELECT "PayIn".id::text, + "PayIn"."payInStateChangedAt" AS "sortTime", NULL as "earnedSats", 'PayInFailed' AS type + FROM "PayIn" + WHERE "PayIn"."userId" = $1 + AND "PayIn"."payInStateChangedAt" < $2 + AND "PayIn"."payInState" = 'FAILED' + AND "PayIn"."payInType" IN ('ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'BOOST') + AND "PayIn"."successorId" IS NULL ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) @@ -587,8 +601,12 @@ export default { InvoicePaid: { invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models }) }, - Invoicification: { - invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models }) + PayInFailed: { + payIn: async (n, args, { me, models }) => getPayIn(n, { id: Number(n.id) }, { me, models }), + item: async (n, args, { models, me }) => { + const { itemId } = await models.itemPayIn.findUnique({ where: { payInId: Number(n.id) } }) + return await getItem(n, { id: itemId }, { models, me }) + } }, WithdrawlPaid: { withdrawl: async (n, args, { me, models }) => getWithdrawl(n, { id: n.id }, { me, models }) diff --git a/api/resolvers/payIn.js b/api/resolvers/payIn.js new file mode 100644 index 0000000000..eb04e4adb9 --- /dev/null +++ b/api/resolvers/payIn.js @@ -0,0 +1,239 @@ +import { USER_ID } from '@/lib/constants' +import { GqlInputError } from '@/lib/error' +import { verifyHmac } from './wallet' +import { payInCancel } from '../payIn/transitions' +import { retry } from '../payIn' +import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' +import { getItem } from './item' +import { getSub } from './sub' + +function payInResultType (payInType) { + switch (payInType) { + case 'ITEM_CREATE': + case 'ITEM_UPDATE': + return 'Item' + case 'ZAP': + case 'DOWN_ZAP': + case 'BOOST': + return 'ItemAct' + case 'POLL_VOTE': + return 'PollVote' + case 'TERRITORY_CREATE': + case 'TERRITORY_UPDATE': + case 'TERRITORY_BILLING': + case 'TERRITORY_UNARCHIVE': + return 'Sub' + } +} + +const INCLUDE_PAYOUT_CUSTODIAL_TOKENS = { + include: { + user: true, + subPayOutCustodialToken: { + include: { + sub: true + } + } + } +} + +const INCLUDE = { + payInBolt11: { + include: { + lud18Data: true, + nostrNote: true, + comment: true + } + }, + payOutBolt11: { + include: { + user: true + } + }, + pessimisticEnv: true, + payInCustodialTokens: true, + payOutCustodialTokens: INCLUDE_PAYOUT_CUSTODIAL_TOKENS, + beneficiaries: { + include: { + payOutCustodialTokens: INCLUDE_PAYOUT_CUSTODIAL_TOKENS + } + }, + itemPayIn: true, + subPayIn: true +} + +export async function getPayIn (parent, { id }, { me, models }) { + const payIn = await models.PayIn.findUnique({ + where: { id }, + include: INCLUDE + }) + if (!payIn) { + throw new Error('PayIn not found') + } + return payIn +} + +function isMine (payIn, { me }) { + return payIn.userId === USER_ID.anon || (me && Number(me.id) === Number(payIn.userId)) +} + +export default { + Query: { + payIn: getPayIn, + satistics: async (parent, { cursor, inc }, { models, me }) => { + const userId = me?.id ?? USER_ID.anon + const decodedCursor = decodeCursor(cursor) + const payIns = await models.PayIn.findMany({ + where: { + OR: [ + { userId }, + { payOutBolt11: { userId } }, + { payOutCustodialTokens: { some: { userId } } } + ], + benefactorId: null, + createdAt: { + lte: decodedCursor.time + } + }, + include: INCLUDE, + orderBy: { createdAt: 'desc' }, + take: LIMIT, + skip: decodedCursor.offset + }) + return { + payIns, + cursor: payIns.length === LIMIT ? nextCursorEncoded(decodedCursor) : null + } + } + }, + Mutation: { + cancelPayInBolt11: async (parent, { hash, hmac, userCancel }, { models, me, boss, lnd }) => { + const payInBolt11 = await models.PayInBolt11.findUnique({ where: { hash } }) + if (me && !hmac) { + if (!payInBolt11) throw new GqlInputError('bolt11 not found') + if (payInBolt11.userId !== me.id) throw new GqlInputError('not ur bolt11') + } else { + verifyHmac(hash, hmac) + } + return await payInCancel({ + data: { + payInId: payInBolt11.payInId, + payInFailureReason: userCancel ? 'USER_CANCELLED' : 'SYSTEM_CANCELLED' + }, + models, + me, + boss, + lnd + }) + }, + retryPayIn: async (parent, { payInId }, { models, me }) => { + return await retry(payInId, { models, me }) + } + }, + PayIn: { + // the payIn result is dependent on the payIn type + // so we need to resolve the type here + result: (payIn, args, { models, me }) => { + if (!isMine(payIn, { me })) { + return null + } + // if the payIn was paid pessimistically, the result is permanently in the pessimisticEnv + const result = payIn.result || payIn.pessimisticEnv?.result + if (result) { + return { ...result, __typename: payInResultType(payIn.payInType) } + } + return null + }, + userId: (payIn, args, { me }) => { + if (!isMine(payIn, { me })) { + return null + } + return payIn.userId + }, + payInBolt11: async (payIn, args, { models, me }) => { + if (typeof payIn.payInBolt11 !== 'undefined') { + return payIn.payInBolt11 + } + return await models.payInBolt11.findUnique({ where: { payInId: payIn.id } }) + }, + payInCustodialTokens: async (payIn, args, { models, me }) => { + let payInCustodialTokens = payIn.payInCustodialTokens + if (typeof payInCustodialTokens === 'undefined') { + payInCustodialTokens = await models.payInCustodialToken.findMany({ where: { payInId: payIn.id } }) + } + return payInCustodialTokens.map(token => ({ + ...token, + mtokensAfter: isMine(payIn, { me }) ? token.mtokensAfter : null + })) + }, + pessimisticEnv: async (payIn, args, { models, me }) => { + if (!isMine(payIn, { me })) { + return null + } + if (typeof payIn.pessimisticEnv !== 'undefined') { + return payIn.pessimisticEnv + } + return await models.pessimisticEnv.findUnique({ where: { payInId: payIn.id } }) + }, + payOutBolt11: async (payIn, args, { models, me }) => { + if (!me) { + return null + } + let payOutBolt11 = payIn.payOutBolt11 + if (!payOutBolt11) { + payOutBolt11 = await models.payOutBolt11.findUnique({ where: { payInId: payIn.id } }) + if (payOutBolt11 && Number(payOutBolt11.userId) !== Number(me.id)) { + // only return the amount forwarded and the type of payOut + return { msats: payOutBolt11.msats, payOutType: payOutBolt11.payOutType } + } + } + return payOutBolt11 + }, + item: async (payIn, args, { models, me }) => { + if (!payIn.itemPayIn) { + return null + } + return await getItem(payIn, { id: payIn.itemPayIn.itemId }, { models, me }) + }, + sub: async (payIn, args, { models, me }) => { + if (!payIn.subPayIn) { + return null + } + return await getSub(payIn, { name: payIn.subPayIn.subName }, { models, me }) + }, + payOutCustodialTokens: async (payIn, args, { models, me }) => { + if (typeof payIn.payOutCustodialTokens !== 'undefined') { + return [ + ...payIn.payOutCustodialTokens, + ...payIn.beneficiaries.reduce((acc, beneficiary) => { + if (beneficiary.payOutCustodialTokens) { + return [...acc, ...beneficiary.payOutCustodialTokens] + } + return acc + }, []) + ] + } + return await models.payOutCustodialToken.findMany({ where: { payInId: payIn.id } }) + } + }, + PayInBolt11: { + preimage: (payInBolt11, args, { models, me }) => { + // do not reveal the preimage if the invoice is not confirmed + if (!payInBolt11.confirmedAt) { + return null + } + return payInBolt11.preimage + } + }, + PayOutCustodialToken: { + mtokensAfter: (payOutCustodialToken, args, { me }) => { + if (!isMine(payOutCustodialToken, { me })) { + return null + } + return payOutCustodialToken.mtokensAfter + }, + sub: (payOutCustodialToken) => { + return payOutCustodialToken.subPayOutCustodialToken?.sub + } + } +} diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index 1fe4e27882..c80446c161 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -1,7 +1,7 @@ import { amountSchema, validateSchema } from '@/lib/validate' import { getAd, getItem } from './item' -import performPaidAction from '../paidAction' import { GqlInputError } from '@/lib/error' +import pay from '../payIn' let rewardCache @@ -162,10 +162,10 @@ export default { } }, Mutation: { - donateToRewards: async (parent, { sats }, { me, models, lnd }) => { + donateToRewards: async (parent, { sats }, { me, models }) => { await validateSchema(amountSchema, { amount: sats }) - return await performPaidAction('DONATE', { sats }, { me, models, lnd }) + return await pay('DONATE', { sats }, { me, models }) } }, Reward: { diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 0f0e38d655..b24a25dce1 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -3,7 +3,7 @@ import { validateSchema, territorySchema } from '@/lib/validate' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { viewGroup } from './growth' import { notifyTerritoryTransfer } from '@/lib/webPush' -import performPaidAction from '../paidAction' +import pay from '../payIn' import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { uploadIdsFromText } from './upload' import { Prisma } from '@prisma/client' @@ -231,7 +231,7 @@ export default { return sub } - return await performPaidAction('TERRITORY_BILLING', { name }, { me, models, lnd }) + return await pay('TERRITORY_BILLING', { name }, { me, models, lnd }) }, toggleMuteSub: async (parent, { name }, { me, models }) => { if (!me) { @@ -322,7 +322,7 @@ export default { throw new GqlInputError('sub should not be archived') } - return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd }) + return await pay('TERRITORY_UNARCHIVE', data, { me, models, lnd }) } }, Sub: { @@ -362,7 +362,7 @@ export default { async function createSub (parent, data, { me, models, lnd }) { try { - return await performPaidAction('TERRITORY_CREATE', data, { me, models, lnd }) + return await pay('TERRITORY_CREATE', data, { me, models, lnd }) } catch (error) { if (error.code === 'P2002') { throw new GqlInputError('name taken') @@ -389,7 +389,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd }) { } try { - return await performPaidAction('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd }) + return await pay('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd }) } catch (error) { if (error.code === 'P2002') { throw new GqlInputError('name taken') diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 25e6a11365..0972c2634c 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -4,9 +4,9 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { msatsToSats } from '@/lib/format' import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate' import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item' -import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES, WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants' +import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, PAY_IN_NOTIFICATION_TYPES } from '@/lib/constants' import { viewGroup } from './growth' -import { datePivot, timeUnitForRange, whenRange } from '@/lib/time' +import { timeUnitForRange, whenRange } from '@/lib/time' import assertApiKeyNotPermitted from './apiKey' import { hashEmail } from '@/lib/crypto' import { isMuted } from '@/lib/user' @@ -536,26 +536,26 @@ export default { return true } - const invoiceActionFailed = await models.invoice.findFirst({ + const invoiceActionFailed = await models.PayIn.findFirst({ where: { userId: me.id, - updatedAt: { + payInStateChangedAt: { gt: lastChecked }, - actionType: { - in: INVOICE_ACTION_NOTIFICATION_TYPES + payInType: { + in: PAY_IN_NOTIFICATION_TYPES }, - actionState: 'FAILED', - OR: [ - { - paymentAttempt: { - gte: WALLET_MAX_RETRIES - } - }, - { - userCancel: true - } - ] + payInState: 'FAILED' + // OR: [ + // { + // paymentAttempt: { + // gte: WALLET_MAX_RETRIES + // } + // }, + // { + // userCancel: true + // } + // ] } }) @@ -564,30 +564,30 @@ export default { return true } - const invoiceActionFailed2 = await models.invoice.findFirst({ - where: { - userId: me.id, - updatedAt: { - gt: datePivot(lastChecked, { milliseconds: -WALLET_RETRY_BEFORE_MS }) - }, - actionType: { - in: INVOICE_ACTION_NOTIFICATION_TYPES - }, - actionState: 'FAILED', - paymentAttempt: { - lt: WALLET_MAX_RETRIES - }, - userCancel: false, - cancelledAt: { - lte: datePivot(new Date(), { milliseconds: -WALLET_RETRY_BEFORE_MS }) - } - } - }) - - if (invoiceActionFailed2) { - foundNotes() - return true - } + // const invoiceActionFailed2 = await models.invoice.findFirst({ + // where: { + // userId: me.id, + // updatedAt: { + // gt: datePivot(lastChecked, { milliseconds: -WALLET_RETRY_BEFORE_MS }) + // }, + // actionType: { + // in: INVOICE_ACTION_NOTIFICATION_TYPES + // }, + // actionState: 'FAILED', + // paymentAttempt: { + // lt: WALLET_MAX_RETRIES + // }, + // userCancel: false, + // cancelledAt: { + // lte: datePivot(new Date(), { milliseconds: -WALLET_RETRY_BEFORE_MS }) + // } + // } + // }) + + // if (invoiceActionFailed2) { + // foundNotes() + // return true + // } // update checkedNotesAt to prevent rechecking same time period models.user.update({ @@ -981,7 +981,14 @@ export default { const item = await models.item.findFirst({ where: { userId: user.id, - OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }] + itemPayIns: { + some: { + payIn: { + payInState: 'PAID', + payInType: 'ITEM_CREATE' + } + } + } }, orderBy: { createdAt: 'asc' @@ -1002,7 +1009,14 @@ export default { gte, lte }, - OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }] + itemPayIns: { + some: { + payIn: { + payInState: 'PAID', + payInType: 'ITEM_CREATE' + } + } + } } }) }, @@ -1020,7 +1034,14 @@ export default { gte, lte }, - OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }] + itemPayIns: { + some: { + payIn: { + payInState: 'PAID', + payInType: 'ITEM_CREATE' + } + } + } } }) }, @@ -1038,7 +1059,14 @@ export default { gte, lte }, - OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }] + itemPayIns: { + some: { + payIn: { + payInState: 'PAID', + payInType: 'ITEM_CREATE' + } + } + } } }) }, diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b6b5af896a..9af5b1185d 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,5 +1,5 @@ import { - getInvoice as getInvoiceFromLnd, deletePayment, getPayment, + getInvoice as getInvoiceFromLnd, getPayment, parsePaymentRequest } from 'ln-service' import crypto, { timingSafeEqual } from 'crypto' @@ -7,7 +7,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { SELECT, itemQueryWithMeta } from './item' import { msatsToSats, msatsToSatsDecimal } from '@/lib/format' import { - USER_ID, INVOICE_RETENTION_DAYS, + USER_ID, WALLET_RETRY_AFTER_MS, WALLET_RETRY_BEFORE_MS, WALLET_MAX_RETRIES @@ -16,12 +16,11 @@ import { validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' import { bolt11Tags } from '@/lib/bolt11' -import { finalizeHodlInvoice } from '@/worker/wallet' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets } from '../lnd' -import performPaidAction from '../paidAction' -import performPayingAction from '../payingAction' +import pay from '../payIn' +import { dropBolt11 } from '@/worker/autoDropBolt11' export async function getInvoice (parent, { id }, { me, models, lnd }) { const inv = await models.invoice.findUnique({ @@ -321,66 +320,16 @@ const resolvers = { Mutation: { createWithdrawl: createWithdrawal, sendToLnAddr, - cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => { - // stackers can cancel their own invoices without hmac - if (me && !hmac) { - const inv = await models.invoice.findUnique({ where: { hash } }) - if (!inv) throw new GqlInputError('invoice not found') - if (inv.userId !== me.id) throw new GqlInputError('not ur invoice') - } else { - verifyHmac(hash, hmac) - } - await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) - return await models.invoice.update({ where: { hash }, data: { userCancel: !!userCancel } }) - }, dropBolt11: async (parent, { hash }, { me, models, lnd }) => { if (!me) { throw new GqlAuthenticationError() } - const retention = `${INVOICE_RETENTION_DAYS} days` - - const [invoice] = await models.$queryRaw` - WITH to_be_updated AS ( - SELECT id, hash, bolt11 - FROM "Withdrawl" - WHERE "userId" = ${me.id} - AND hash = ${hash} - AND now() > created_at + ${retention}::INTERVAL - AND hash IS NOT NULL - AND status IS NOT NULL - ), updated_rows AS ( - UPDATE "Withdrawl" - SET hash = NULL, bolt11 = NULL, preimage = NULL - FROM to_be_updated - WHERE "Withdrawl".id = to_be_updated.id) - SELECT * FROM to_be_updated;` - - if (invoice) { - try { - await deletePayment({ id: invoice.hash, lnd }) - } catch (error) { - console.error(error) - await models.withdrawl.update({ - where: { id: invoice.id }, - data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage } - }) - throw new GqlInputError('failed to drop bolt11 from lnd') - } - } - - await models.$queryRaw` - UPDATE "DirectPayment" - SET hash = NULL, bolt11 = NULL, preimage = NULL - WHERE "receiverId" = ${me.id} - AND hash = ${hash} - AND now() > created_at + ${retention}::INTERVAL - AND hash IS NOT NULL` - + await dropBolt11({ userId: me.id, hash }, { models, lnd }) return true }, - buyCredits: async (parent, { credits }, { me, models, lnd }) => { - return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd }) + buyCredits: async (parent, { credits }, { me, models }) => { + return await pay('BUY_CREDITS', { credits }, { models, me }) } }, @@ -577,7 +526,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model throw new GqlInputError('SN cannot pay an invoice that SN is proxying') } - return await performPayingAction({ bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models, lnd }) + return await pay('WITHDRAWAL', { bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models }) } async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 29ed7dda1c..da021db719 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -18,6 +18,7 @@ import admin from './admin' import blockHeight from './blockHeight' import chainFee from './chainFee' import paidAction from './paidAction' +import payIn from './payIn' const common = gql` type Query { @@ -35,7 +36,8 @@ const common = gql` scalar JSONObject scalar Date scalar Limit + scalar BigInt ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, payIn] diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a4e0a98853..73983b52a0 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -27,19 +27,6 @@ export default gql` unshorted: String } - type ItemActResult { - id: ID! - sats: Int! - path: String - act: String! - } - - type ItemAct { - id: ID! - act: String! - invoice: Invoice - } - extend type Mutation { bookmarkItem(id: ID): Item pinItem(id: ID): Item @@ -47,30 +34,26 @@ export default gql` deleteItem(id: ID): Item upsertLink( id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], - hash: String, hmac: String): ItemPaidAction! + hash: String, hmac: String): PayIn! upsertDiscussion( id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], - hash: String, hmac: String): ItemPaidAction! + hash: String, hmac: String): PayIn! upsertBounty( id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput], - hash: String, hmac: String): ItemPaidAction! + hash: String, hmac: String): PayIn! upsertJob( id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, - text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction! + text: String!, url: String!, boost: Int, status: String, logo: Int): PayIn! upsertPoll( id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date, - randPollOptions: Boolean, hash: String, hmac: String): ItemPaidAction! + randPollOptions: Boolean, hash: String, hmac: String): PayIn! updateNoteId(id: ID!, noteId: String!): Item! - upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction! - act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction! - pollVote(id: ID!): PollVotePaidAction! + upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): PayIn! + act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): PayIn! + pollVote(id: ID!): PayIn! toggleOutlaw(id: ID!): Item! } - type PollVoteResult { - id: ID! - } - type PollOption { id: ID, option: String! @@ -78,12 +61,10 @@ export default gql` } type Poll { - meVoted: Boolean! - meInvoiceId: Int - meInvoiceActionState: InvoiceActionState count: Int! options: [PollOption!]! randPollOptions: Boolean + meVoted: Boolean! } type Items { @@ -106,11 +87,23 @@ export default gql` FAILED } + type ItemAct { + id: ID! + sats: Int! + act: String! + path: String + payIn: PayIn + } + + type PollVote { + id: ID! + payIn: PayIn + } + type Item { id: ID! createdAt: Date! updatedAt: Date! - invoicePaidAt: Date deletedAt: Date deleteScheduledAt: Date reminderScheduledAt: Date @@ -171,8 +164,8 @@ export default gql` imgproxyUrls: JSONObject rel: String apiKey: Boolean - invoice: Invoice cost: Int! + payIn: PayIn } input ItemForwardInput { diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 8152cb0ce2..23ebbd5b34 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -55,9 +55,10 @@ export default gql` sortTime: Date! } - type Invoicification { + type PayInFailed { id: ID! - invoice: Invoice! + payIn: PayIn! + item: Item! sortTime: Date! } @@ -178,7 +179,7 @@ export default gql` union Notification = Reply | Votification | Mention | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral | FollowActivity | ForwardedVotification | Revenue | SubStatus - | TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification + | TerritoryPost | TerritoryTransfer | Reminder | ItemMention | PayInFailed | ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun type Notifications { diff --git a/api/typeDefs/paidAction.js b/api/typeDefs/paidAction.js index 38a592090d..4997b2ba4e 100644 --- a/api/typeDefs/paidAction.js +++ b/api/typeDefs/paidAction.js @@ -30,31 +30,16 @@ type ItemPaidAction implements PaidAction { } type ItemActPaidAction implements PaidAction { - result: ItemActResult invoice: Invoice paymentMethod: PaymentMethod! } type PollVotePaidAction implements PaidAction { - result: PollVoteResult - invoice: Invoice - paymentMethod: PaymentMethod! -} - -type SubPaidAction implements PaidAction { - result: Sub invoice: Invoice paymentMethod: PaymentMethod! } type DonatePaidAction implements PaidAction { - result: DonateResult - invoice: Invoice - paymentMethod: PaymentMethod! -} - -type BuyCreditsPaidAction implements PaidAction { - result: BuyCreditsResult invoice: Invoice paymentMethod: PaymentMethod! } diff --git a/api/typeDefs/payIn.js b/api/typeDefs/payIn.js new file mode 100644 index 0000000000..61173ba879 --- /dev/null +++ b/api/typeDefs/payIn.js @@ -0,0 +1,203 @@ +import { gql } from 'graphql-tag' + +export default gql` + +extend type Query { + payIn(id: Int!): PayIn + satistics(cursor: String, inc: String): Satistics +} + +extend type Mutation { + retryPayIn(payInId: Int!): PayIn! + cancelPayInBolt11(hash: String!, hmac: String, userCancel: Boolean): PayIn +} + +type Satistics { + payIns: [PayIn!]! + cursor: String +} + +enum CustodialTokenType { + CREDITS + SATS +} + +enum PayInType { + BUY_CREDITS + ITEM_CREATE + ITEM_UPDATE + ZAP + DOWN_ZAP + BOOST + DONATE + POLL_VOTE + INVITE_GIFT + TERRITORY_CREATE + TERRITORY_UPDATE + TERRITORY_BILLING + TERRITORY_UNARCHIVE + PROXY_PAYMENT + REWARDS + WITHDRAWAL + AUTO_WITHDRAWAL +} + +enum PayInState { + PENDING_INVOICE_CREATION + PENDING_INVOICE_WRAP + PENDING_WITHDRAWAL + WITHDRAWAL_PAID + WITHDRAWAL_FAILED + PENDING + PENDING_HELD + HELD + PAID + FAILED + FORWARDING + FORWARDED + FAILED_FORWARD + CANCELLED +} + +enum PayInFailureReason { + INVOICE_CREATION_FAILED + INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE + INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY + INVOICE_WRAPPING_FAILED_UNKNOWN + INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW + INVOICE_FORWARDING_FAILED + HELD_INVOICE_UNEXPECTED_ERROR + HELD_INVOICE_SETTLED_TOO_SLOW + WITHDRAWAL_FAILED + USER_CANCELLED + SYSTEM_CANCELLED + INVOICE_EXPIRED + EXECUTION_FAILED + UNKNOWN_FAILURE +} + +type PayInBolt11Lud18 { + id: Int! + name: String + identifier: String + email: String + pubkey: String +} + +type PayInBolt11NostrNote { + id: Int! + note: JSONObject! +} + +type PayInBolt11Comment { + id: Int! + comment: String! +} + +type PayInBolt11 { + id: Int! + payInId: Int! + hash: String! + preimage: String + hmac: String + bolt11: String! + expiresAt: Date! + confirmedAt: Date + cancelledAt: Date + msatsRequested: BigInt! + msatsReceived: BigInt + createdAt: Date! + updatedAt: Date! + lud18Data: PayInBolt11Lud18 + nostrNote: PayInBolt11NostrNote + comment: PayInBolt11Comment +} + +type PayInCustodialToken { + id: Int! + payInId: Int! + mtokens: BigInt! + mtokensAfter: BigInt + custodialTokenType: CustodialTokenType! +} + +union PayInResult = Item | ItemAct | PollVote | Sub + +type PayInPessimisticEnv { + id: Int! + payInId: Int! + args: JSONObject + error: String + result: JSONObject +} + +type PayIn { + id: Int! + createdAt: Date! + updatedAt: Date! + mcost: BigInt! + userId: Int + payInType: PayInType! + payInState: PayInState! + payInFailureReason: PayInFailureReason + payInStateChangedAt: Date! + payInBolt11: PayInBolt11 + payInCustodialTokens: [PayInCustodialToken!] + result: PayInResult + pessimisticEnv: PayInPessimisticEnv + payOutBolt11: PayOutBolt11 + payOutCustodialTokens: [PayOutCustodialToken!] + item: Item + sub: Sub +} + +enum PayOutType { + TERRITORY_REVENUE + REWARDS_POOL + ROUTING_FEE + ROUTING_FEE_REFUND + PROXY_PAYMENT + ZAP + REWARD + INVITE_GIFT + WITHDRAWAL + SYSTEM_REVENUE + BUY_CREDITS +} + +enum WithdrawlStatus { + INSUFFICIENT_BALANCE + INVALID_PAYMENT + PATHFINDING_TIMEOUT + ROUTE_NOT_FOUND + CONFIRMED + UNKNOWN_FAILURE +} + +type PayOutBolt11 { + id: Int! + createdAt: Date! + updatedAt: Date! + userId: Int + payOutType: PayOutType! + status: WithdrawlStatus! + msats: BigInt! + payInId: Int! + hash: String! + bolt11: String! + expiresAt: Date! +} + +type PayOutCustodialToken { + id: Int! + payInId: Int! + userId: Int + mtokens: BigInt! + mtokensAfter: BigInt + custodialTokenType: CustodialTokenType! + payOutType: PayOutType! + payIn: PayIn! + sub: Sub + user: User +} +` diff --git a/api/typeDefs/rewards.js b/api/typeDefs/rewards.js index 2b0a871991..4323d4c22b 100644 --- a/api/typeDefs/rewards.js +++ b/api/typeDefs/rewards.js @@ -7,7 +7,7 @@ export default gql` } extend type Mutation { - donateToRewards(sats: Int!): DonatePaidAction! + donateToRewards(sats: Int!): PayIn! } type DonateResult { diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 8bf8bd2a14..3661d86ccb 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -22,15 +22,15 @@ export default gql` replyCost: Int!, postTypes: [String!]!, billingType: String!, billingAutoRenew: Boolean!, - moderated: Boolean!, nsfw: Boolean!): SubPaidAction! - paySub(name: String!): SubPaidAction! + moderated: Boolean!, nsfw: Boolean!): PayIn! + paySub(name: String!): PayIn! toggleMuteSub(name: String!): Boolean! toggleSubSubscription(name: String!): Boolean! transferTerritory(subName: String!, userName: String!): Sub unarchiveTerritory(name: String!, desc: String, baseCost: Int!, replyCost: Int!, postTypes: [String!]!, billingType: String!, billingAutoRenew: Boolean!, - moderated: Boolean!, nsfw: Boolean!): SubPaidAction! + moderated: Boolean!, nsfw: Boolean!): PayIn! } type Sub { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index d3d2cea6bc..9a6c636b63 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -20,9 +20,8 @@ const typeDefs = gql` extend type Mutation { 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, userCancel: Boolean): Invoice! dropBolt11(hash: String!): Boolean - buyCredits(credits: Int!): BuyCreditsPaidAction! + buyCredits(credits: Int!): PayIn! # upserts upsertWalletSendLNbits( @@ -156,10 +155,6 @@ const typeDefs = gql` deleteWalletLogs(protocolId: Int, debug: Boolean): Boolean } - type BuyCreditsResult { - credits: Int! - } - interface InvoiceOrDirect { id: ID! } diff --git a/components/accordian-item.js b/components/accordian-item.js index 6ebb58b6fb..8b1e8f7397 100644 --- a/components/accordian-item.js +++ b/components/accordian-item.js @@ -52,9 +52,9 @@ export default function AccordianItem ({ header, body, className, headerColor = ) } -export function AccordianCard ({ header, children, show }) { +export function AccordianCard ({ header, children, show, className }) { return ( - + {header} diff --git a/components/bolt11-info.js b/components/bolt11-info.js index 1dd4dff875..dd03b84d44 100644 --- a/components/bolt11-info.js +++ b/components/bolt11-info.js @@ -48,3 +48,5 @@ export default ({ bolt11, preimage, children }) => { ) } + +// TODO: delete when payIn is finished diff --git a/components/bounty-form.js b/components/bounty-form.js index 5b0876b803..6b97e027a1 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -10,7 +10,7 @@ import { MAX_TITLE_LENGTH } from '@/lib/constants' import { useMe } from './me' import { ItemButtonBar } from './post' import useItemSubmit from './use-item-submit' -import { UPSERT_BOUNTY } from '@/fragments/paidAction' +import { UPSERT_BOUNTY } from '@/fragments/payIn' export function BountyForm ({ item, diff --git a/components/comment-edit.js b/components/comment-edit.js index 97c59c1d59..dfdb0115d9 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -3,7 +3,7 @@ import styles from './reply.module.css' import { commentSchema } from '@/lib/validate' import { FeeButtonProvider } from './fee-button' import { ItemButtonBar } from './post' -import { UPDATE_COMMENT } from '@/fragments/paidAction' +import { UPDATE_COMMENT } from '@/fragments/payIn' import useItemSubmit from './use-item-submit' export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { diff --git a/components/comment.js b/components/comment.js index 6c4c90195b..31572a3918 100644 --- a/components/comment.js +++ b/components/comment.js @@ -80,12 +80,13 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) { ) :
} { e.preventDefault() router.push(href, as) }} href={href} + pad > @@ -97,8 +98,8 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) { export default function Comment ({ item, children, replyOpen, includeParent, topLevel, rootLastCommentAt, - rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry, - navigator + rootText, noComments, noReply, truncate, depth, pin, + navigator, ...props }) { const [edit, setEdit] = useState() const { me } = useMe() @@ -225,8 +226,7 @@ export default function Comment ({ embellishUser={op && <> {op}} onQuoteReply={quoteReply} nested={!includeParent} - setDisableRetry={setDisableRetry} - disableRetry={disableRetry} + {...props} extraInfo={ <> {includeParent && } diff --git a/components/discussion-form.js b/components/discussion-form.js index 1f8c02b9fb..843868a390 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -12,7 +12,7 @@ import { normalizeForwards } from '@/lib/form' import { MAX_TITLE_LENGTH } from '@/lib/constants' import { useMe } from './me' import { ItemButtonBar } from './post' -import { UPSERT_DISCUSSION } from '@/fragments/paidAction' +import { UPSERT_DISCUSSION } from '@/fragments/payIn' import useItemSubmit from './use-item-submit' export function DiscussionForm ({ diff --git a/components/invoice-status.js b/components/invoice-status.js index b8c19d1c45..624e9957d8 100644 --- a/components/invoice-status.js +++ b/components/invoice-status.js @@ -49,3 +49,5 @@ export default function InvoiceStatus ({ variant, status }) { return } } + +// TODO: delete when payIn is finished diff --git a/components/invoice.js b/components/invoice.js index cdc984ed82..474e754e6f 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -221,3 +221,5 @@ function WalletError ({ error }) {
) } + +// TODO: delete when payIn is finished diff --git a/components/item-act.js b/components/item-act.js index 87189b4c52..097c648acb 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -8,12 +8,14 @@ import { amountSchema, boostSchema } from '@/lib/validate' import { useToast } from './toast' import { nextTip, defaultTipIncludingRandom } from './upvote' import { ZAP_UNDO_DELAY_MS } from '@/lib/constants' -import { usePaidMutation } from './use-paid-mutation' -import { ACT_MUTATION } from '@/fragments/paidAction' +import { ACT_MUTATION } from '@/fragments/payIn' import { meAnonSats } from '@/lib/apollo' import { BoostItemInput } from './adv-post-form' import { useHasSendWallet } from '@/wallets/client/hooks' import { useAnimation } from '@/components/animation' +import usePayInMutation from '@/components/payIn/hooks/use-pay-in-mutation' +import { getOperationName } from '@apollo/client/utilities' +import { satsToMsats } from '@/lib/format' const defaultTips = [100, 1000, 10_000, 100_000] @@ -130,12 +132,9 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a }, optimisticResponse: me ? { - act: { - __typename: 'ItemActPaidAction', - result: { - id: item.id, sats: Number(amount), act, path: item.path - } - } + payInType: act === 'DONT_LIKE_THIS' ? 'DOWN_ZAP' : act === 'BOOST' ? 'BOOST' : 'ZAP', + mcost: satsToMsats(Number(amount)), + result: { path: item.path, id: item.id, sats: Number(amount), act, __typename: 'ItemAct' } } : undefined, // don't close modal immediately because we want the QR modal to stack @@ -179,10 +178,10 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a ) } -function modifyActCache (cache, { result, invoice }, me) { +function modifyActCache (cache, { result, payOutBolt11 }, me) { if (!result) return const { id, sats, act } = result - const p2p = invoice?.invoiceForward + const p2p = !!payOutBolt11 cache.modify({ id: `Item:${id}`, @@ -230,10 +229,10 @@ function modifyActCache (cache, { result, invoice }, me) { // doing this onPaid fixes issue #1695 because optimistically updating all ancestors // conflicts with the writeQuery on navigation from SSR -function updateAncestors (cache, { result, invoice }) { +function updateAncestors (cache, { result, payOutBolt11 }) { if (!result) return const { id, sats, act, path } = result - const p2p = invoice?.invoiceForward + const p2p = !!payOutBolt11 if (act === 'TIP') { // update all ancestors @@ -262,25 +261,25 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) { const { me } = useMe() // because the mutation name we use varies, // we need to extract the result/invoice from the response - const getPaidActionResult = data => Object.values(data)[0] + const getPayInResult = data => data[getOperationName(query)] const hasSendWallet = useHasSendWallet() - const [act] = usePaidMutation(query, { - waitFor: inv => + const [act] = usePayInMutation(query, { + waitFor: payIn => // if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure // we don't prematurely consider the payment as successful (important for receiver fallbacks) hasSendWallet - ? inv?.actionState === 'PAID' - : inv?.satsReceived > 0, + ? payIn?.payInState === 'PAID' + : ['FORWARDING', 'PAID'].includes(payIn?.payInState), ...options, update: (cache, { data }) => { - const response = getPaidActionResult(data) + const response = getPayInResult(data) if (!response) return modifyActCache(cache, response, me) options?.update?.(cache, { data }) }, onPayError: (e, cache, { data }) => { - const response = getPaidActionResult(data) + const response = getPayInResult(data) if (!response || !response.result) return const { result: { sats } } = response const negate = { ...response, result: { ...response.result, sats: -1 * sats } } @@ -288,7 +287,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) { options?.onPayError?.(e, cache, { data }) }, onPaid: (cache, { data }) => { - const response = getPaidActionResult(data) + const response = getPayInResult(data) if (!response) return updateAncestors(cache, response) options?.onPaid?.(cache, { data }) @@ -310,7 +309,7 @@ export function useZap () { const sats = nextTip(meSats, { ...me?.privates }) const variables = { id: item.id, sats, act: 'TIP', hasSendWallet } - const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } } + const optimisticResponse = { payInType: 'ZAP', mcost: satsToMsats(sats), result: { path: item.path, ...variables, __typename: 'ItemAct' } } try { await abortSignal.pause({ me, amount: sats }) diff --git a/components/item-full.js b/components/item-full.js index 8e8eb3f0bd..b39773332b 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -182,7 +182,7 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props {item.parentId ? : ( -
{bio +
{bio ? : }
)} diff --git a/components/item-info.js b/components/item-info.js index 63129fcda0..3f939dd80d 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -22,13 +22,13 @@ import { DropdownItemUpVote } from './upvote' import { useRoot } from './root' import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header' import UserPopover from './user-popover' -import useQrPayment from './use-qr-payment' -import { useRetryCreateItem } from './use-item-submit' +import useQrPayIn from './payIn/hooks/use-qr-pay-in' import { useToast } from './toast' import { useShowModal } from './modal' import classNames from 'classnames' import SubPopover from './sub-popover' import useCanEdit from './use-can-edit' +import { useRetryPayIn } from './payIn/hooks/use-retry-pay-in' function itemTitle (item) { let title = '' @@ -67,7 +67,7 @@ export default function ItemInfo ({ item, full, commentsText = 'comments', commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleEdit, editText, onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true, - setDisableRetry, disableRetry + setDisableRetry, disableRetry, updatePayIn }) { const { me } = useMe() const router = useRouter() @@ -138,8 +138,8 @@ export default function ItemInfo ({ {embellishUser} } - - {timeSince(new Date(item.invoicePaidAt || item.createdAt))} + + {timeSince(new Date(item.payIn?.payInStateChangedAt || item.createdAt))} {item.prior && <> @@ -177,7 +177,7 @@ export default function ItemInfo ({ item={item} edit={edit} canEdit={canEdit} setCanEdit={setCanEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold} /> - + {item.payIn && } @@ -252,10 +252,10 @@ function InfoDropdownItem ({ item }) {
{item.id}
created at
{item.createdAt}
- {item.invoicePaidAt && + {item.payIn?.payInState === 'PAID' && <>
paid at
-
{item.invoicePaidAt}
+
{item.payIn?.payInStateChangedAt}
}
cost
{item.cost}
@@ -285,11 +285,11 @@ function InfoDropdownItem ({ item }) { ) } -export function PaymentInfo ({ item, disableRetry, setDisableRetry }) { +export function PayInInfo ({ item, updatePayIn, disableRetry, setDisableRetry }) { const { me } = useMe() const toaster = useToast() - const retryCreateItem = useRetryCreateItem({ id: item.id }) - const waitForQrPayment = useQrPayment() + const retryPayIn = useRetryPayIn(item.payIn.id, { update: updatePayIn }) + const waitForQrPayIn = useQrPayIn() const [disableInfoRetry, setDisableInfoRetry] = useState(disableRetry) if (item.deletedAt) return null @@ -301,14 +301,14 @@ export function PaymentInfo ({ item, disableRetry, setDisableRetry }) { let Component let onClick - if (me && item.invoice?.actionState && item.invoice?.actionState !== 'PAID') { - if (item.invoice?.actionState === 'FAILED') { + if (me && item.payIn?.payInState && item.payIn?.payInState !== 'PAID') { + if (['FAILED', 'CANCELLED'].includes(item.payIn?.payInState)) { Component = () => retry payment onClick = async () => { if (disableDualRetry) return setDisableDualRetry(true) try { - const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } }) + const { error } = await retryPayIn() if (error) throw error } catch (error) { toaster.danger(error.message) @@ -323,7 +323,7 @@ export function PaymentInfo ({ item, disableRetry, setDisableRetry }) { >pending ) - onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error) + onClick = () => waitForQrPayIn(item.payIn, null, { cancelOnClose: false }).catch(console.error) } } else { return null @@ -354,7 +354,7 @@ function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, edit onClick={() => toggleEdit ? toggleEdit() : router.push(`/items/${item.id}/edit`)} > {editText || 'edit'} - {(!item.invoice?.actionState || item.invoice?.actionState === 'PAID') + {(!item.payIn?.payInState || item.payIn?.payInState === 'PAID') ? { setCanEdit(false) }} diff --git a/components/item-job.js b/components/item-job.js index 17d2c72782..7195a23256 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -13,9 +13,9 @@ import { MEDIA_URL } from '@/lib/constants' import { abbrNum } from '@/lib/format' import { Badge } from 'react-bootstrap' import SubPopover from './sub-popover' -import { PaymentInfo } from './item-info' +import { PayInInfo } from './item-info' -export default function ItemJob ({ item, toc, rank, children, disableRetry, setDisableRetry }) { +export default function ItemJob ({ item, toc, rank, children, ...props }) { const isEmail = string().email().isValidSync(item.url) return ( @@ -79,7 +79,7 @@ export default function ItemJob ({ item, toc, rank, children, disableRetry, setD edit - + )}
diff --git a/components/item.js b/components/item.js index c1892ba427..09f5830f94 100644 --- a/components/item.js +++ b/components/item.js @@ -89,7 +89,7 @@ function ItemLink ({ url, rel }) { export default function Item ({ item, rank, belowTitle, right, full, children, itemClassName, - onQuoteReply, pinnable, setDisableRetry, disableRetry, ad + onQuoteReply, pinnable, ad, ...props }) { const titleRef = useRef() const router = useRouter() @@ -150,8 +150,7 @@ export default function Item ({ top boost } - setDisableRetry={setDisableRetry} - disableRetry={disableRetry} + {...props} /> {belowTitle} diff --git a/components/item.module.css b/components/item.module.css index 9c6fc504b8..b07e3b32df 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -123,7 +123,6 @@ a.link:visited { display: flex; justify-content: flex-start; min-width: 0; - padding-top: .5rem; } .item .companyImage { @@ -159,6 +158,10 @@ a.link:visited { grid-template-columns: auto minmax(0, 1fr); } +.grid > * { + padding-top: 0.5rem; +} + .details { display: grid; grid-template-columns: auto auto; diff --git a/components/items.js b/components/items.js index 738ee76440..b5073cf7f0 100644 --- a/components/items.js +++ b/components/items.js @@ -1,7 +1,7 @@ import { useQuery } from '@apollo/client' import Item, { ItemSkeleton } from './item' import ItemJob from './item-job' -import styles from './items.module.css' +import styles from './item.module.css' import MoreFooter from './more-footer' import { Fragment, useCallback, useMemo } from 'react' import { CommentFlat } from './comment' diff --git a/components/items.module.css b/components/items.module.css deleted file mode 100644 index bf34a2e98d..0000000000 --- a/components/items.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.grid { - display: grid; - grid-template-columns: auto minmax(0, 1fr); -} \ No newline at end of file diff --git a/components/job-form.js b/components/job-form.js index f8533586b3..f56eb04970 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -9,7 +9,7 @@ import { useLazyQuery, gql } from '@apollo/client' import Avatar from './avatar' import { jobSchema } from '@/lib/validate' import { BOOST_MIN, BOOST_MULT, MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants' -import { UPSERT_JOB } from '@/fragments/paidAction' +import { UPSERT_JOB } from '@/fragments/payIn' import useItemSubmit from './use-item-submit' import { BoostInput } from './adv-post-form' import { numWithUnits, giveOrdinalSuffix } from '@/lib/format' diff --git a/components/layout.js b/components/layout.js index e90f6ccf39..d4a551258d 100644 --- a/components/layout.js +++ b/components/layout.js @@ -18,7 +18,7 @@ export default function Layout ({ {contain ? ( - + {children} ) diff --git a/components/link-form.js b/components/link-form.js index 2c0be2f90d..7ee4673424 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -14,7 +14,7 @@ import { SubSelectInitial } from './sub-select' import { MAX_TITLE_LENGTH } from '@/lib/constants' import { useMe } from './me' import { ItemButtonBar } from './post' -import { UPSERT_LINK } from '@/fragments/paidAction' +import { UPSERT_LINK } from '@/fragments/payIn' import useItemSubmit from './use-item-submit' import useDebounceCallback from './use-debounce-callback' diff --git a/components/link-to-context.js b/components/link-to-context.js index 8804a28fdb..ab1fcfcb2e 100644 --- a/components/link-to-context.js +++ b/components/link-to-context.js @@ -2,9 +2,9 @@ import classNames from 'classnames' import styles from './link-to-context.module.css' import Link from 'next/link' -export default function LinkToContext ({ children, onClick, href, className, ...props }) { +export default function LinkToContext ({ children, onClick, href, className, pad, ...props }) { return ( -
+
{ + return ( + + ) +} + export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, invisible, noMoreText = 'GENESIS' }) { const [loading, setLoading] = useState(false) @@ -9,31 +29,14 @@ export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, invisi return
} - let Footer - if (cursor) { - Footer = () => ( - - ) - } else { + let Footer = FooterFetchMore + if (!cursor) { Footer = () => (
{count === 0 ? 'EMPTY' : noMoreText}
) } - return
+ return
} export function NavigateFooter ({ cursor, count, fetchMore, href, text, invisible, noMoreText = 'NO MORE' }) { diff --git a/components/notifications.js b/components/notifications.js index 7c12f489ad..a09fc03162 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useCallback } from 'react' import { gql, useQuery } from '@apollo/client' import Comment, { CommentSkeleton } from './comment' import Item from './item' @@ -26,25 +26,20 @@ import { useData } from './use-data' import { nostrZapDetails } from '@/lib/nostr' import Text from './text' import NostrIcon from '@/svgs/nostr.svg' -import { numWithUnits } from '@/lib/format' +import { msatsToSats, numWithUnits } from '@/lib/format' import BountyIcon from '@/svgs/bounty-bag.svg' import { LongCountdown } from './countdown' import { nextBillingWithGrace } from '@/lib/territory' import { commentSubTreeRootId } from '@/lib/item' import LinkToContext from './link-to-context' import { Badge, Button } from 'react-bootstrap' -import { useAct } from './item-act' -import { RETRY_PAID_ACTION } from '@/fragments/paidAction' -import { usePollVote } from './poll' -import { paidActionCacheMods } from './use-paid-mutation' -import { useRetryCreateItem } from './use-item-submit' -import { payBountyCacheMods } from './pay-bounty' import { useToast } from './toast' import classNames from 'classnames' import HolsterIcon from '@/svgs/holster.svg' import SaddleIcon from '@/svgs/saddle.svg' import CCInfo from './info/cc' import { useMe } from './me' +import { useRetryPayIn, useRetryBountyPayIn, useRetryItemActPayIn } from './payIn/hooks/use-retry-pay-in' function Notification ({ n, fresh }) { const type = n.__typename @@ -72,7 +67,7 @@ function Notification ({ n, fresh }) { (type === 'TerritoryPost' && ) || (type === 'TerritoryTransfer' && ) || (type === 'Reminder' && ) || - (type === 'Invoicification' && ) || + (type === 'PayInFailed' && ) || (type === 'ReferralReward' && ) } @@ -84,7 +79,7 @@ function NotificationLayout ({ children, type, nid, href, as, fresh }) { if (!href) return
{children}
return ( { e.preventDefault() nid && await router.replace({ @@ -97,6 +92,7 @@ function NotificationLayout ({ children, type, nid, href, as, fresh }) { router.push(href, as) }} href={href} + pad > {children} @@ -163,7 +159,7 @@ const defaultOnClick = n => { if (type === 'SubStatus') return { href: `/~${n.sub.name}` } if (type === 'Invitification') return { href: '/invites' } if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` } - if (type === 'Invoicification') return itemLink(n.invoice.item) + if (type === 'PayInFailed') return itemLink(n.item) if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` } if (type === 'Referral') return { href: '/referrals/month' } if (type === 'ReferralReward') return { href: '/referrals/month' } @@ -405,114 +401,85 @@ function InvoicePaid ({ n }) { ) } -function useActRetry ({ invoice }) { - const bountyCacheMods = - invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine - ? payBountyCacheMods - : {} - - const update = (cache, { data }) => { - const response = Object.values(data)[0] - if (!response?.invoice) return - cache.modify({ - id: `ItemAct:${invoice.itemAct?.id}`, - fields: { - // this is a bit of a hack just to update the reference to the new invoice - invoice: () => cache.writeFragment({ - id: `Invoice:${response.invoice.id}`, - fragment: gql` - fragment _ on Invoice { - bolt11 - } - `, - data: { bolt11: response.invoice.bolt11 } - }) +function PayInFailed ({ n }) { + const [disableRetry, setDisableRetry] = useState(false) + const toaster = useToast() + const { payIn, item } = n + const updatePayIn = useCallback((cache, { data }) => { + cache.writeFragment({ + id: `PayInFailed:${n.id}`, + fragment: gql` + fragment _ on PayInFailed { + payIn { + id + mcost + payInType + payInState + payInStateChangedAt + } + } + `, + data: { + payIn: data.retryPayIn } }) - paidActionCacheMods?.update?.(cache, { data }) - bountyCacheMods?.update?.(cache, { data }) - } - - return useAct({ - query: RETRY_PAID_ACTION, - onPayError: (e, cache, { data }) => { - paidActionCacheMods?.onPayError?.(e, cache, { data }) - bountyCacheMods?.onPayError?.(e, cache, { data }) - }, - onPaid: (cache, { data }) => { - paidActionCacheMods?.onPaid?.(cache, { data }) - bountyCacheMods?.onPaid?.(cache, { data }) - }, - update, - updateOnFallback: update - }) -} - -function Invoicification ({ n: { invoice, sortTime } }) { - const toaster = useToast() - const actRetry = useActRetry({ invoice }) - const retryCreateItem = useRetryCreateItem({ id: invoice.item?.id }) - const retryPollVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: invoice.item?.id }) - const [disableRetry, setDisableRetry] = useState(false) - // XXX if we navigate to an invoice after it is retried in notifications - // the cache will clear invoice.item and will error on window.back - // alternatively, we could/should - // 1. update the notification cache to include the new invoice - // 2. make item has-many invoices - if (!invoice.item) return null - - let retry - let actionString - let invoiceId - let invoiceActionState - const itemType = invoice.item.title ? 'post' : 'comment' - - if (invoice.actionType === 'ITEM_CREATE') { - actionString = `${itemType} create ` - retry = retryCreateItem; - ({ id: invoiceId, actionState: invoiceActionState } = invoice.item.invoice) - } else if (invoice.actionType === 'POLL_VOTE') { - actionString = 'poll vote ' - retry = retryPollVote - invoiceId = invoice.item.poll?.meInvoiceId - invoiceActionState = invoice.item.poll?.meInvoiceActionState - } else { - if (invoice.actionType === 'ZAP') { - if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine) { + }, [n.id]) + + const retryPayIn = useRetryPayIn(payIn.id, { update: updatePayIn }) + const act = payIn.payInType === 'ZAP' ? 'TIP' : payIn.payInType === 'DOWN_ZAP' ? 'DONT_LIKE_THIS' : 'BOOST' + const optimisticResponse = { payInType: payIn.payInType, mcost: payIn.mcost, result: { id: item.id, sats: msatsToSats(payIn.mcost), path: item.path, act, __typename: 'ItemAct' } } + const retryBountyPayIn = useRetryBountyPayIn(payIn.id, { update: updatePayIn, optimisticResponse }) + const retryItemActPayIn = useRetryItemActPayIn(payIn.id, { update: updatePayIn, optimisticResponse }) + + const [actionString, colorClass, retry] = useMemo(() => { + let retry + let actionString = '' + const itemType = item.title ? 'post' : 'comment' + if (payIn.payInType === 'ITEM_CREATE') { + actionString = `${itemType} create ` + retry = retryPayIn + } else if (payIn.payInType === 'POLL_VOTE') { + actionString = 'poll vote ' + retry = retryPayIn + } else { + if (payIn.payInType === 'ZAP' && item.root?.bounty === msatsToSats(payIn.mcost) && item.root?.mine) { actionString = 'bounty payment' + retry = retryBountyPayIn } else { - actionString = 'zap' + if (payIn.payInType === 'ZAP') { + actionString = 'zap' + } else if (payIn.payInType === 'DOWN_ZAP') { + actionString = 'downzap' + } else if (payIn.payInType === 'BOOST') { + actionString = 'boost' + } + retry = retryItemActPayIn } - } else if (invoice.actionType === 'DOWN_ZAP') { - actionString = 'downzap' - } else if (invoice.actionType === 'BOOST') { - actionString = 'boost' + actionString = `${actionString} on ${itemType} ` } - actionString = `${actionString} on ${itemType} ` - retry = actRetry; - ({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice) - } - - let colorClass = 'info' - switch (invoiceActionState) { - case 'FAILED': - actionString += 'failed' - colorClass = 'warning' - break - case 'PAID': - actionString += 'paid' - colorClass = 'success' - break - default: - actionString += 'pending' - } + let colorClass = 'info' + switch (payIn.payInState) { + case 'FAILED': + case 'CANCELLED': + actionString += 'failed' + colorClass = 'warning' + break + case 'PAID': + actionString += 'paid' + colorClass = 'success' + break + default: + actionString += 'pending' + } + return [actionString, colorClass, retry] + }, [payIn, item, retryPayIn, retryBountyPayIn, retryItemActPayIn]) return (
{actionString} - {numWithUnits(invoice.satsRequested)} - + {numWithUnits(msatsToSats(payIn.mcost))} + - {timeSince(new Date(sortTime))} + {timeSince(new Date(payIn.payInStateChangedAt))} - +
) } diff --git a/components/pay-bounty.js b/components/pay-bounty.js index b4d079e559..111f2fef50 100644 --- a/components/pay-bounty.js +++ b/components/pay-bounty.js @@ -2,7 +2,7 @@ import React from 'react' import styles from './pay-bounty.module.css' import ActionTooltip from './action-tooltip' import { useMe } from './me' -import { numWithUnits } from '@/lib/format' +import { numWithUnits, satsToMsats } from '@/lib/format' import { useShowModal } from './modal' import { useRoot } from './root' import { ActCanceledError, useAct } from './item-act' @@ -12,9 +12,10 @@ import { useHasSendWallet } from '@/wallets/client/hooks' import { Form, SubmitButton } from './form' export const payBountyCacheMods = { - onPaid: (cache, { data }) => { + update: (cache, { data }) => { const response = Object.values(data)[0] if (!response?.result) return + console.log('payBounty: update', response) const { id, path } = response.result const root = path.split('.')[0] cache.modify({ @@ -30,6 +31,7 @@ export const payBountyCacheMods = { onPayError: (e, cache, { data }) => { const response = Object.values(data)[0] if (!response?.result) return + console.log('payBounty: onPayError', response) const { id, path } = response.result const root = path.split('.')[0] cache.modify({ @@ -53,9 +55,10 @@ export default function PayBounty ({ children, item }) { const hasSendWallet = useHasSendWallet() const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet } + console.log('payBounty', item.path) const act = useAct({ variables, - optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } }, + optimisticResponse: { payInType: 'ZAP', mcost: satsToMsats(root.bounty), result: { path: item.path, id: item.id, sats: root.bounty, act: 'TIP', __typename: 'ItemAct' } }, ...payBountyCacheMods }) diff --git a/components/payIn/bolt11-info.js b/components/payIn/bolt11-info.js new file mode 100644 index 0000000000..96027d55f6 --- /dev/null +++ b/components/payIn/bolt11-info.js @@ -0,0 +1,59 @@ +import { CopyInput } from '@/components/form' +import { bolt11Tags } from '@/lib/bolt11' + +export default ({ bolt11, preimage, children }) => { + let description, paymentHash + if (bolt11) { + ({ description, payment_hash: paymentHash } = bolt11Tags(bolt11)) + } + + return ( +
+ {bolt11 && + <> +
bolt11
+ + } + {paymentHash && + <> +
hash
+ + } + {preimage && + <> +
preimage
+ + } + {description && + <> +
description
+ + } + {children} +
+ ) +} diff --git a/components/payIn/context.js b/components/payIn/context.js new file mode 100644 index 0000000000..98ea4fec71 --- /dev/null +++ b/components/payIn/context.js @@ -0,0 +1,37 @@ +import ItemJob from '@/components/item-job' +import Item from '@/components/item' +import { CommentFlat } from '@/components/comment' +import { TerritoryDetails } from '../territory-header' +import { truncateString } from '@/lib/format' + +export function PayInContext ({ payIn }) { + switch (payIn.payInType) { + case 'ITEM_CREATE': + case 'ITEM_UPDATE': + case 'ZAP': + case 'DOWN_ZAP': + case 'BOOST': + case 'POLL_VOTE': + return ( + <> + {!payIn.item.title && } + {payIn.item.isJob && } + {payIn.item.title && } + + ) + case 'TERRITORY_CREATE': + case 'TERRITORY_UPDATE': + case 'TERRITORY_BILLING': + case 'TERRITORY_UNARCHIVE': + return + case 'INVITE_GIFT': + return
TODO: Invite
+ case 'PROXY_PAYMENT': + return
TODO: Proxy Payment
+ case 'WITHDRAWAL': + case 'AUTOWITHDRAWAL': + case 'DONATE': + case 'BUY_CREDITS': + } + return N/A +} diff --git a/components/payIn/error.js b/components/payIn/error.js new file mode 100644 index 0000000000..d0a4bbf249 --- /dev/null +++ b/components/payIn/error.js @@ -0,0 +1,21 @@ +import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors' + +export default function PayInError ({ error }) { + if (!error || error instanceof WalletConfigurationError) return null + + if (!(error instanceof WalletPaymentAggregateError)) { + console.error('unexpected wallet error:', error) + return null + } + + return ( +
+
Paying from attached wallets failed:
+ {error.errors.map((e, i) => ( +
+ {e.wallet}: {e.reason || e.message} +
+ ))} +
+ ) +} diff --git a/components/payIn/hooks/use-auto-retry-pay-ins.js b/components/payIn/hooks/use-auto-retry-pay-ins.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/components/payIn/hooks/use-pay-in-helper.js b/components/payIn/hooks/use-pay-in-helper.js new file mode 100644 index 0000000000..13ecaebce7 --- /dev/null +++ b/components/payIn/hooks/use-pay-in-helper.js @@ -0,0 +1,118 @@ +import { useApolloClient, useMutation } from '@apollo/client' +import { useCallback, useMemo } from 'react' +import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors' +import { GET_PAY_IN_RESULT, CANCEL_PAY_IN_BOLT11, RETRY_PAY_IN } from '@/fragments/payIn' +import { FAST_POLL_INTERVAL } from '@/lib/constants' + +const RECEIVER_FAILURE_REASONS = [ + 'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE', + 'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY', + 'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW', + 'INVOICE_FORWARDING_FAILED' +] + +export default function usePayInHelper () { + const client = useApolloClient() + const [retryPayIn] = useMutation(RETRY_PAY_IN) + const [cancelPayInBolt11] = useMutation(CANCEL_PAY_IN_BOLT11) + + const check = useCallback(async (id, that, { query = GET_PAY_IN_RESULT, fetchPolicy = 'network-only' } = {}) => { + const { data, error } = await client.query({ query, fetchPolicy, variables: { id } }) + if (error) { + throw error + } + + const { payInBolt11, payInFailureReason, pessimisticEnv } = data.payIn + const { cancelledAt, expiresAt } = payInBolt11 + + const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt) + if (expired) { + throw new InvoiceExpiredError(payInBolt11) + } + + if (RECEIVER_FAILURE_REASONS.includes(payInFailureReason)) { + throw new WalletReceiverError(payInBolt11) + } + + const failed = cancelledAt || pessimisticEnv?.error + if (failed) { + throw new InvoiceCanceledError(payInBolt11, pessimisticEnv?.error) + } + + return { payIn: data.payIn, check: that(data.payIn) } + }, [client]) + + const waitCheckController = useCallback((payInId) => { + return waitCheckPayInController(payInId, check) + }, [check]) + + const cancel = useCallback(async (payIn, { userCancel = false } = {}) => { + const { hash, hmac } = payIn.payInBolt11 + console.log('canceling invoice:', hash) + const { data } = await cancelPayInBolt11({ variables: { hash, hmac, userCancel } }) + return data.cancelPayInBolt11 + }, [cancelPayInBolt11]) + + const retry = useCallback(async ({ payIn, newAttempt = false }, { update } = {}) => { + console.log('retrying invoice:', payIn.payInBolt11.hash) + const { data, error } = await retryPayIn({ variables: { payInId: payIn.id, newAttempt }, update }) + if (error) throw error + + const newPayIn = data.retryPayIn + console.log('new payIn:', newPayIn?.payInBolt11?.hash) + + return newPayIn + }, [retryPayIn]) + + return useMemo(() => ({ cancel, retry, check, waitCheckController }), [cancel, retry, check, waitCheckController]) +} + +export class WaitCheckControllerAbortedError extends Error { + constructor (payInId) { + super(`waitCheckPayInController: aborted: ${payInId}`) + this.name = 'WaitCheckControllerAbortedError' + this.payInId = payInId + } +} + +function waitCheckPayInController (payInId, check) { + const controller = new AbortController() + const signal = controller.signal + controller.wait = async (waitFor = payIn => payIn?.payInState === 'PAID', options) => { + console.log('waitCheckPayInController: wait', payInId) + let result + return await new Promise((resolve, reject) => { + const interval = setInterval(async () => { + try { + console.log('waitCheckPayInController: checking', payInId) + result = await check(payInId, waitFor, options) + console.log('waitCheckPayInController: checked', payInId, result) + if (result.check) { + resolve(result.payIn) + clearInterval(interval) + signal.removeEventListener('abort', abort) + } else { + console.info(`payIn #${payInId}: waiting for payment ...`) + } + } catch (err) { + console.log('waitCheckPayInController: error', payInId, err) + reject(err) + clearInterval(interval) + signal.removeEventListener('abort', abort) + } + }, FAST_POLL_INTERVAL) + + const abort = () => { + console.info(`payIn #${payInId}: stopped waiting`) + result?.check ? resolve(result.payIn) : reject(new WaitCheckControllerAbortedError(payInId)) + clearInterval(interval) + signal.removeEventListener('abort', abort) + } + signal.addEventListener('abort', abort) + }) + } + + controller.stop = () => controller.abort() + + return controller +} diff --git a/components/payIn/hooks/use-pay-in-mutation.js b/components/payIn/hooks/use-pay-in-mutation.js new file mode 100644 index 0000000000..6027c0ea44 --- /dev/null +++ b/components/payIn/hooks/use-pay-in-mutation.js @@ -0,0 +1,134 @@ +// if PENDING_HELD and not a zap, then it's pessimistic +// if PENDING_HELD and a zap, it's optimistic unless the zapper is anon + +import { useCallback, useState } from 'react' +import { InvoiceCanceledError } from '@/wallets/client/errors' +import { useApolloClient, useMutation } from '@apollo/client' +import usePayPayIn from '@/components/payIn/hooks/use-pay-pay-in' +import { getOperationName } from '@apollo/client/utilities' +import { useMe } from '@/components/me' + +/* +this is just like useMutation with a few changes: +1. pays an invoice returned by the mutation +2. takes an onPaid and onPayError callback, and additional options for payment behavior + - namely forceWaitForPayment which will always wait for the invoice to be paid + - and persistOnNavigate which will keep the invoice in the cache after navigation +3. onCompleted behaves a little differently, but analogously to useMutation, ie clientside side effects + of completion can still rely on it + a. it's called before the invoice is paid for optimistic updates + b. it's called after the invoice is paid for pessimistic updates +4. we return a payError field in the result object if the invoice fails to pay +*/ +export default function usePayInMutation (mutation, { onCompleted, ...options } = {}) { + if (options) { + options.optimisticResponse = addOptimisticResponseExtras(mutation, options.optimisticResponse) + } + const [mutate, result] = useMutation(mutation, options) + const client = useApolloClient() + const { me } = useMe() + // innerResult is used to store/control the result of the mutation when innerMutate runs + const [innerResult, setInnerResult] = useState(result) + const payPayIn = usePayPayIn() + const mutationName = getOperationName(mutation) + + const innerMutate = useCallback(async ({ onCompleted: innerOnCompleted, ...innerOptions } = {}) => { + if (innerOptions) { + innerOptions.optimisticResponse = addOptimisticResponseExtras(mutation, innerOptions.optimisticResponse) + } + const { data, ...rest } = await mutate({ ...options, ...innerOptions }) + + // use the most inner callbacks/options if they exist + const { + onPaid, onPayError, forceWaitForPayment, persistOnNavigate, + update, waitFor = payIn => payIn?.payInState === 'PAID', updateOnFallback + } = { ...options, ...innerOptions } + // onCompleted needs to run after the payIn is paid for pessimistic updates, so we give it special treatment + const ourOnCompleted = innerOnCompleted || onCompleted + + const payIn = data[mutationName] + + console.log('payInMutation', payIn) + + // if the mutation returns in a pending state, it has an invoice we need to pay + let payError + if (payIn.payInState === 'PENDING' || payIn.payInState === 'PENDING_HELD') { + console.log('payInMutation: pending', payIn.payInState, payIn.payInType) + if (forceWaitForPayment || !me || (payIn.payInState === 'PENDING_HELD' && payIn.payInType !== 'ZAP')) { + console.log('payInMutation: forceWaitForPayment', forceWaitForPayment, me, payIn.payInState, payIn.payInType) + // the action is pessimistic + try { + // wait for the invoice to be paid + const paidPayIn = await payPayIn(payIn, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor, updateOnFallback }) + console.log('payInMutation: paidPayIn', paidPayIn) + // we need to run update functions on mutations now that we have the data + const data = { [mutationName]: paidPayIn } + update?.(client.cache, { data }) + ourOnCompleted?.(data) + onPaid?.(client.cache, { data }) + } catch (e) { + console.error('usePayInMutation: failed to pay for pessimistic mutation', mutationName, e) + onPayError?.(e, client.cache, { data }) + payError = e + } + } else { + console.log('payInMutation: not forceWaitForPayment', forceWaitForPayment, me, payIn.payInState, payIn.payInType) + // onCompleted is called before the invoice is paid for optimistic updates + ourOnCompleted?.(data) + // don't wait to pay the invoice + payPayIn(payIn, { persistOnNavigate, waitFor, updateOnFallback }).then((paidPayIn) => { + // invoice might have been retried during payment + onPaid?.(client.cache, { data: { [mutationName]: paidPayIn } }) + }).catch(e => { + console.error('usePayInMutation: failed to pay for optimistic mutation', mutationName, e) + // onPayError is called after the invoice fails to pay + // useful for updating invoiceActionState to FAILED + onPayError?.(e, client.cache, { data }) + payError = e + }) + } + } else if (payIn.payInState === 'PAID') { + console.log('payInMutation: paid', payIn.payInState, payIn.payInType) + // fee credits/reward sats paid for it + ourOnCompleted?.(data) + onPaid?.(client.cache, { data }) + } else { + console.log('payInMutation: unexpected', payIn.payInState, payIn.payInType) + payError = new Error(`PayIn is in an unexpected state: ${payIn.payInState}`) + } + + const result = { + data, + payError, + ...rest, + error: payError instanceof InvoiceCanceledError && payError.actionError ? payError : undefined + } + setInnerResult(result) + return result + }, [mutate, options, payPayIn, client.cache, setInnerResult, !!me]) + + return [innerMutate, innerResult] +} + +// all paid actions need these fields and they're easy to forget +function addOptimisticResponseExtras (mutation, payInOptimisticResponse) { + if (!payInOptimisticResponse) return payInOptimisticResponse + const mutationName = getOperationName(mutation) + return { + [mutationName]: { + __typename: 'PayIn', + id: 'temp-pay-in-id', + payInBolt11: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + payInState: 'PENDING', + payInStateChangedAt: new Date().toISOString(), + payInType: payInOptimisticResponse.payInType, + payInFailureReason: null, + payInCustodialTokens: null, + pessimisticEnv: null, + mcost: payInOptimisticResponse.mcost, + result: payInOptimisticResponse.result + } + } +} diff --git a/components/payIn/hooks/use-pay-pay-in.js b/components/payIn/hooks/use-pay-pay-in.js new file mode 100644 index 0000000000..85dfa70188 --- /dev/null +++ b/components/payIn/hooks/use-pay-pay-in.js @@ -0,0 +1,48 @@ +import { useWalletPayment } from '@/wallets/client/hooks' +import usePayInHelper from './use-pay-in-helper' +import useQrPayIn from './use-qr-pay-in' +import { useCallback } from 'react' +import { WalletError, InvoiceCanceledError, InvoiceExpiredError, WalletPaymentError } from '@/wallets/client/errors' + +export default function usePayPayIn () { + const walletPayment = useWalletPayment() + const payInHelper = usePayInHelper() + const qrPayIn = useQrPayIn() + return useCallback(async (payIn, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor, updateOnFallback }) => { + let walletError + let walletInvoice = payIn.payInBolt11.bolt11 + const start = Date.now() + + try { + return await walletPayment(walletInvoice, { waitFor, updateOnFallback }) + } catch (err) { + walletError = null + if (err instanceof WalletError) { + walletError = err + // get the last invoice that was attempted but failed and was canceled + if (err.invoice) walletInvoice = err.invoice + } + + const invoiceError = err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError + if (!invoiceError && !walletError) { + // unexpected error, rethrow + throw err + } + + // bail if the payment took too long to prevent showing a QR code on an unrelated page + // (if alwaysShowQROnFailure is not set) or user canceled the invoice or it expired + const tooSlow = Date.now() - start > 1000 + const skipQr = (tooSlow && !alwaysShowQROnFailure) || invoiceError + if (skipQr) { + throw err + } + } + + const paymentAttempted = walletError instanceof WalletPaymentError + if (paymentAttempted) { + walletInvoice = await payInHelper.retry(walletInvoice, { update: updateOnFallback }) + } + console.log('usePayPayIn: qrPayIn', payIn.id, walletError) + return await qrPayIn(payIn, walletError, { persistOnNavigate, waitFor }) + }, [payInHelper, qrPayIn, walletPayment]) +} diff --git a/components/payIn/hooks/use-qr-pay-in.js b/components/payIn/hooks/use-qr-pay-in.js new file mode 100644 index 0000000000..6913f17a19 --- /dev/null +++ b/components/payIn/hooks/use-qr-pay-in.js @@ -0,0 +1,99 @@ +import { useCallback } from 'react' +import { AnonWalletError, InvoiceCanceledError } from '@/wallets/client/errors' +import { useShowModal } from '@/components/modal' +import usePayInHelper from '@/components/payIn/hooks/use-pay-in-helper' +import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln' +import useWatchPayIn from './use-watch-pay-in' +import Qr, { QrSkeleton } from '@/components/qr' +import PayInError from '../error' +import { msatsToSats, numWithUnits } from '@/lib/format' +import { PayInStatus } from '../status' + +export default function useQrPayIn () { + const payInHelper = usePayInHelper() + const showModal = useShowModal() + + const waitForQrPayIn = useCallback(async (payIn, walletError, + { + keepOpen = true, + cancelOnClose = true, + persistOnNavigate = false, + waitFor = payIn => payIn?.payInState === 'PAID' + } = {} + ) => { + // if anon user and webln is available, try to pay with webln + if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) { + weblnSendPayment(payIn.payInBolt11.bolt11).catch(e => { console.error('WebLN payment failed:', e) }) + } + return await new Promise((resolve, reject) => { + console.log('waitForQrPayIn', payIn.id, walletError) + let updatedPayIn + const cancelAndReject = async (onClose) => { + console.log('waitForQrPayIn: cancelAndReject', payIn.id, updatedPayIn, cancelOnClose) + if (!updatedPayIn && cancelOnClose) { + const updatedPayIn = await payInHelper.cancel(payIn, { userCancel: true }) + reject(new InvoiceCanceledError(updatedPayIn?.payInBolt11)) + } + resolve(updatedPayIn) + } + showModal(onClose => + { + console.log('waitForQrPayIn: onPaymentError', err) + if (err instanceof InvoiceCanceledError) { + onClose() + reject(err) + } else { + reject(err) + } + }} + onPaymentSuccess={(payIn) => { + console.log('waitForQrPayIn: onPaymentSuccess', payIn) + updatedPayIn = payIn + // this onClose will resolve the promise before the subsequent line runs + // so we need to set updatedPayIn first + onClose() + resolve(payIn) + }} + />, + { keepOpen, persistOnNavigate, onClose: cancelAndReject }) + }) + }, [payInHelper]) + + return waitForQrPayIn +} + +function QrPayIn ({ + id, onPaymentError, onPaymentSuccess, waitFor, walletError +}) { + const { data, error } = useWatchPayIn({ id, onPaymentError, onPaymentSuccess, waitFor }) + + const payIn = data?.payIn + + if (error) { + return
{error.message}
+ } + + if (!payIn) { + return + } + + const { bolt11 } = payIn.payInBolt11 + + return ( + <> + + 'lightning:' + value.toUpperCase()} + description={numWithUnits(msatsToSats(payIn.payInBolt11.msatsRequested), { abbreviate: false })} + /> +
+ +
+ + ) +} diff --git a/components/payIn/hooks/use-retry-pay-in.js b/components/payIn/hooks/use-retry-pay-in.js new file mode 100644 index 0000000000..e83ca6c71e --- /dev/null +++ b/components/payIn/hooks/use-retry-pay-in.js @@ -0,0 +1,35 @@ +import { RETRY_PAY_IN } from '@/fragments/payIn' +import usePayInMutation from './use-pay-in-mutation' +import { useAct } from '@/components/item-act' +import { payBountyCacheMods } from '@/components/pay-bounty' + +export function useRetryPayIn (payInId, mutationOptions = {}) { + const [retryPayIn] = usePayInMutation(RETRY_PAY_IN, { ...mutationOptions, variables: { payInId } }) + return retryPayIn +} + +export function useRetryItemActPayIn (payInId, mutationOptions = {}) { + const retryPayIn = useAct({ query: RETRY_PAY_IN, ...mutationOptions, variables: { payInId } }) + return retryPayIn +} + +export function useRetryBountyPayIn (payInId, mutationOptions = {}) { + const options = { + ...mutationOptions, + ...payBountyCacheMods, + update: (cache, { data }) => { + payBountyCacheMods.update?.(cache, { data }) + mutationOptions.update?.(cache, { data }) + }, + onPayError: (error, cache, { data }) => { + payBountyCacheMods.onPayError?.(error, cache, { data }) + mutationOptions.onPayError?.(error, cache, { data }) + }, + onPaid: (cache, { data }) => { + payBountyCacheMods.onPaid?.(cache, { data }) + mutationOptions.onPaid?.(cache, { data }) + } + } + const retryPayIn = useAct({ query: RETRY_PAY_IN, ...options, variables: { payInId } }) + return retryPayIn +} diff --git a/components/payIn/hooks/use-watch-pay-in.js b/components/payIn/hooks/use-watch-pay-in.js new file mode 100644 index 0000000000..f18af7d51f --- /dev/null +++ b/components/payIn/hooks/use-watch-pay-in.js @@ -0,0 +1,37 @@ +import { useQuery } from '@apollo/client' +import { GET_PAY_IN_RESULT } from '@/fragments/payIn' +import usePayInHelper, { WaitCheckControllerAbortedError } from './use-pay-in-helper' +import { useEffect } from 'react' + +export default function useWatchPayIn ({ id, query = GET_PAY_IN_RESULT, onPaymentError, onPaymentSuccess, waitFor }) { + const payInHelper = usePayInHelper() + + // we use the controller in a useEffect like this so we can reuse the same logic + // ... this controller is used in loops elsewhere where hooks are not allowed + useEffect(() => { + const controller = payInHelper.waitCheckController(id, waitFor, { query, fetchPolicy: 'cache-and-network' }) + + console.log('useWatchPayIn: useEffect', id) + const check = async () => { + console.log('useWatchPayIn: check', id) + try { + const payIn = await controller.wait() + console.log('useWatchPayIn: check: success', payIn) + onPaymentSuccess?.(payIn) + } catch (error) { + // check for error type so that we don't callback when the controller is stopped + // on unmount + if (!(error instanceof WaitCheckControllerAbortedError)) { + console.log('useWatchPayIn: check: error', error) + onPaymentError?.(error) + } + } + } + check() + + return () => controller.stop() + }, [id, waitFor, payInHelper, onPaymentError, onPaymentSuccess]) + + // this will return the payIn in the cache as the useEffect updates the cache + return useQuery(query, { variables: { id } }) +} diff --git a/components/payIn/index.js b/components/payIn/index.js new file mode 100644 index 0000000000..b051aa7c95 --- /dev/null +++ b/components/payIn/index.js @@ -0,0 +1,73 @@ +import { msatsToSats, numWithUnits } from '@/lib/format' +import Qr, { QrSkeleton } from '../qr' +import Bolt11Info from './bolt11-info' +import useWatchPayIn from './hooks/use-watch-pay-in' +import { PayInStatus } from './status' +import PayInMetadata from './metadata' +import { describePayInType } from '@/lib/pay-in' +import { useMe } from '../me' +import { PayInContext } from './context' +import { GET_PAY_IN_FULL } from '@/fragments/payIn' +import { PayInSankey } from './sankey' + +export default function PayIn ({ id }) { + const { me } = useMe() + const { data, error } = useWatchPayIn({ id, query: GET_PAY_IN_FULL }) + + const payIn = data?.payIn + + if (error) { + return
{error.message}
+ } + + if (!payIn) { + return + } + + return ( +
+
+
+

{describePayInType(payIn, me.id)}

+ +
+
+ {new Date(payIn.createdAt).toLocaleString()} +
+
+ {payIn.payInBolt11 && + ( + <> + {['PENDING', 'PENDING_HELD'].includes(payIn.payInState) + ? ( +
+
+ 'lightning:' + value.toUpperCase()} + description={numWithUnits(msatsToSats(payIn.payInBolt11.msatsRequested), { abbreviate: false })} + /> +
+
) + : ( +
+
lightning invoice
+ +
+ )} + + + )} +
+
context
+ +
+
+
transaction diagram
+
+ +
+
+
+ ) +} diff --git a/components/payIn/metadata.js b/components/payIn/metadata.js new file mode 100644 index 0000000000..99097c6c15 --- /dev/null +++ b/components/payIn/metadata.js @@ -0,0 +1,59 @@ +import AccordianItem from '@/components/accordian-item' + +export function PayInMetadata ({ payInBolt11 }) { + const { nostrNote, lud18Data, comment } = payInBolt11 + + return ( + <> +
+ {nostrNote + ? + + {JSON.stringify(nostrNote, null, 2)} + + + } + /> + : null} +
+ {lud18Data && +
+ } + className='mb-3' + /> +
} + {comment && +
+ {comment}} + className='mb-3' + /> +
} + + ) +} + +export default function PayerData ({ data, className, header = false }) { + const supportedPayerData = ['name', 'pubkey', 'email', 'identifier'] + + if (!data) { + return null + } + return ( +
+ {header && sender information:} + {Object.entries(data) + // Don't display unsupported keys + .filter(([key]) => supportedPayerData.includes(key)) + .map(([key, value]) => { + return
{value} ({key})
+ })} +
+ ) +} diff --git a/components/payIn/result.js b/components/payIn/result.js new file mode 100644 index 0000000000..47eb939bfd --- /dev/null +++ b/components/payIn/result.js @@ -0,0 +1,49 @@ +import { CommentFlat } from '@/components/comment' +import Item from '@/components/item' +import ItemJob from '@/components/item-job' +import classNames from 'classnames' + +export default function PayInResult ({ payIn }) { + if (!payIn.result || !payIn.itemPayIn) return null + + let className = 'text-info' + let actionString = '' + + switch (payIn.payInState) { + case 'FAILED': + case 'RETRYING': + actionString += 'attempted ' + className = 'text-warning' + break + case 'PAID': + actionString += 'successful ' + className = 'text-success' + break + default: + actionString += 'pending ' + } + + switch (payIn.payInType) { + case 'ITEM_CREATE': + actionString += 'item creation' + break + case 'ZAP': + actionString += 'zap on item' + break + case 'DOWN_ZAP': + actionString += 'downzap on item' + break + case 'POLL_VOTE': + actionString += 'poll vote' + break + } + + return ( +
+
{actionString}
+ {(payIn.payInItem?.item?.isJob && ) || + (payIn.payInItem?.item?.title && ) || + } +
+ ) +} diff --git a/components/payIn/sankey.js b/components/payIn/sankey.js new file mode 100644 index 0000000000..d5edcb3156 --- /dev/null +++ b/components/payIn/sankey.js @@ -0,0 +1,205 @@ +import { msatsToSatsDecimal, numWithUnits } from '@/lib/format' +import { ResponsiveSankey, SankeyLabelComponent } from '@nivo/sankey' + +export function PayInSankey ({ payIn }) { + const data = getSankeyData(payIn) + return ( +
+ +
+ ) +} + +function assetFormatted (msats, type) { + if (type === 'CREDITS') { + return numWithUnits(msatsToSatsDecimal(msats), { unitSingular: 'CC', unitPlural: 'CCs', abbreviate: false }) + } + return numWithUnits(msatsToSatsDecimal(msats), { unitSingular: 'sat', unitPlural: 'sats', abbreviate: false }) +} + +function Tooltip ({ node, link }) { + node ??= link + if (!node.asset) { + return null + } + return
{node.asset}
+} + +function getSankeyData (payIn) { + const nodes = [] + const links = [] + + // stacker news is always a node + nodes.push({ + id: '' + }) + + // Create individual nodes for each payInCustodialToken + if (payIn.payInCustodialTokens && payIn.payInCustodialTokens.length > 0) { + payIn.payInCustodialTokens.forEach((token, index) => { + const id = token.custodialTokenType === 'SATS' ? 'sats' : 'CCs' + nodes.push({ + id, + mtokens: token.mtokens, + custodialTokenType: token.custodialTokenType + }) + links.push({ + source: id, + target: '', + mtokens: token.mtokens, + custodialTokenType: token.custodialTokenType + }) + }) + } + + // Create node for payInBolt11 if it exists + if (payIn.payInBolt11) { + nodes.push({ + id: 'lightning', + mtokens: payIn.payInBolt11.msatsRequested, + custodialTokenType: 'SATS' + }) + + let leftOverMsats = payIn.payInBolt11.msatsRequested + + // this is a p2p zap or payment + if (payIn.payOutBolt11) { + leftOverMsats = payIn.payInBolt11.msatsRequested - payIn.payOutBolt11.msats + let id = 'lightning (out)' + if (payIn.payOutBolt11) { + if (payIn.payOutBolt11.user?.name) { + id = `@${payIn.payOutBolt11.user.name}` + } + + nodes.push({ + id, + mtokens: payIn.payOutBolt11.msats, + custodialTokenType: 'SATS' + }) + + links.push({ + source: 'lightning', + target: id, + mtokens: payIn.payOutBolt11.msats, + custodialTokenType: 'SATS' + }) + } + } + + if (leftOverMsats > 0) { + links.push({ + source: 'lightning', + target: '', + mtokens: leftOverMsats, + custodialTokenType: 'SATS' + }) + } + } else if (payIn.payOutBolt11) { + // this is a withdrawal + nodes.push({ + id: 'lightning (out)', + mtokens: payIn.payOutBolt11.msats, + custodialTokenType: 'SATS' + }) + + links.push({ + source: '', + target: 'lightning (out)', + mtokens: payIn.payOutBolt11.msats, + custodialTokenType: 'SATS' + }) + } + + // Create individual nodes for each payOutCustodialToken + if (payIn.payOutCustodialTokens && payIn.payOutCustodialTokens.length > 0) { + payIn.payOutCustodialTokens.forEach((token, index) => { + let id = token.payOutType.toLowerCase().replace('_', ' ') + if (token.payOutType === 'TERRITORY_REVENUE' && token.sub?.name) { + id = `~${token.sub.name}` + } else if (token.payOutType === 'ZAP' && token.user?.name) { + id = `@${token.user.name}` + } else if (token.payOutType === 'ROUTING_FEE') { + id = 'route' + } else if (token.payOutType === 'ROUTING_FEE_REFUND') { + id = 'refund' + } else if (token.payOutType === 'REWARDS_POOL') { + id = 'rewards' + } + + nodes.push({ + id, + mtokens: token.mtokens, + custodialTokenType: token.custodialTokenType + }) + links.push({ + source: '', + target: id, + mtokens: token.mtokens, + custodialTokenType: token.custodialTokenType + }) + }) + } + + return reduceLinksAndNodes({ nodes, links }) +} + +// combine duplicate nodes and links, adding the mtokens together +function reduceLinksAndNodes ({ links, nodes }) { + const reducedLinks = [] + const reducedNodes = [] + + nodes.forEach(node => { + const existingNode = reducedNodes.find(n => n.id === node.id) + if (existingNode) { + existingNode.mtokens += node.mtokens + } else { + reducedNodes.push(node) + } + }) + + reducedNodes.forEach(node => { + if (node.mtokens) { + node.asset = assetFormatted(node.mtokens, node.custodialTokenType) + } + }) + + links.forEach(link => { + const existingLink = reducedLinks.find(l => l.source === link.source && l.target === link.target) + if (existingLink) { + existingLink.mtokens += link.mtokens + } else { + reducedLinks.push(link) + } + }) + + reducedLinks.forEach(link => { + link.value = msatsToSatsDecimal(link.mtokens) + link.asset = assetFormatted(link.mtokens, link.custodialTokenType) + }) + + return { links: reducedLinks, nodes: reducedNodes } +} diff --git a/components/payIn/status.js b/components/payIn/status.js new file mode 100644 index 0000000000..86c5284799 --- /dev/null +++ b/components/payIn/status.js @@ -0,0 +1,23 @@ +import CompactLongCountdown from '@/components/countdown' +import Moon from '@/svgs/moon-fill.svg' +import Check from '@/svgs/check-double-line.svg' +import ThumbDown from '@/svgs/thumb-down-fill.svg' + +const statusIconSize = 16 + +export function PayInStatus ({ payIn }) { + function StatusText ({ color, children }) { + return ( + {children} + ) + } + + return ( +
+ {(payIn.payInState === 'PAID' && <>{payIn.mcost > 0 ? 'paid' : 'free'}) || + ((payIn.payInState === 'FAILED' || payIn.payInState === 'CANCELLED' || payIn.payInState === 'FORWARD_FAILED') && <>failed) || + ((payIn.payInState === 'FORWARDING' || payIn.payInState === 'FORWARDED' || !payIn.payInBolt11) && <>settling) || + ()} +
+ ) +} diff --git a/components/payIn/table/index.js b/components/payIn/table/index.js new file mode 100644 index 0000000000..e590f182b9 --- /dev/null +++ b/components/payIn/table/index.js @@ -0,0 +1,46 @@ +import styles from '../table/index.module.css' +import { useMe } from '../../me' +import classNames from 'classnames' +import { PayInType } from './type' +import { PayInContext } from '../context' +import { PayInMoney } from './money' +import LinkToContext from '@/components/link-to-context' + +export default function PayInTable ({ payIns }) { + return ( +
+
+
type
+
context
+
sats
+
+ {payIns?.map(payIn => ( + + ))} +
+ ) +} + +function PayInRow ({ payIn }) { + const { me } = useMe() + + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/components/payIn/table/index.module.css b/components/payIn/table/index.module.css new file mode 100644 index 0000000000..e8a978ed32 --- /dev/null +++ b/components/payIn/table/index.module.css @@ -0,0 +1,79 @@ +.table { + display: grid; + grid-template-columns: max-content 1fr max-content; +} + +.row { + display: contents; +} + +.row.header { + text-transform: uppercase; + font-weight: 600; + font-size: 0.8rem; + color: var(--theme-mutedTextColor); + text-align: center; +} + +.row.header > *:first-child { + text-align: left; +} + +.row.header > *:last-child { + text-align: right; +} + +.row:hover:not(.header) > * { + background-color: var(--theme-clickToContextColor); +} + +.row > * { + padding: 10px 10px; +} + +.row.failed > * { + opacity: 0.75; + background-color:rgba(255, 0, 0, 0.05); +} + +.row.failed:hover > * { + background-color: rgba(255, 0, 0, 0.1); +} + +.row.stacking:not(.failed) > * { + background-color:rgba(0, 255, 0, 0.05); +} + +.row.stacking:not(.failed):hover > * { + background-color:rgba(0, 255, 0, 0.1); +} + +.type { + text-align: left; + display: grid; + grid-template-rows: 1fr 1fr 1fr; + align-items: center; + gap: 0px; +} + +.context { + display: flex; + align-items: center; +} + +.money { + display: grid; + align-items: center; + text-align: right; + line-height: 1.1; +} + +.money > * { + padding: .2rem 0; +} + +.sats { + text-align: right; + display: grid; + align-items: center; +} \ No newline at end of file diff --git a/components/payIn/table/money.js b/components/payIn/table/money.js new file mode 100644 index 0000000000..6443ce8438 --- /dev/null +++ b/components/payIn/table/money.js @@ -0,0 +1,84 @@ +import { useMemo } from 'react' +import { useMe } from '../../me' +import { isNumber, numWithUnits, msatsToSats } from '@/lib/format' +import Plug from '@/svgs/plug.svg' + +export function PayInMoney ({ payIn }) { + const { me } = useMe() + const { SATS, CREDITS } = useMemo(() => reduceCustodialTokenCosts(payIn, me.id), [payIn, me.id]) + const bolt11Cost = useMemo(() => reduceBolt11Cost(payIn, me.id), [payIn, me.id]) + + if (payIn.mcost === 0 || payIn.payInState === 'FAILED' || (Number(payIn.userId) !== Number(me.id) && payIn.payInState !== 'PAID')) { + return <>N/A + } + + return ( + <> + {isNumber(SATS?.mtokens) && SATS.mtokens !== 0 && } + {isNumber(CREDITS?.mtokens) && CREDITS.mtokens !== 0 && } + {isNumber(bolt11Cost) && bolt11Cost !== 0 &&
{formatCost(bolt11Cost, 'sat', 'sats')}
} + + ) +} + +function Money ({ mtokens, mtokensAfter, singular, plural }) { + return ( +
+
{formatCost(mtokens, singular, plural)}
+ {isNumber(mtokensAfter) && {numWithUnits(msatsToSats(mtokensAfter), { unitSingular: singular, unitPlural: plural, abbreviate: false })}} +
+ ) +} + +function formatCost (mtokens, unitSingular, unitPlural) { + let sign = '' + if (mtokens < 0) { + mtokens = -mtokens + } else { + sign = '+' + } + + return `${sign}${numWithUnits(msatsToSats(mtokens), { unitSingular, unitPlural, abbreviate: false })}` +} + +function reduceBolt11Cost (payIn, userId) { + console.log('reduceBolt11Cost', payIn, userId) + let cost = 0 + if (Number(payIn.userId) === Number(userId) && payIn.payInBolt11) { + cost -= payIn.payInBolt11.msatsReceived + } + if (Number(payIn.payOutBolt11?.userId) === Number(userId)) { + cost += payIn.payOutBolt11.msats + } + return cost +} + +function reduceCustodialTokenCosts (payIn, userId) { + // on a payin, the mtokensAfter is going to be the maximum + const payInCosts = payIn.payInCustodialTokens?.reduce((acc, token) => { + if (Number(payIn.userId) !== Number(userId)) { + return acc + } + + acc[token.custodialTokenType] = { + mtokens: acc[token.custodialTokenType]?.mtokens - token.mtokens, + mtokensAfter: acc[token.custodialTokenType]?.mtokensAfter ? Math.min(acc[token.custodialTokenType].mtokensAfter, token.mtokensAfter) : token.mtokensAfter + } + return acc + }, { SATS: { mtokens: 0, mtokensAfter: null }, CREDITS: { mtokens: 0, mtokensAfter: null } }) + + // on a payout, the mtokensAfter is going to be the maximum + const totalCost = payIn.payOutCustodialTokens?.reduce((acc, token) => { + if (Number(token.userId) !== Number(userId)) { + return acc + } + + acc[token.custodialTokenType] = { + mtokens: acc[token.custodialTokenType]?.mtokens + token.mtokens, + mtokensAfter: acc[token.custodialTokenType]?.mtokensAfter ? Math.max(acc[token.custodialTokenType].mtokensAfter, token.mtokensAfter) : token.mtokensAfter + } + return acc + }, { ...payInCosts }) + + return totalCost +} diff --git a/components/payIn/table/type.js b/components/payIn/table/type.js new file mode 100644 index 0000000000..0ad4357730 --- /dev/null +++ b/components/payIn/table/type.js @@ -0,0 +1,19 @@ +import { timeSince } from '@/lib/time' +import { useMe } from '@/components/me' +import { describePayInType } from '@/lib/pay-in' +import { PayInStatus } from '../status' + +export function PayInType ({ payIn }) { + return ( + <> + {timeSince(new Date(payIn.payInStateChangedAt))} + + + + ) +} + +function PayInTypeShortDescription ({ payIn }) { + const { me } = useMe() + return {describePayInType(payIn, me.id)} +} diff --git a/components/payer-data.js b/components/payer-data.js index 64c8f77bbb..9fd0f44473 100644 --- a/components/payer-data.js +++ b/components/payer-data.js @@ -16,3 +16,5 @@ export default function PayerData ({ data, className, header = false }) {
) } + +// TODO: delete when payIn is finished diff --git a/components/poll-form.js b/components/poll-form.js index cab8a45d55..0899aea960 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -9,7 +9,7 @@ import { SubSelectInitial } from './sub-select' import { normalizeForwards } from '@/lib/form' import { useMe } from './me' import { ItemButtonBar } from './post' -import { UPSERT_POLL } from '@/fragments/paidAction' +import { UPSERT_POLL } from '@/fragments/payIn' import useItemSubmit from './use-item-submit' export function PollForm ({ item, sub, editThreshold, children }) { diff --git a/components/poll.js b/components/poll.js index d02283335e..13566a178c 100644 --- a/components/poll.js +++ b/components/poll.js @@ -5,79 +5,60 @@ import { useMe } from './me' import styles from './poll.module.css' import { signIn } from 'next-auth/react' import ActionTooltip from './action-tooltip' -import useQrPayment from './use-qr-payment' import { useToast } from './toast' -import { usePaidMutation } from './use-paid-mutation' -import { POLL_VOTE, RETRY_PAID_ACTION } from '@/fragments/paidAction' +import usePayInMutation from '@/components/payIn/hooks/use-pay-in-mutation' +import { POLL_VOTE } from '@/fragments/payIn' +import { useState } from 'react' +import classNames from 'classnames' -export default function Poll ({ item }) { - const { me } = useMe() +const PollButton = ({ v, item }) => { const pollVote = usePollVote({ query: POLL_VOTE, itemId: item.id }) + const [isSubmitting, setIsSubmitting] = useState(false) const toaster = useToast() + const { me } = useMe() - const PollButton = ({ v }) => { - return ( - - - - ) - } - - const RetryVote = () => { - const retryVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: item.id }) - const waitForQrPayment = useQrPayment() - - if (item.poll.meInvoiceActionState === 'PENDING') { - return ( - waitForQrPayment( - { id: parseInt(item.poll.meInvoiceId) }, null, { cancelOnClose: false }).catch(console.error)} - >vote pending - - ) - } - return ( - retryVote({ variables: { invoiceId: parseInt(item.poll.meInvoiceId) } })} + } + : signIn} > - retry vote - - ) - } + {v.option} + + + ) +} + +export default function Poll ({ item }) { + const { me } = useMe() const hasExpiration = !!item.pollExpiresAt const timeRemaining = timeLeft(new Date(item.pollExpiresAt)) const mine = item.user.id === me?.id - const meVotePending = item.poll.meInvoiceActionState && item.poll.meInvoiceActionState !== 'PAID' - const showPollButton = me && (!hasExpiration || timeRemaining) && !item.poll.meVoted && !meVotePending && !mine + const showPollButton = me && (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine const pollCount = item.poll.count return (
{item.poll.options.map(v => showPollButton - ? + ? : {numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })} {hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`} - {!showPollButton && meVotePending && }
) @@ -107,8 +87,8 @@ export function usePollVote ({ query = POLL_VOTE, itemId }) { const update = (cache, { data }) => { // the mutation name varies for optimistic retries const response = Object.values(data)[0] - if (!response) return - const { result, invoice } = response + if (!response?.result) return + const { result } = response const { id } = result cache.modify({ id: `Item:${itemId}`, @@ -116,15 +96,10 @@ export function usePollVote ({ query = POLL_VOTE, itemId }) { poll (existingPoll) { const poll = { ...existingPoll } poll.meVoted = true - if (invoice) { - poll.meInvoiceActionState = 'PENDING' - poll.meInvoiceId = invoice.id - } poll.count += 1 return poll } - }, - optimistic: true + } }) cache.modify({ id: `PollOption:${id}`, @@ -132,63 +107,10 @@ export function usePollVote ({ query = POLL_VOTE, itemId }) { count (existingCount) { return existingCount + 1 } - }, - optimistic: true - }) - } - - const onPayError = (e, cache, { data }) => { - // the mutation name varies for optimistic retries - const response = Object.values(data)[0] - if (!response) return - const { result, invoice } = response - const { id } = result - cache.modify({ - id: `Item:${itemId}`, - fields: { - poll (existingPoll) { - const poll = { ...existingPoll } - poll.meVoted = false - if (invoice) { - poll.meInvoiceActionState = 'FAILED' - poll.meInvoiceId = invoice?.id - } - poll.count -= 1 - return poll - } - }, - optimistic: true - }) - cache.modify({ - id: `PollOption:${id}`, - fields: { - count (existingCount) { - return existingCount - 1 - } - }, - optimistic: true - }) - } - - const onPaid = (cache, { data }) => { - // the mutation name varies for optimistic retries - const response = Object.values(data)[0] - if (!response?.invoice) return - const { invoice } = response - cache.modify({ - id: `Item:${itemId}`, - fields: { - poll (existingPoll) { - const poll = { ...existingPoll } - poll.meVoted = true - poll.meInvoiceActionState = 'PAID' - poll.meInvoiceId = invoice.id - return poll - } } }) } - const [pollVote] = usePaidMutation(query, { update, onPayError, onPaid }) + const [pollVote] = usePayInMutation(query, { update }) return pollVote } diff --git a/components/qr.js b/components/qr.js index 2ff2957303..f01a08e243 100644 --- a/components/qr.js +++ b/components/qr.js @@ -1,7 +1,5 @@ import { QRCodeSVG } from 'qrcode.react' -import { CopyInput, InputSkeleton } from './form' -import InvoiceStatus from './invoice-status' -import Bolt11Info from './bolt11-info' +import { CopyInput, InputSkeleton } from '@/components/form' export const qrImageSettings = { src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E', @@ -12,8 +10,8 @@ export const qrImageSettings = { excavate: true } -export default function Qr ({ asIs, value, statusVariant, description, status }) { - const qrValue = asIs ? value : 'lightning:' + value.toUpperCase() +export default function Qr ({ value, qrTransform = (value) => value, description, copy = true }) { + const qrValue = qrTransform(value) return ( <> @@ -23,24 +21,23 @@ export default function Qr ({ asIs, value, statusVariant, description, status }) /> {description &&
{description}
} -
- -
- + {copy && +
+ +
} ) } -export function QrSkeleton ({ status, description, bolt11Info }) { +export function QrSkeleton ({ description, copy = true }) { return ( <>
{description &&
i'm invisible
} -
- -
- - {bolt11Info && } + {copy && +
+ +
} ) } diff --git a/components/reply.js b/components/reply.js index 1d3ab77c0c..c5c1975b8d 100644 --- a/components/reply.js +++ b/components/reply.js @@ -10,7 +10,7 @@ import { ItemButtonBar } from './post' import { useShowModal } from './modal' import { Button } from 'react-bootstrap' import { useRoot } from './root' -import { CREATE_COMMENT } from '@/fragments/paidAction' +import { CREATE_COMMENT } from '@/fragments/payIn' import useItemSubmit from './use-item-submit' import gql from 'graphql-tag' import { updateAncestorsCommentCount } from '@/lib/comments' @@ -48,18 +48,21 @@ export default forwardRef(function Reply ({ const onSubmit = useItemSubmit(CREATE_COMMENT, { extraValues: { parentId }, paidMutationOptions: { - update (cache, { data: { upsertComment: { result, invoice } } }) { + update (cache, { data: { upsertComment: { result } } }) { if (!result) return cache.modify({ id: `Item:${parentId}`, fields: { comments (existingComments = {}) { + // to insert a new comment, we need to write a fragment that matches the + // comments query which expects a comments field (which won't be returned on new comment creation) const newCommentRef = cache.writeFragment({ - data: result, + data: { comments: { comments: [] }, ...result }, fragment: COMMENTS, fragmentName: 'CommentsRecursive' }) + return { cursor: existingComments.cursor, comments: [newCommentRef, ...(existingComments?.comments || [])] diff --git a/components/territory-form.js b/components/territory-form.js index 0983002c85..7d438a21b9 100644 --- a/components/territory-form.js +++ b/components/territory-form.js @@ -12,15 +12,15 @@ import Info from './info' import { abbrNum } from '@/lib/format' import { purchasedType } from '@/lib/territory' import { SUB } from '@/fragments/subs' -import { usePaidMutation } from './use-paid-mutation' -import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction' +import usePayInMutation from '@/components/payIn/hooks/use-pay-in-mutation' +import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/payIn' export default function TerritoryForm ({ sub }) { const router = useRouter() const client = useApolloClient() const { me } = useMe() - const [upsertSub] = usePaidMutation(UPSERT_SUB) - const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY) + const [upsertSub] = usePayInMutation(UPSERT_SUB) + const [unarchiveTerritory] = usePayInMutation(UNARCHIVE_TERRITORY) const schema = territorySchema({ client, me, sub }) diff --git a/components/territory-header.js b/components/territory-header.js index 72029e5c18..9ffa8c5f2a 100644 --- a/components/territory-header.js +++ b/components/territory-header.js @@ -24,9 +24,11 @@ export const SubscribeTerritoryContextProvider = ({ children, value }) => ( export const useSubscribeTerritoryContext = () => useContext(SubscribeTerritoryContext) -export function TerritoryDetails ({ sub, children }) { +export function TerritoryDetails ({ sub, children, className, show }) { return ( {sub.name} diff --git a/components/territory-payment-due.js b/components/territory-payment-due.js index c612a32fb3..7aa4d6a3a3 100644 --- a/components/territory-payment-due.js +++ b/components/territory-payment-due.js @@ -8,13 +8,13 @@ import { LongCountdown } from './countdown' import { useCallback } from 'react' import { useApolloClient } from '@apollo/client' import { nextBillingWithGrace } from '@/lib/territory' -import { usePaidMutation } from './use-paid-mutation' -import { SUB_PAY } from '@/fragments/paidAction' +import usePayInMutation from '@/components/payIn/hooks/use-pay-in-mutation' +import { SUB_PAY } from '@/fragments/payIn' export default function TerritoryPaymentDue ({ sub }) { const { me } = useMe() const client = useApolloClient() - const [paySub] = usePaidMutation(SUB_PAY) + const [paySub] = usePayInMutation(SUB_PAY) const onSubmit = useCallback(async ({ ...variables }) => { const { error } = await paySub({ diff --git a/components/use-can-edit.js b/components/use-can-edit.js index b97596e4e1..7031668401 100644 --- a/components/use-can-edit.js +++ b/components/use-can-edit.js @@ -4,7 +4,7 @@ import { useMe } from '@/components/me' import { ITEM_EDIT_SECONDS, USER_ID } from '@/lib/constants' export default function useCanEdit (item) { - const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { seconds: ITEM_EDIT_SECONDS }) + const editThreshold = datePivot(new Date(item.payIn?.payInStateChangedAt ?? item.createdAt), { seconds: ITEM_EDIT_SECONDS }) const { me } = useMe() // deleted items can never be edited and every item has a 10 minute edit window diff --git a/components/use-invoice.js b/components/use-invoice.js deleted file mode 100644 index a12d4d1f91..0000000000 --- a/components/use-invoice.js +++ /dev/null @@ -1,57 +0,0 @@ -import { useApolloClient, useMutation } from '@apollo/client' -import { useCallback, useMemo } from 'react' -import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors' -import { RETRY_PAID_ACTION } from '@/fragments/paidAction' -import { INVOICE, CANCEL_INVOICE } from '@/fragments/invoice' - -export default function useInvoice () { - const client = useApolloClient() - const [retryPaidAction] = useMutation(RETRY_PAID_ACTION) - - const [cancelInvoice] = useMutation(CANCEL_INVOICE) - - const isInvoice = useCallback(async ({ id }, that) => { - const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } }) - if (error) { - throw error - } - - const { cancelled, cancelledAt, actionError, expiresAt, forwardStatus } = data.invoice - - const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt) - if (expired) { - throw new InvoiceExpiredError(data.invoice) - } - - const failedForward = forwardStatus && forwardStatus !== 'CONFIRMED' - if (failedForward) { - throw new WalletReceiverError(data.invoice) - } - - const failed = cancelled || actionError - if (failed) { - throw new InvoiceCanceledError(data.invoice, actionError) - } - - return { invoice: data.invoice, check: that(data.invoice) } - }, [client]) - - const cancel = useCallback(async ({ hash, hmac }, { userCancel = false } = {}) => { - console.log('canceling invoice:', hash) - const { data } = await cancelInvoice({ variables: { hash, hmac, userCancel } }) - return data.cancelInvoice - }, [cancelInvoice]) - - const retry = useCallback(async ({ id, hash, hmac, newAttempt = false }, { update } = {}) => { - console.log('retrying invoice:', hash) - const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id), newAttempt }, update }) - if (error) throw error - - const newInvoice = data.retryPaidAction.invoice - console.log('new invoice:', newInvoice?.hash) - - return newInvoice - }, [retryPaidAction]) - - return useMemo(() => ({ cancel, retry, isInvoice }), [cancel, retry, isInvoice]) -} diff --git a/components/use-item-submit.js b/components/use-item-submit.js index b6ce388237..df6e38e264 100644 --- a/components/use-item-submit.js +++ b/components/use-item-submit.js @@ -1,11 +1,9 @@ import { useRouter } from 'next/router' import { useToast } from './toast' -import { usePaidMutation, paidActionCacheMods } from './use-paid-mutation' +import usePayInMutation from '@/components/payIn/hooks/use-pay-in-mutation' import useCrossposter from './use-crossposter' import { useCallback } from 'react' import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form' -import { RETRY_PAID_ACTION } from '@/fragments/paidAction' -import gql from 'graphql-tag' import { USER_ID } from '@/lib/constants' import { useMe } from './me' import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/client/hooks' @@ -21,7 +19,7 @@ export default function useItemSubmit (mutation, const router = useRouter() const toaster = useToast() const crossposter = useCrossposter() - const [upsertItem] = usePaidMutation(mutation) + const [upsertItem] = usePayInMutation(mutation) const { me } = useMe() const walletPrompt = useWalletRecvPrompt() @@ -66,11 +64,9 @@ export default function useItemSubmit (mutation, persistOnNavigate: navigateOnSubmit, ...paidMutationOptions, onPayError: (e, cache, { data }) => { - paidActionCacheMods.onPayError(e, cache, { data }) paidMutationOptions?.onPayError?.(e, cache, { data }) }, onPaid: (cache, { data }) => { - paidActionCacheMods.onPaid(cache, { data }) paidMutationOptions?.onPaid?.(cache, { data }) }, onCompleted: (data) => { @@ -106,45 +102,14 @@ export default function useItemSubmit (mutation, ) } -export function useRetryCreateItem ({ id }) { - const [retryPaidAction] = usePaidMutation( - RETRY_PAID_ACTION, - { - ...paidActionCacheMods, - update: (cache, { data }) => { - const response = Object.values(data)[0] - if (!response?.invoice) return - cache.modify({ - id: `Item:${id}`, - fields: { - // this is a bit of a hack just to update the reference to the new invoice - invoice: () => cache.writeFragment({ - id: `Invoice:${response.invoice.id}`, - fragment: gql` - fragment _ on Invoice { - bolt11 - } - `, - data: { bolt11: response.invoice.bolt11 } - }) - }, - optimistic: true - }) - paidActionCacheMods?.update?.(cache, { data }) - } - } - ) - - return retryPaidAction -} - function saveItemInvoiceHmac (mutationData) { + console.log('saveItemInvoiceHmac', mutationData) const response = Object.values(mutationData)[0] - if (!response?.invoice) return + if (!response?.payInBolt11) return const id = response.result.id - const { hash, hmac } = response.invoice + const { hash, hmac } = response.payInBolt11 if (id && hash && hmac) { window.localStorage.setItem(`item:${id}:hash:hmac`, `${hash}:${hmac}`) diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index 110ac17bf3..4653713c6b 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -1,7 +1,7 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { useCallback, useState } from 'react' -import useQrPayment from '@/components/use-qr-payment' -import useInvoice from '@/components/use-invoice' +import useQrPayment from '@/components/payIn/hooks/use-qr-pay-in' +import useInvoice from '@/components/payIn/hooks/use-pay-in-helper' import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/client/errors' import { GET_PAID_ACTION } from '@/fragments/paidAction' import { useWalletPayment } from '@/wallets/client/hooks' @@ -212,3 +212,5 @@ export const paidActionCacheMods = { }) } } + +// TODO: delete when payIn is finished diff --git a/components/use-qr-payment.js b/components/use-qr-payment.js deleted file mode 100644 index bfb291899c..0000000000 --- a/components/use-qr-payment.js +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback } from 'react' -import Invoice from '@/components/invoice' -import { AnonWalletError, InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/client/errors' -import { useShowModal } from '@/components/modal' -import useInvoice from '@/components/use-invoice' -import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln' - -export default function useQrPayment () { - const invoice = useInvoice() - const showModal = useShowModal() - - const waitForQrPayment = useCallback(async (inv, walletError, - { - keepOpen = true, - cancelOnClose = true, - persistOnNavigate = false, - waitFor = inv => inv?.satsReceived > 0 - } = {} - ) => { - // if anon user and webln is available, try to pay with webln - if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) { - weblnSendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) }) - } - return await new Promise((resolve, reject) => { - let paid - const cancelAndReject = async (onClose) => { - if (!paid && cancelOnClose) { - const updatedInv = await invoice.cancel(inv, { userCancel: true }) - reject(new InvoiceCanceledError(updatedInv)) - } - resolve(inv) - } - showModal(onClose => - reject(new InvoiceExpiredError(inv))} - onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv, inv?.actionError)) }} - onPayment={(inv) => { paid = true; onClose(); resolve(inv) }} - poll - />, - { keepOpen, persistOnNavigate, onClose: cancelAndReject }) - }) - }, [invoice]) - - return waitForQrPayment -} diff --git a/fragments/comments.js b/fragments/comments.js index c55283ea4a..f7f8fb8502 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -18,7 +18,6 @@ export const COMMENT_FIELDS = gql` position parentId createdAt - invoicePaidAt deletedAt text user { @@ -27,6 +26,11 @@ export const COMMENT_FIELDS = gql` meMute ...StreakFields } + payIn { + id + payInState + payInStateChangedAt + } sats credits meAnonSats @client @@ -51,11 +55,6 @@ export const COMMENT_FIELDS = gql` imgproxyUrls rel apiKey - invoice { - id - actionState - confirmedAt - } cost } ` @@ -67,7 +66,6 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql` position parentId createdAt - invoicePaidAt deletedAt text user { @@ -97,11 +95,6 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql` imgproxyUrls rel apiKey - invoice { - id - actionState - confirmedAt - } cost } ` diff --git a/fragments/items.js b/fragments/items.js index 151587a202..db6c5cc878 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -18,7 +18,6 @@ export const ITEM_FIELDS = gql` id parentId createdAt - invoicePaidAt deletedAt title url @@ -37,6 +36,11 @@ export const ITEM_FIELDS = gql` nsfw replyCost } + payIn { + id + payInState + payInStateChangedAt + } otsHash position sats @@ -75,11 +79,6 @@ export const ITEM_FIELDS = gql` imgproxyUrls rel apiKey - invoice { - id - actionState - confirmedAt - } cost }` @@ -144,8 +143,6 @@ export const POLL_FIELDS = gql` fragment PollFields on Item { poll { meVoted - meInvoiceId - meInvoiceActionState count options { id diff --git a/fragments/notifications.js b/fragments/notifications.js index 3460b00d9f..e975278d7e 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -2,36 +2,27 @@ import { gql } from '@apollo/client' import { ITEM_FULL_FIELDS, POLL_FIELDS } from './items' import { INVITE_FIELDS } from './invites' import { SUB_FIELDS } from './subs' -import { INVOICE_FIELDS } from './invoice' - +import { PAY_IN_LINK_FIELDS } from './payIn' export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }` -export const INVOICIFICATION = gql` +export const PAY_IN_FAILED = gql` ${ITEM_FULL_FIELDS} ${POLL_FIELDS} - ${INVOICE_FIELDS} - fragment InvoicificationFields on Invoicification { + ${PAY_IN_LINK_FIELDS} + fragment PayInFailedFields on PayInFailed { id sortTime - invoice { - ...InvoiceFields - item { - ...ItemFullFields - ...PollFields - } - itemAct { - id - act - invoice { - id - actionState - } - } + item { + ...ItemFullFields + ...PollFields + } + payIn { + ...PayInLinkFields } }` export const NOTIFICATIONS = gql` - ${INVOICIFICATION} + ${PAY_IN_FAILED} ${INVITE_FIELDS} ${SUB_FIELDS} @@ -205,8 +196,8 @@ export const NOTIFICATIONS = gql` forwardedSats } } - ... on Invoicification { - ...InvoicificationFields + ... on PayInFailed { + ...PayInFailedFields } ... on WithdrawlPaid { id diff --git a/fragments/paidAction.js b/fragments/payIn.js similarity index 56% rename from fragments/paidAction.js rename to fragments/payIn.js index 94c319fcf9..262b9b2b88 100644 --- a/fragments/paidAction.js +++ b/fragments/payIn.js @@ -1,304 +1,366 @@ import gql from 'graphql-tag' -import { COMMENTS, COMMENT_FIELDS_NO_CHILD_COMMENTS } from './comments' +import { ITEM_FULL_FIELDS } from './items' import { SUB_FULL_FIELDS } from './subs' -import { INVOICE_FIELDS } from './invoice' +import { COMMENTS } from './comments' const HASH_HMAC_INPUT_1 = '$hash: String, $hmac: String' const HASH_HMAC_INPUT_2 = 'hash: $hash, hmac: $hmac' -export const PAID_ACTION = gql` - ${INVOICE_FIELDS} - fragment PaidActionFields on PaidAction { - invoice { - ...InvoiceFields - invoiceForward - } - paymentMethod - }` +export const PAY_IN_LINK_FIELDS = gql` + fragment PayInLinkFields on PayIn { + id + mcost + payInType + payInState + payInStateChangedAt + } +` -const ITEM_PAID_ACTION_FIELDS = gql` +export const PAY_IN_FIELDS = gql` + ${SUB_FULL_FIELDS} ${COMMENTS} - fragment ItemPaidActionFields on ItemPaidAction { - result { + ${PAY_IN_LINK_FIELDS} + fragment PayInFields on PayIn { + id + createdAt + updatedAt + mcost + userId + payInType + payInState + payInFailureReason + payInStateChangedAt + payInBolt11 { id - deleteScheduledAt - reminderScheduledAt - ...CommentFields - comments { - comments { - ...CommentsRecursive - } + payInId + bolt11 + hash + hmac + msatsRequested + msatsReceived + expiresAt + confirmedAt + cancelledAt + createdAt + updatedAt + lud18Data { + id + name + identifier + email + pubkey + } + nostrNote { + id + note + } + comment { + id + comment } } - }` - -const ITEM_PAID_ACTION_FIELDS_NO_CHILD_COMMENTS = gql` - ${COMMENT_FIELDS_NO_CHILD_COMMENTS} - fragment ItemPaidActionFieldsNoChildComments on ItemPaidAction { - result { - id - deleteScheduledAt - reminderScheduledAt - ...CommentFieldsNoChildComments + pessimisticEnv { + error + result } - } -` - -const ITEM_ACT_PAID_ACTION_FIELDS = gql` - fragment ItemActPaidActionFields on ItemActPaidAction { - result { + payInCustodialTokens { id - sats - path - act + mtokens + custodialTokenType } - }` - -export const GET_PAID_ACTION = gql` - ${PAID_ACTION} - ${ITEM_PAID_ACTION_FIELDS} - ${ITEM_ACT_PAID_ACTION_FIELDS} - ${SUB_FULL_FIELDS} - query paidAction($invoiceId: Int!) { - paidAction(invoiceId: $invoiceId) { + result { __typename - ...PaidActionFields - ... on ItemPaidAction { - ...ItemPaidActionFields - } - ... on ItemActPaidAction { - ...ItemActPaidActionFields - } - ... on PollVotePaidAction { - result { - id + ... on Item { + id + deleteScheduledAt + reminderScheduledAt + ...CommentFields + payIn { + ...PayInLinkFields } } - ... on SubPaidAction { - result { - ...SubFullFields + ... on ItemAct { + id + sats + path + act + payIn { + ...PayInLinkFields } } - ... on DonatePaidAction { - result { - sats + ... on PollVote { + id + payIn { + ...PayInLinkFields } } + ... on Sub { + ...SubFullFields + } } - }` + } +` -export const RETRY_PAID_ACTION = gql` - ${PAID_ACTION} - ${ITEM_PAID_ACTION_FIELDS} - ${ITEM_ACT_PAID_ACTION_FIELDS} - mutation retryPaidAction($invoiceId: Int!, $newAttempt: Boolean) { - retryPaidAction(invoiceId: $invoiceId, newAttempt: $newAttempt) { - __typename - ...PaidActionFields - ... on ItemPaidAction { - ...ItemPaidActionFields +export const PAY_IN_STATISTICS_FIELDS = gql` + ${PAY_IN_FIELDS} + ${ITEM_FULL_FIELDS} + ${SUB_FULL_FIELDS} + fragment PayInStatisticsFields on PayIn { + id + createdAt + updatedAt + mcost + userId + payInType + payInState + payInFailureReason + payInStateChangedAt + payInCustodialTokens { + id + mtokens + mtokensAfter + custodialTokenType + } + payInBolt11 { + id + bolt11 + preimage + hmac + expiresAt + confirmedAt + cancelledAt + lud18Data { + id + name + identifier + email + pubkey } - ... on ItemActPaidAction { - ...ItemActPaidActionFields + nostrNote { + id + note } - ... on PollVotePaidAction { - result { - id - } + comment { + id + comment + } + msatsRequested + msatsReceived + } + payOutBolt11 { + id + msats + userId + payOutType + } + payOutCustodialTokens { + id + userId + payOutType + mtokens + mtokensAfter + custodialTokenType + user { + name + } + sub { + name } } + item { + ...ItemFullFields + } + sub { + ...SubFullFields + } + } +` + +export const SATISTICS = gql` + ${PAY_IN_STATISTICS_FIELDS} + query satistics($cursor: String, $inc: String) { + satistics(cursor: $cursor, inc: $inc) { + payIns { + ...PayInStatisticsFields + } + cursor + } + } +` + +export const GET_PAY_IN_FULL = gql` + ${PAY_IN_STATISTICS_FIELDS} + query payIn($id: Int!) { + payIn(id: $id) { + ...PayInStatisticsFields + } + } +` + +export const GET_PAY_IN_RESULT = gql` + ${PAY_IN_FIELDS} + query payIn($id: Int!) { + payIn(id: $id) { + ...PayInFields + } + } +` + +export const RETRY_PAY_IN = gql` + ${PAY_IN_FIELDS} + mutation retryPayIn($payInId: Int!) { + retryPayIn(payInId: $payInId) { + ...PayInFields + } + } +` + +export const CANCEL_PAY_IN_BOLT11 = gql` + ${PAY_IN_FIELDS} + mutation cancelPayInBolt11($hash: String!, $hmac: String, $userCancel: Boolean) { + cancelPayInBolt11(hash: $hash, hmac: $hmac, userCancel: $userCancel) { + ...PayInFields + } }` export const DONATE = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation donateToRewards($sats: Int!) { donateToRewards(sats: $sats) { - result { - sats - } - ...PaidActionFields + ...PayInFields } }` export const BUY_CREDITS = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation buyCredits($credits: Int!) { buyCredits(credits: $credits) { - result { - credits - } - ...PaidActionFields + ...PayInFields } }` export const ACT_MUTATION = gql` - ${PAID_ACTION} - ${ITEM_ACT_PAID_ACTION_FIELDS} + ${PAY_IN_FIELDS} mutation act($id: ID!, $sats: Int!, $act: String, $hasSendWallet: Boolean) { act(id: $id, sats: $sats, act: $act, hasSendWallet: $hasSendWallet) { - ...ItemActPaidActionFields - ...PaidActionFields + ...PayInFields } }` export const UPSERT_DISCUSSION = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], ${HASH_HMAC_INPUT_1}) { upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, ${HASH_HMAC_INPUT_2}) { - result { - id - deleteScheduledAt - reminderScheduledAt - } - ...PaidActionFields + ...PayInFields } }` export const UPSERT_JOB = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String, $remote: Boolean, $text: String!, $url: String!, $boost: Int, $status: String, $logo: Int) { upsertJob(sub: $sub, id: $id, title: $title, company: $company, location: $location, remote: $remote, text: $text, url: $url, boost: $boost, status: $status, logo: $logo) { - result { - id - deleteScheduledAt - reminderScheduledAt - } - ...PaidActionFields + ...PayInFields } }` export const UPSERT_LINK = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], ${HASH_HMAC_INPUT_1}) { upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text, boost: $boost, forward: $forward, ${HASH_HMAC_INPUT_2}) { - result { - id - deleteScheduledAt - reminderScheduledAt - } - ...PaidActionFields + ...PayInFields } }` export const UPSERT_POLL = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $pollExpiresAt: Date, $randPollOptions: Boolean, ${HASH_HMAC_INPUT_1}) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, options: $options, boost: $boost, forward: $forward, pollExpiresAt: $pollExpiresAt, randPollOptions: $randPollOptions, ${HASH_HMAC_INPUT_2}) { - result { - id - deleteScheduledAt - reminderScheduledAt - } - ...PaidActionFields + ...PayInFields } }` export const UPSERT_BOUNTY = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertBounty($sub: String, $id: ID, $title: String!, $bounty: Int!, $text: String, $boost: Int, $forward: [ItemForwardInput]) { upsertBounty(sub: $sub, id: $id, title: $title, bounty: $bounty, text: $text, boost: $boost, forward: $forward) { - result { - id - deleteScheduledAt - reminderScheduledAt - } - ...PaidActionFields + ...PayInFields } }` export const POLL_VOTE = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation pollVote($id: ID!) { pollVote(id: $id) { - result { - id - } - ...PaidActionFields + ...PayInFields } }` export const UPSERT_BIO = gql` - ${ITEM_PAID_ACTION_FIELDS} - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertBio($text: String!) { upsertBio(text: $text) { - ...ItemPaidActionFields - ...PaidActionFields + ...PayInFields } }` export const CREATE_COMMENT = gql` - ${ITEM_PAID_ACTION_FIELDS} - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertComment($text: String!, $parentId: ID!) { upsertComment(text: $text, parentId: $parentId) { - ...ItemPaidActionFields - ...PaidActionFields + ...PayInFields } }` export const UPDATE_COMMENT = gql` - ${ITEM_PAID_ACTION_FIELDS_NO_CHILD_COMMENTS} - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertComment($id: ID!, $text: String!, $boost: Int, ${HASH_HMAC_INPUT_1}) { upsertComment(id: $id, text: $text, boost: $boost, ${HASH_HMAC_INPUT_2}) { - ...ItemPaidActionFieldsNoChildComments - ...PaidActionFields + ...PayInFields } }` export const UPSERT_SUB = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!, $replyCost: Int!, $postTypes: [String!]!, $billingType: String!, $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) { upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost, replyCost: $replyCost, postTypes: $postTypes, billingType: $billingType, billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) { - result { - name - } - ...PaidActionFields + ...PayInFields } }` export const UNARCHIVE_TERRITORY = gql` - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!, $replyCost: Int!, $postTypes: [String!]!, $billingType: String!, $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) { unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost, replyCost: $replyCost, postTypes: $postTypes, billingType: $billingType, billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) { - result { - name - } - ...PaidActionFields + ...PayInFields } }` export const SUB_PAY = gql` - ${SUB_FULL_FIELDS} - ${PAID_ACTION} + ${PAY_IN_FIELDS} mutation paySub($name: String!) { paySub(name: $name) { - result { - ...SubFullFields - } - ...PaidActionFields + ...PayInFields } }` diff --git a/lib/apollo.js b/lib/apollo.js index f1887015b3..9294a66304 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -64,14 +64,6 @@ function getClient (uri) { freezeResults: true, // https://github.com/apollographql/apollo-client/issues/7648 possibleTypes: { - PaidAction: [ - 'ItemPaidAction', - 'ItemActPaidAction', - 'PollVotePaidAction', - 'SubPaidAction', - 'DonatePaidAction', - 'ReceivePaidAction' - ], Notification: [ 'Reply', 'Votification', @@ -282,16 +274,17 @@ function getClient (uri) { return incoming } }, - walletHistory: { + satistics: { keyArgs: ['inc'], merge (existing, incoming) { - if (isFirstPage(incoming.cursor, existing?.facts)) { + console.log('merge', existing?.cursor, incoming?.cursor, existing?.cursor === incoming?.cursor) + if (isFirstPage(incoming.cursor, existing?.payIns)) { return incoming } return { cursor: incoming.cursor, - facts: [...(existing?.facts || []), ...incoming.facts] + payIns: [...(existing?.payIns || []), ...incoming.payIns] } } }, @@ -309,6 +302,16 @@ function getClient (uri) { } } }, + PayInBolt11: { + fields: { + hmac: { + // never get rid of the hmac after it's been set + merge (existing, incoming) { + return incoming || existing + } + } + } + }, Item: { fields: { comments: { diff --git a/lib/constants.js b/lib/constants.js index 50f0363ff5..3b501e581b 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -36,7 +36,7 @@ export const UPLOAD_TYPES_ALLOW = [ 'video/webm' ] export const AVATAR_TYPES_ALLOW = UPLOAD_TYPES_ALLOW.filter(t => t.startsWith('image/')) -export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE', 'BOOST'] +export const PAY_IN_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'BOOST'] export const BOUNTY_MIN = 1000 export const BOUNTY_MAX = 10000000 export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL'] diff --git a/lib/cursor.js b/lib/cursor.js index 6f245b4f2a..5b2f9e169c 100644 --- a/lib/cursor.js +++ b/lib/cursor.js @@ -12,13 +12,15 @@ export function decodeCursor (cursor) { } export function nextCursorEncoded (cursor, limit = LIMIT) { - cursor.offset += limit - return Buffer.from(JSON.stringify(cursor)).toString('base64') + const nextCursor = { ...cursor } + nextCursor.offset += limit + return Buffer.from(JSON.stringify(nextCursor)).toString('base64') } export function nextNoteCursorEncoded (cursor, notifications = [], limit = LIMIT) { + const nextCursor = { ...cursor } // what we are looking for this oldest sort time for every table we are looking at - cursor.time = new Date(notifications.slice(-1).pop()?.sortTime ?? cursor.time) - cursor.offset += limit - return Buffer.from(JSON.stringify(cursor)).toString('base64') + nextCursor.time = new Date(notifications.slice(-1).pop()?.sortTime ?? cursor.time) + nextCursor.offset += limit + return Buffer.from(JSON.stringify(nextCursor)).toString('base64') } diff --git a/lib/lnurl.js b/lib/lnurl.js index fff51b9789..8bc9dbbb6f 100644 --- a/lib/lnurl.js +++ b/lib/lnurl.js @@ -10,22 +10,17 @@ export function encodeLNUrl (url) { return bech32.encode('lnurl', words, 1023) } -export function lnurlPayMetadataString (username) { - return JSON.stringify([[ - 'text/plain', - `Funding @${username} on stacker.news` - ], [ - 'text/identifier', - `${username}@stacker.news` - ]]) -} - -export function lnurlPayDescriptionHashForUser (username) { - return lnurlPayDescriptionHash(lnurlPayMetadataString(username)) -} - -export function lnurlPayDescriptionHash (data) { - return createHash('sha256').update(data).digest('hex') +export function lnurlPayMetadata (username) { + const description = `Proxied payment to @${username}@stacker.news` + const metadata = JSON.stringify([ + ['text/plain', description], + ['text/identifier', `${username}@stacker.news`] + ]) + return { + metadata, + description, + descriptionHash: createHash('sha256').update(metadata).digest('hex') + } } export async function lnAddrOptions (addr, { signal } = {}) { diff --git a/lib/pay-in.js b/lib/pay-in.js new file mode 100644 index 0000000000..7ef3d2bf85 --- /dev/null +++ b/lib/pay-in.js @@ -0,0 +1,69 @@ +import { msatsToSats } from './format' + +export function describePayInType (payIn, meId) { + function type () { + switch (payIn.payInType) { + case 'ITEM_CREATE': + if (payIn.item.isJob) { + return 'job' + } else if (payIn.item.title) { + return 'post' + } else if (payIn.item.parentId) { + return 'comment' + } else { + return 'item' + } + case 'ITEM_UPDATE': + if (payIn.item.isJob) { + return 'job edit' + } else if (payIn.item.title) { + return 'post edit' + } else if (payIn.item.parentId) { + return 'comment edit' + } else { + return 'item edit' + } + case 'ZAP': + if (payIn.item?.root?.bounty === msatsToSats(payIn.mcost)) { + return 'pay bounty' + } else { + return 'zap' + } + case 'DOWN_ZAP': + return 'downzap' + case 'BOOST': + return 'boost' + case 'POLL_VOTE': + return 'poll vote' + case 'TERRITORY_CREATE': + return 'territory created' + case 'TERRITORY_UPDATE': + return 'territory updated' + case 'TERRITORY_BILLING': + return 'territory billing' + case 'TERRITORY_UNARCHIVE': + return 'territory unarchived' + case 'INVITE_GIFT': + return 'invite gift' + case 'DONATE': + return 'donate' + case 'BUY_CREDITS': + return 'buy credits' + case 'PROXY_PAYMENT': + return 'proxy payment' + case 'WITHDRAWAL': + return 'withdrawal' + case 'AUTOWITHDRAWAL': + return 'autowithdrawal' + default: + return 'unknown' + } + } + + const t = type() + if (Number(payIn.userId) !== Number(meId)) { + return t + ' receive' + } + + return t +} diff --git a/package-lock.json b/package-lock.json index 38843d8fc0..8d2cef04c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,11 @@ "@as-integrations/next": "^3.1.0", "@auth/prisma-adapter": "^2.7.0", "@cashu/cashu-ts": "^2.4.1", + "@graphile/depth-limit": "^0.3.1", "@graphql-tools/schema": "^10.0.6", "@lightninglabs/lnc-web": "^0.3.2-alpha", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "@noble/curves": "^1.6.0", "@nostr-dev-kit/ndk": "^2.12.2", "@nostr-dev-kit/ndk-wallet": "^0.5.0", @@ -3074,6 +3077,14 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" }, + "node_modules/@graphile/depth-limit": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@graphile/depth-limit/-/depth-limit-0.3.1.tgz", + "integrity": "sha512-3MwdOEScb7yZJnU/qM4sR7MEW7Nge8XxsvBmj33YamP+1e2st43M1VDYviPnOvqAPrIg2PJvvuNux+IId8sn0A==", + "peerDependencies": { + "graphql": ">=15 <17" + } + }, "node_modules/@graphql-tools/merge": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.0.7.tgz", @@ -4419,6 +4430,162 @@ "semver": "bin/semver.js" } }, + "node_modules/@nivo/colors": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", + "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", + "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", + "dependencies": { + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "react-virtualized-auto-sizer": "^1.0.26", + "use-debounce": "^10.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nivo/donate" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" + }, + "node_modules/@nivo/core/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/@nivo/core/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/core/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, + "node_modules/@nivo/legends": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", + "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/sankey": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/sankey/-/sankey-0.99.0.tgz", + "integrity": "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw==", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-sankey": "^0.11.2", + "@types/d3-shape": "^3.1.6", + "d3-sankey": "^0.12.3", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/text": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", + "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/theming": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", + "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/tooltip": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", + "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, "node_modules/@noble/ciphers": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", @@ -5049,6 +5216,72 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@react-spring/animated": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.1.tgz", + "integrity": "sha512-BGL3hA66Y8Qm3KmRZUlfG/mFbDPYajgil2/jOP0VXf2+o2WPVmcDps/eEgdDqgf5Pv9eBbyj7LschLMuSjlW3Q==", + "dependencies": { + "@react-spring/shared": "~10.0.1", + "@react-spring/types": "~10.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.1.tgz", + "integrity": "sha512-KaMMsN1qHuVTsFpg/5ajAVye7OEqhYbCq0g4aKM9bnSZlDBBYpO7Uf+9eixyXN8YEbF+YXaYj9eoWDs+npZ+sA==", + "dependencies": { + "@react-spring/animated": "~10.0.1", + "@react-spring/shared": "~10.0.1", + "@react-spring/types": "~10.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.1.tgz", + "integrity": "sha512-UrzG/d6Is+9i0aCAjsjWRqIlFFiC4lFqFHrH63zK935z2YDU95TOFio4VKGISJ5SG0xq4ULy7c1V3KU+XvL+Yg==" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.1.tgz", + "integrity": "sha512-KR2tmjDShPruI/GGPfAZOOLvDgkhFseabjvxzZFFggJMPkyICLjO0J6mCIoGtdJSuHywZyc4Mmlgi+C88lS00g==", + "dependencies": { + "@react-spring/rafz": "~10.0.1", + "@react-spring/types": "~10.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.1.tgz", + "integrity": "sha512-Fk1wYVAKL+ZTYK+4YFDpHf3Slsy59pfFFvnnTfRjQQFGlyIo4VejPtDs3CbDiuBjM135YztRyZjIH2VbycB+ZQ==" + }, + "node_modules/@react-spring/web": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.1.tgz", + "integrity": "sha512-FgQk02OqFrYyJBTTnBTWAU0WPzkHkKXauc6aeexcvATvLapUxwnfGuLlsLYF8BYjEVfkivPT04ziAue6zyRBtQ==", + "dependencies": { + "@react-spring/animated": "~10.0.1", + "@react-spring/core": "~10.0.1", + "@react-spring/shared": "~10.0.1", + "@react-spring/types": "~10.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@restart/hooks": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", @@ -5859,18 +6092,44 @@ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz", "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==" }, + "node_modules/@types/d3-sankey": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", + "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "dependencies": { + "@types/d3-path": "^1" + } + }, "node_modules/@types/d3-scale": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.3.tgz", - "integrity": "sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "dependencies": { "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==" + }, "node_modules/@types/d3-shape": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.1.tgz", - "integrity": "sha512-6Uh86YFF7LGg4PQkuO2oG6EMBRLuW9cbavUW46zkIO5kuS2PfTqo2o9SkgtQzguBHbLgNnU90UNsITpsX1My+A==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", "dependencies": { "@types/d3-path": "*" } @@ -8402,6 +8661,41 @@ "node": ">=12" } }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -8417,6 +8711,18 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -17674,6 +17980,15 @@ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-youtube": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-10.1.0.tgz", @@ -20612,6 +20927,17 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/use-debounce": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.5.tgz", + "integrity": "sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", diff --git a/package.json b/package.json index 77bf38d44f..d14c45e028 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,11 @@ "@as-integrations/next": "^3.1.0", "@auth/prisma-adapter": "^2.7.0", "@cashu/cashu-ts": "^2.4.1", + "@graphile/depth-limit": "^0.3.1", "@graphql-tools/schema": "^10.0.6", "@lightninglabs/lnc-web": "^0.3.2-alpha", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "@noble/curves": "^1.6.0", "@nostr-dev-kit/ndk": "^2.12.2", "@nostr-dev-kit/ndk-wallet": "^0.5.0", diff --git a/pages/[name]/index.js b/pages/[name]/index.js index 9e3c7355c5..4c85f309f8 100644 --- a/pages/[name]/index.js +++ b/pages/[name]/index.js @@ -15,7 +15,7 @@ import { useRouter } from 'next/router' import PageLoading from '@/components/page-loading' import { ItemButtonBar } from '@/components/post' import useItemSubmit from '@/components/use-item-submit' -import { UPSERT_BIO } from '@/fragments/paidAction' +import { UPSERT_BIO } from '@/fragments/payIn' export const getServerSideProps = getGetServerSideProps({ query: USER_FULL, diff --git a/pages/api/graphql.js b/pages/api/graphql.js index f6ba066ef7..8a6695159b 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -8,6 +8,8 @@ import { getServerSession } from 'next-auth/next' import { getAuthOptions } from './auth/[...nextauth]' import search from '@/api/search' import { multiAuthMiddleware } from '@/lib/auth' +import { depthLimit } from '@graphile/depth-limit' +import { COMMENT_DEPTH_LIMIT } from '@/lib/constants' import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled' const apolloServer = new ApolloServer({ @@ -15,6 +17,17 @@ const apolloServer = new ApolloServer({ resolvers, introspection: true, allowBatchedHttpRequests: true, + validationRules: [depthLimit({ + revealDetails: true, + maxListDepth: COMMENT_DEPTH_LIMIT, + maxDepth: 20, + maxIntrospectionDepth: 20, + maxDepthByFieldCoordinates: { + '__Type.ofType': 20, + 'Item.comments': COMMENT_DEPTH_LIMIT, + 'Comments.comments': COMMENT_DEPTH_LIMIT + } + })], plugins: [{ requestDidStart (initialRequestContext) { return { diff --git a/pages/api/lnurlp/[username]/index.js b/pages/api/lnurlp/[username]/index.js index 68626eb03f..799aa323cc 100644 --- a/pages/api/lnurlp/[username]/index.js +++ b/pages/api/lnurlp/[username]/index.js @@ -1,6 +1,6 @@ import { getPublicKey } from 'nostr' import models from '@/api/models' -import { lnurlPayMetadataString } from '@/lib/lnurl' +import { lnurlPayMetadata } from '@/lib/lnurl' import { LNURLP_COMMENT_MAX_LENGTH, PROXY_RECEIVE_FEE_PERCENT } from '@/lib/constants' export default async ({ query: { username } }, res) => { @@ -15,11 +15,12 @@ export default async ({ query: { username } }, res) => { } const url = process.env.NODE_ENV === 'development' ? process.env.SELF_URL : process.env.NEXT_PUBLIC_URL + const { metadata } = lnurlPayMetadata(username) return res.status(200).json({ callback: `${url}/api/lnurlp/${username}/pay`, // The URL from LN SERVICE which will accept the pay request parameters minSendable: Number(minSendable), // Min amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable` maxSendable: 1000000000, - metadata: lnurlPayMetadataString(username), // Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step + metadata, // Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step commentAllowed: LNURLP_COMMENT_MAX_LENGTH, // LUD-12 Comments for payRequests https://github.com/lnurl/luds/blob/luds/12.md payerData: { // LUD-18 payer data for payRequests https://github.com/lnurl/luds/blob/luds/18.md name: { mandatory: false }, diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 6be8f860e7..9160fc440f 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -1,14 +1,13 @@ import models from '@/api/models' -import lnd from '@/api/lnd' -import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl' +import { lnurlPayMetadata } from '@/lib/lnurl' import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' -import { LNURLP_COMMENT_MAX_LENGTH, MAX_INVOICE_DESCRIPTION_LENGTH } from '@/lib/constants' +import { LNURLP_COMMENT_MAX_LENGTH } from '@/lib/constants' import { formatMsats, toPositiveBigInt } from '@/lib/format' import assertGofacYourself from '@/api/resolvers/ofac' -import performPaidAction from '@/api/paidAction' import { validateSchema, lud18PayerDataSchema } from '@/lib/validate' import { walletLogger } from '@/wallets/server' +import pay from '@/api/payIn' export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => { const user = await models.user.findUnique({ where: { name: username } }) @@ -26,7 +25,8 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa try { await assertGofacYourself({ models, headers }) // if nostr, decode, validate sig, check tags, set description hash - let description, descriptionHash, noteStr + let { description, descriptionHash } = lnurlPayMetadata(username) + let noteStr if (nostr) { noteStr = decodeURIComponent(nostr) const note = JSON.parse(noteStr) @@ -37,17 +37,12 @@ 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 = 'zap' + // override description hash descriptionHash = createHash('sha256').update(noteStr).digest('hex') } else { res.status(400).json({ status: 'ERROR', reason: 'invalid NIP-57 note' }) return } - } else { - description = `Paying @${username} on stacker.news` - description += comment ? `: ${comment}` : '.' - description = description.slice(0, MAX_INVOICE_DESCRIPTION_LENGTH) - descriptionHash = lnurlPayDescriptionHashForUser(username) } if (comment?.length > LNURLP_COMMENT_MAX_LENGTH) { @@ -77,26 +72,25 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa } // Update description hash to include the passed payer data - const metadataStr = `${lnurlPayMetadataString(username)}${payerData}` - descriptionHash = lnurlPayDescriptionHash(metadataStr) + descriptionHash = createHash('sha256').update(lnurlPayMetadata(username).metadata + payerData).digest('hex') } // generate invoice - const { invoice, paymentMethod } = await performPaidAction('RECEIVE', { + const { payInBolt11 } = await pay('PROXY_PAYMENT', { msats: toPositiveBigInt(amount), description, descriptionHash, comment: comment || '', lud18Data: parsedPayerData, noteStr - }, { models, lnd, me: user }) + }, { models, me: user }) - if (!invoice?.bolt11) throw new Error('could not generate invoice') + if (!payInBolt11) throw new Error('could not generate invoice') return res.status(200).json({ - pr: invoice.bolt11, + pr: payInBolt11.bolt11, routes: [], - verify: paymentMethod !== 'DIRECT' && invoice.hash ? `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.hash}` : undefined + verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${payInBolt11.hash}` }) } catch (error) { console.log(error) diff --git a/pages/credits.js b/pages/credits.js index 27d85d909b..0c00e5f9c7 100644 --- a/pages/credits.js +++ b/pages/credits.js @@ -5,8 +5,8 @@ import { CenterLayout } from '@/components/layout' import { useAnimation } from '@/components/animation' import { useMe } from '@/components/me' import { useShowModal } from '@/components/modal' -import { usePaidMutation } from '@/components/use-paid-mutation' -import { BUY_CREDITS } from '@/fragments/paidAction' +import usePayInMutation from '@/components/payIn/hooks/use-pay-in-mutation' +import { BUY_CREDITS } from '@/fragments/payIn' import { amountSchema } from '@/lib/validate' import classNames from 'classnames' import { Button, Col, InputGroup, Row } from 'react-bootstrap' @@ -77,7 +77,7 @@ function WithdrawButton ({ className }) { export function BuyCreditsButton ({ className }) { const showModal = useShowModal() const animate = useAnimation() - const [buyCredits] = usePaidMutation(BUY_CREDITS) + const [buyCredits] = usePayInMutation(BUY_CREDITS) return ( <> diff --git a/pages/invites/[id].js b/pages/invites/[id].js index c088d98b9e..52e14decb7 100644 --- a/pages/invites/[id].js +++ b/pages/invites/[id].js @@ -8,7 +8,7 @@ import getSSRApolloClient from '@/api/ssrApollo' import Link from 'next/link' import { CenterLayout } from '@/components/layout' import { getAuthOptions } from '@/pages/api/auth/[...nextauth]' -import performPaidAction from '@/api/paidAction' +import pay from '@/api/payIn' export async function getServerSideProps ({ req, res, query: { id, error = null } }) { const session = await getServerSession(req, res, getAuthOptions(req)) @@ -35,10 +35,10 @@ export async function getServerSideProps ({ req, res, query: { id, error = null try { // attempt to send gift // catch any errors and just ignore them for now - await performPaidAction('INVITE_GIFT', { + await pay('INVITE_GIFT', { id, userId: session.user.id - }, { models, me: { id: data.invite.user.id } }) + }, { models, me: { id: parseInt(data.invite.user.id) } }) } catch (e) { console.log(e) } diff --git a/pages/rewards/index.js b/pages/rewards/index.js index a8b654c169..d07435562c 100644 --- a/pages/rewards/index.js +++ b/pages/rewards/index.js @@ -19,10 +19,10 @@ import { useData } from '@/components/use-data' import { GrowthPieChartSkeleton } from '@/components/charts-skeletons' import { useMemo } from 'react' import { CompactLongCountdown } from '@/components/countdown' -import { usePaidMutation } from '@/components/use-paid-mutation' -import { DONATE } from '@/fragments/paidAction' +import { DONATE } from '@/fragments/payIn' import { ITEM_FULL_FIELDS } from '@/fragments/items' import { ListItem } from '@/components/items' +import usePayInMutation from '@/components/payIn/hooks/use-pay-in-mutation' const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), { loading: () => @@ -134,7 +134,7 @@ export function DonateButton () { const showModal = useShowModal() const toaster = useToast() const animate = useAnimation() - const [donateToRewards] = usePaidMutation(DONATE) + const [donateToRewards] = usePayInMutation(DONATE) return ( <> diff --git a/pages/satistics/index.js b/pages/satistics/index.js index cd1767ee4b..a3dde22e7f 100644 --- a/pages/satistics/index.js +++ b/pages/satistics/index.js @@ -4,174 +4,13 @@ import { getGetServerSideProps } from '@/api/ssrApollo' import Nav from 'react-bootstrap/Nav' import Layout from '@/components/layout' import MoreFooter from '@/components/more-footer' -import { WALLET_HISTORY } from '@/fragments/invoice' -import styles from '@/styles/satistics.module.css' -import Moon from '@/svgs/moon-fill.svg' -import Check from '@/svgs/check-double-line.svg' -import ThumbDown from '@/svgs/thumb-down-fill.svg' -import { Checkbox, Form } from '@/components/form' import { useRouter } from 'next/router' -import Item from '@/components/item' -import { CommentFlat } from '@/components/comment' -import ItemJob from '@/components/item-job' import PageLoading from '@/components/page-loading' -import PayerData from '@/components/payer-data' -import { Badge } from 'react-bootstrap' import navStyles from '@/styles/nav.module.css' -import classNames from 'classnames' +import { SATISTICS } from '@/fragments/payIn' +import PayInTable from '@/components/payIn/table' -export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true }) - -function satusClass (status) { - if (!status) { - return 'text-reset' - } - - switch (status) { - case 'CONFIRMED': - return 'text-reset' - case 'PENDING': - return 'text-muted' - default: - return `${styles.failed} text-muted` - } -} - -function Satus ({ status, className }) { - if (!status) { - return null - } - - let color = 'danger'; let desc - switch (status) { - case 'CONFIRMED': - desc = 'confirmed' - color = 'success' - break - case 'EXPIRED': - desc = 'expired' - color = 'muted' - break - case 'CANCELLED': - desc = 'cancelled' - color = 'muted' - break - case 'PENDING': - desc = 'pending' - color = 'muted' - break - case 'INSUFFICIENT_BALANCE': - desc = "you didn't have enough sats" - break - case 'INVALID_PAYMENT': - desc = 'invalid payment' - break - case 'PATHFINDING_TIMEOUT': - case 'ROUTE_NOT_FOUND': - desc = 'no route found' - break - default: - return 'unknown failure' - } - - const Icon = () => { - switch (status) { - case 'CONFIRMED': - return - case 'PENDING': - return - default: - return - } - } - - return ( - - {desc} - - ) -} - -function Detail ({ fact }) { - if (fact.type === 'earn') { - return ( - - SN distributes the sats it earns back to its best stackers daily. These sats come from jobs, boosts, posting fees, and donations. - - ) - } - if (fact.type === 'donation') { - return ( -
- You made a donation to daily rewards! -
- ) - } - if (fact.type === 'referral') { - return ( -
- You stacked sats from a referral! -
- ) - } - - if (fact.type === 'billing') { - return ( -
billing for ~{fact.subName}
- ) - } - - if (fact.type === 'revenue') { - return ( -
revenue for ~{fact.subName}
- ) - } - - if (!fact.item) { - let zap - try { - zap = JSON.parse(fact.description) - } catch { } - - const pathRoot = fact.type === 'p2p' ? 'withdrawal' : fact.type - return ( -
- - {(!fact.bolt11 && invoice deleted) || - (zap && nostr zap{zap.content && `: ${zap.content}`}) || - (fact.description && {fact.description})} - - {fact.invoiceComment && sender says: {fact.invoiceComment}} - - {fact.autoWithdraw && {fact.type === 'p2p' ? 'p2p' : 'autowithdraw'}} - -
- ) - } - - if (fact.item.title) { - if (fact.item.isJob) { - return - } - return - } - - return -} - -function Fact ({ fact }) { - const factDate = new Date(fact.createdAt) - return ( - <> -
0 ? '' : 'text-muted'}`}>{fact.type}
-
- -
{`${factDate.toLocaleDateString()} ${factDate.toLocaleTimeString()}`}
-
-
0 ? '' : 'text-muted'}`}>{fact.sats}
- - ) -} +export const getServerSideProps = getGetServerSideProps({ query: SATISTICS, authRequired: true, variables: { inc: '' } }) export function SatisticsHeader () { const router = useRouter() @@ -200,75 +39,18 @@ export function SatisticsHeader () { } export default function Satistics ({ ssrData }) { - const router = useRouter() - const { data, fetchMore } = useQuery(WALLET_HISTORY, { variables: { inc: router.query.inc } }) - if (!data && !ssrData) return - - function filterRoutePush (filter, add) { - const inc = new Set(router.query.inc?.split(',')) - inc.delete('') - // depending on addrem, add or remove filter - if (add) { - inc.add(filter) - } else { - inc.delete(filter) - } - - const incstr = [...inc].join(',') - router.push(`/satistics?inc=${incstr}`) - } - - function included (filter) { - const inc = new Set(router.query.inc?.split(',')) - return inc.has(filter) - } + const { data, fetchMore } = useQuery(SATISTICS, { variables: { inc: '' } }) + if (!ssrData && !data) return - const { walletHistory: { facts, cursor } } = data || ssrData + const { satistics: { payIns, cursor } } = data || ssrData return (
- -
-
- filterRoutePush('invoice', c)} - /> - filterRoutePush('withdrawal', c)} - /> - filterRoutePush('stacked', c)} - /> - filterRoutePush('spent', c)} - /> -
-
-
-
type
-
detail
-
sats/credits
- {facts.map(f => )} -
+
- +
) diff --git a/pages/satistics_old/graphs/[when].js b/pages/satistics_old/graphs/[when].js new file mode 100644 index 0000000000..c7a58db0aa --- /dev/null +++ b/pages/satistics_old/graphs/[when].js @@ -0,0 +1,111 @@ +import { useQuery } from '@apollo/client' +import { getGetServerSideProps } from '@/api/ssrApollo' +import Layout from '@/components/layout' +import { USER_STATS } from '@/fragments/users' +import { useRouter } from 'next/router' +import PageLoading from '@/components/page-loading' +import dynamic from 'next/dynamic' +import { numWithUnits } from '@/lib/format' +import { UserAnalyticsHeader } from '@/components/user-analytics-header' +import { SatisticsHeader } from '..' +import { WhenComposedChartSkeleton, WhenAreaChartSkeleton } from '@/components/charts-skeletons' +import OverlayTrigger from 'react-bootstrap/OverlayTrigger' +import Tooltip from 'react-bootstrap/Tooltip' + +export const getServerSideProps = getGetServerSideProps({ query: USER_STATS, authRequired: true }) + +const WhenAreaChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenAreaChart), { + loading: () => +}) +const WhenComposedChart = dynamic(() => import('@/components/charts').then(mod => mod.WhenComposedChart), { + loading: () => +}) + +const SatisticsTooltip = ({ children, overlayText }) => { + return ( + + {overlayText} + + } + > + + {children} + + + ) +} + +export default function Satistics ({ ssrData }) { + const router = useRouter() + const { when, from, to } = router.query + + const { data } = useQuery(USER_STATS, { variables: { when, from, to } }) + if (!data && !ssrData) return + const { userStatsActions, userStatsIncomingSats, userStatsOutgoingSats } = data || ssrData + + const totalStacked = userStatsIncomingSats.reduce((total, a) => total + a.data?.reduce((acc, d) => acc + d.value, 0), 0) + const totalSpent = userStatsOutgoingSats.reduce((total, a) => total + a.data?.reduce((acc, d) => acc + d.value, 0), 0) + + return ( + +
+ +
+
+ +
+
+
+

stacked

+
+
+ +

+ {numWithUnits(totalStacked, { abbreviate: true, format: true })} +

+
+
+
+
+
+

spent

+
+
+ +

+ {numWithUnits(totalSpent, { abbreviate: true, format: true })} +

+
+
+
+
+
+
+ {userStatsIncomingSats.length > 0 && +
+
stacking
+ +
} + {userStatsOutgoingSats.length > 0 && +
+
spending
+ +
} +
+
+ {userStatsActions.length > 0 && +
+
items
+ +
} +
+
+
+
+
+
+ ) +} diff --git a/pages/satistics_old/index.js b/pages/satistics_old/index.js new file mode 100644 index 0000000000..2425e76047 --- /dev/null +++ b/pages/satistics_old/index.js @@ -0,0 +1,275 @@ +import { useQuery } from '@apollo/client' +import Link from 'next/link' +import { getGetServerSideProps } from '@/api/ssrApollo' +import Nav from 'react-bootstrap/Nav' +import Layout from '@/components/layout' +import MoreFooter from '@/components/more-footer' +import { WALLET_HISTORY } from '@/fragments/invoice' +import styles from '@/styles/satistics_old.module.css' +import Moon from '@/svgs/moon-fill.svg' +import Check from '@/svgs/check-double-line.svg' +import ThumbDown from '@/svgs/thumb-down-fill.svg' +import { Checkbox, Form } from '@/components/form' +import { useRouter } from 'next/router' +import Item from '@/components/item' +import { CommentFlat } from '@/components/comment' +import ItemJob from '@/components/item-job' +import PageLoading from '@/components/page-loading' +import PayerData from '@/components/payer-data' +import { Badge } from 'react-bootstrap' +import navStyles from '@/styles/nav.module.css' +import classNames from 'classnames' + +export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true }) + +function satusClass (status) { + if (!status) { + return 'text-reset' + } + + switch (status) { + case 'CONFIRMED': + return 'text-reset' + case 'PENDING': + return 'text-muted' + default: + return `${styles.failed} text-muted` + } +} + +function Satus ({ status, className }) { + if (!status) { + return null + } + + let color = 'danger'; let desc + switch (status) { + case 'CONFIRMED': + desc = 'confirmed' + color = 'success' + break + case 'EXPIRED': + desc = 'expired' + color = 'muted' + break + case 'CANCELLED': + desc = 'cancelled' + color = 'muted' + break + case 'PENDING': + desc = 'pending' + color = 'muted' + break + case 'INSUFFICIENT_BALANCE': + desc = "you didn't have enough sats" + break + case 'INVALID_PAYMENT': + desc = 'invalid payment' + break + case 'PATHFINDING_TIMEOUT': + case 'ROUTE_NOT_FOUND': + desc = 'no route found' + break + default: + return 'unknown failure' + } + + const Icon = () => { + switch (status) { + case 'CONFIRMED': + return + case 'PENDING': + return + default: + return + } + } + + return ( + + {desc} + + ) +} + +function Detail ({ fact }) { + if (fact.type === 'earn') { + return ( + + SN distributes the sats it earns back to its best stackers daily. These sats come from jobs, boosts, posting fees, and donations. + + ) + } + if (fact.type === 'donation') { + return ( +
+ You made a donation to daily rewards! +
+ ) + } + if (fact.type === 'referral') { + return ( +
+ You stacked sats from a referral! +
+ ) + } + + if (fact.type === 'billing') { + return ( +
billing for ~{fact.subName}
+ ) + } + + if (fact.type === 'revenue') { + return ( +
revenue for ~{fact.subName}
+ ) + } + + if (!fact.item) { + let zap + try { + zap = JSON.parse(fact.description) + } catch { } + + const pathRoot = fact.type === 'p2p' ? 'withdrawal' : fact.type + return ( +
+ + {(!fact.bolt11 && invoice deleted) || + (zap && nostr zap{zap.content && `: ${zap.content}`}) || + (fact.description && {fact.description})} + + {fact.invoiceComment && sender says: {fact.invoiceComment}} + + {fact.autoWithdraw && {fact.type === 'p2p' ? 'p2p' : 'autowithdraw'}} + +
+ ) + } + + if (fact.item.title) { + if (fact.item.isJob) { + return + } + return + } + + return +} + +function Fact ({ fact }) { + const factDate = new Date(fact.createdAt) + return ( + <> +
0 ? '' : 'text-muted'}`}>{fact.type}
+
+ +
{`${factDate.toLocaleDateString()} ${factDate.toLocaleTimeString()}`}
+
+
0 ? '' : 'text-muted'}`}>{fact.sats}
+ + ) +} + +export function SatisticsHeader () { + const router = useRouter() + const pathParts = router.asPath.split('?')[0].split('/').filter(segment => !!segment) + const activeKey = pathParts[1] ?? 'history' + return ( + <> +

satistics

+ + + ) +} + +export default function Satistics ({ ssrData }) { + const router = useRouter() + const { data, fetchMore } = useQuery(WALLET_HISTORY, { variables: { inc: router.query.inc } }) + if (!data && !ssrData) return + + function filterRoutePush (filter, add) { + const inc = new Set(router.query.inc?.split(',')) + inc.delete('') + // depending on addrem, add or remove filter + if (add) { + inc.add(filter) + } else { + inc.delete(filter) + } + + const incstr = [...inc].join(',') + router.push(`/satistics?inc=${incstr}`) + } + + function included (filter) { + const inc = new Set(router.query.inc?.split(',')) + return inc.has(filter) + } + + const { walletHistory: { facts, cursor } } = data || ssrData + + return ( + +
+ +
+
+ filterRoutePush('invoice', c)} + /> + filterRoutePush('withdrawal', c)} + /> + filterRoutePush('stacked', c)} + /> + filterRoutePush('spent', c)} + /> +
+
+
+
+
type
+
detail
+
sats/credits
+ {facts.map(f => )} +
+
+ +
+
+ ) +} diff --git a/pages/transactions/[id].js b/pages/transactions/[id].js new file mode 100644 index 0000000000..83d01d2e53 --- /dev/null +++ b/pages/transactions/[id].js @@ -0,0 +1,17 @@ +import { getGetServerSideProps } from '@/api/ssrApollo' +import Layout from '@/components/layout' +import PayIn from '@/components/payIn' +import { useRouter } from 'next/router' + +// force SSR to include CSP nonces +export const getServerSideProps = getGetServerSideProps({ query: null }) + +export default function Transaction () { + const router = useRouter() + + return ( + + + + ) +} diff --git a/prisma/migrations/20250805192029_pay_in/migration.sql b/prisma/migrations/20250805192029_pay_in/migration.sql new file mode 100644 index 0000000000..7772308d1a --- /dev/null +++ b/prisma/migrations/20250805192029_pay_in/migration.sql @@ -0,0 +1,622 @@ +-- CreateEnum +CREATE TYPE "PayInType" AS ENUM ('BUY_CREDITS', 'ITEM_CREATE', 'ITEM_UPDATE', 'ZAP', 'DOWN_ZAP', 'BOOST', 'DONATE', 'POLL_VOTE', 'INVITE_GIFT', 'TERRITORY_CREATE', 'TERRITORY_UPDATE', 'TERRITORY_BILLING', 'TERRITORY_UNARCHIVE', 'PROXY_PAYMENT', 'REWARDS', 'WITHDRAWAL', 'AUTO_WITHDRAWAL'); + +-- CreateEnum +CREATE TYPE "PayInState" AS ENUM ('PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP', 'PENDING_WITHDRAWAL', 'PENDING', 'PENDING_HELD', 'HELD', 'PAID', 'FAILED', 'FORWARDING', 'FORWARDED', 'FAILED_FORWARD', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PayInFailureReason" AS ENUM ('INVOICE_CREATION_FAILED', 'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE', 'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY', 'INVOICE_WRAPPING_FAILED_UNKNOWN', 'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW', 'INVOICE_FORWARDING_FAILED', 'HELD_INVOICE_UNEXPECTED_ERROR', 'HELD_INVOICE_SETTLED_TOO_SLOW', 'WITHDRAWAL_FAILED', 'USER_CANCELLED', 'SYSTEM_CANCELLED', 'INVOICE_EXPIRED', 'EXECUTION_FAILED', 'UNKNOWN_FAILURE'); + +-- CreateEnum +CREATE TYPE "CustodialTokenType" AS ENUM ('CREDITS', 'SATS'); + +-- CreateEnum +CREATE TYPE "PayOutType" AS ENUM ('TERRITORY_REVENUE', 'REWARDS_POOL', 'ROUTING_FEE', 'ROUTING_FEE_REFUND', 'PROXY_PAYMENT', 'ZAP', 'REWARD', 'INVITE_GIFT', 'WITHDRAWAL', 'SYSTEM_REVENUE', 'BUY_CREDITS'); + +-- AlterTable +ALTER TABLE "WalletLog" ADD COLUMN "payOutBolt11Id" INTEGER; + +-- CreateTable +CREATE TABLE "ItemPayIn" ( + "id" SERIAL NOT NULL, + "itemId" INTEGER NOT NULL, + "payInId" INTEGER NOT NULL, + + CONSTRAINT "ItemPayIn_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SubPayIn" ( + "id" SERIAL NOT NULL, + "subName" CITEXT NOT NULL, + "payInId" INTEGER NOT NULL, + + CONSTRAINT "SubPayIn_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayIn" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "mcost" BIGINT NOT NULL, + "payInType" "PayInType" NOT NULL, + "payInState" "PayInState" NOT NULL, + "payInFailureReason" "PayInFailureReason", + "payInStateChangedAt" TIMESTAMP(3), + "genesisId" INTEGER, + "successorId" INTEGER, + "benefactorId" INTEGER, + "userId" INTEGER, + + CONSTRAINT "PayIn_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayInCustodialToken" ( + "id" SERIAL NOT NULL, + "payInId" INTEGER NOT NULL, + "mtokens" BIGINT NOT NULL, + "custodialTokenType" "CustodialTokenType" NOT NULL, + "mtokensAfter" BIGINT, + + CONSTRAINT "PayInCustodialToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PessimisticEnv" ( + "id" SERIAL NOT NULL, + "payInId" INTEGER NOT NULL, + "args" JSONB, + "error" TEXT, + "result" JSONB, + + CONSTRAINT "PessimisticEnv_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SubPayOutCustodialToken" ( + "id" SERIAL NOT NULL, + "subName" CITEXT NOT NULL, + "payOutCustodialTokenId" INTEGER NOT NULL, + + CONSTRAINT "SubPayOutCustodialToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayOutCustodialToken" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "payOutType" "PayOutType" NOT NULL, + "userId" INTEGER, + "payInId" INTEGER NOT NULL, + "mtokens" BIGINT NOT NULL, + "custodialTokenType" "CustodialTokenType" NOT NULL, + "mtokensAfter" BIGINT, + + CONSTRAINT "PayOutCustodialToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayInBolt11" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "payInId" INTEGER NOT NULL, + "hash" TEXT NOT NULL, + "preimage" TEXT, + "bolt11" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "confirmedAt" TIMESTAMP(3), + "confirmedIndex" BIGINT, + "cancelledAt" TIMESTAMP(3), + "msatsRequested" BIGINT NOT NULL, + "msatsReceived" BIGINT, + "expiryHeight" INTEGER, + "acceptHeight" INTEGER, + "userId" INTEGER, + "protocolId" INTEGER, + + CONSTRAINT "PayInBolt11_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayOutBolt11" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "payOutType" "PayOutType" NOT NULL, + "userId" INTEGER NOT NULL, + "hash" TEXT, + "preimage" TEXT, + "bolt11" TEXT, + "msats" BIGINT NOT NULL, + "status" "WithdrawlStatus", + "protocolId" INTEGER, + "payInId" INTEGER NOT NULL, + + CONSTRAINT "PayOutBolt11_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayInBolt11Lud18" ( + "id" SERIAL NOT NULL, + "payInBolt11Id" INTEGER NOT NULL, + "name" TEXT, + "identifier" TEXT, + "email" TEXT, + "pubkey" TEXT, + + CONSTRAINT "PayInBolt11Lud18_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayInBolt11NostrNote" ( + "id" SERIAL NOT NULL, + "payInBolt11Id" INTEGER NOT NULL, + "note" JSONB NOT NULL, + + CONSTRAINT "PayInBolt11NostrNote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayInBolt11Comment" ( + "id" SERIAL NOT NULL, + "payInBolt11Id" INTEGER NOT NULL, + "comment" TEXT NOT NULL, + + CONSTRAINT "PayInBolt11Comment_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "PollVote" ADD COLUMN "payInId" INTEGER; + +-- AddForeignKey +ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_payInId_fkey" FOREIGN KEY ("payInId") REFERENCES "PayIn"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "PollVote_payInId_key" ON "PollVote"("payInId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ItemPayIn_payInId_key" ON "ItemPayIn"("payInId"); + +-- CreateIndex +CREATE INDEX "ItemPayIn_itemId_idx" ON "ItemPayIn"("itemId"); + +-- CreateIndex +CREATE INDEX "ItemPayIn_payInId_idx" ON "ItemPayIn"("payInId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ItemPayIn_itemId_payInId_key" ON "ItemPayIn"("itemId", "payInId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SubPayIn_payInId_key" ON "SubPayIn"("payInId"); + +-- CreateIndex +CREATE INDEX "SubPayIn_subName_idx" ON "SubPayIn"("subName"); + +-- CreateIndex +CREATE INDEX "SubPayIn_payInId_idx" ON "SubPayIn"("payInId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SubPayIn_subName_payInId_key" ON "SubPayIn"("subName", "payInId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayIn_successorId_key" ON "PayIn"("successorId"); + +-- CreateIndex +CREATE INDEX "PayIn_userId_idx" ON "PayIn"("userId"); + +-- CreateIndex +CREATE INDEX "PayIn_payInType_idx" ON "PayIn"("payInType"); + +-- CreateIndex +CREATE INDEX "PayIn_successorId_idx" ON "PayIn"("successorId"); + +-- CreateIndex +CREATE INDEX "PayIn_payInStateChangedAt_idx" ON "PayIn"("payInStateChangedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PessimisticEnv_payInId_key" ON "PessimisticEnv"("payInId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SubPayOutCustodialToken_payOutCustodialTokenId_key" ON "SubPayOutCustodialToken"("payOutCustodialTokenId"); + +-- CreateIndex +CREATE INDEX "SubPayOutCustodialToken_subName_idx" ON "SubPayOutCustodialToken"("subName"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayInBolt11_payInId_key" ON "PayInBolt11"("payInId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayInBolt11_hash_key" ON "PayInBolt11"("hash"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayInBolt11_preimage_key" ON "PayInBolt11"("preimage"); + +-- CreateIndex +CREATE INDEX "PayInBolt11_created_at_idx" ON "PayInBolt11"("created_at"); + +-- CreateIndex +CREATE INDEX "PayInBolt11_confirmedIndex_idx" ON "PayInBolt11"("confirmedIndex"); + +-- CreateIndex +CREATE INDEX "PayInBolt11_confirmedAt_idx" ON "PayInBolt11"("confirmedAt"); + +-- CreateIndex +CREATE INDEX "PayInBolt11_cancelledAt_idx" ON "PayInBolt11"("cancelledAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayOutBolt11_payInId_key" ON "PayOutBolt11"("payInId"); + +-- CreateIndex +CREATE INDEX "PayOutBolt11_created_at_idx" ON "PayOutBolt11"("created_at"); + +-- CreateIndex +CREATE INDEX "PayOutBolt11_userId_idx" ON "PayOutBolt11"("userId"); + +-- CreateIndex +CREATE INDEX "PayOutBolt11_hash_idx" ON "PayOutBolt11"("hash"); + +-- CreateIndex +CREATE INDEX "PayOutBolt11_protocolId_idx" ON "PayOutBolt11"("protocolId"); + +-- CreateIndex +CREATE INDEX "PayOutBolt11_status_idx" ON "PayOutBolt11"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayInBolt11Lud18_payInBolt11Id_key" ON "PayInBolt11Lud18"("payInBolt11Id"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayInBolt11NostrNote_payInBolt11Id_key" ON "PayInBolt11NostrNote"("payInBolt11Id"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayInBolt11Comment_payInBolt11Id_key" ON "PayInBolt11Comment"("payInBolt11Id"); + +-- AddForeignKey +ALTER TABLE "WalletLog" ADD CONSTRAINT "WalletLog_payOutBolt11Id_fkey" FOREIGN KEY ("payOutBolt11Id") REFERENCES "PayOutBolt11"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ItemPayIn" ADD CONSTRAINT "ItemPayIn_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ItemPayIn" ADD CONSTRAINT "ItemPayIn_payInId_fkey" FOREIGN KEY ("payInId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubPayIn" ADD CONSTRAINT "SubPayIn_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubPayIn" ADD CONSTRAINT "SubPayIn_payInId_fkey" FOREIGN KEY ("payInId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayIn" ADD CONSTRAINT "PayIn_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayIn" ADD CONSTRAINT "PayIn_genesisId_fkey" FOREIGN KEY ("genesisId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayIn" ADD CONSTRAINT "PayIn_successorId_fkey" FOREIGN KEY ("successorId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayIn" ADD CONSTRAINT "PayIn_benefactorId_fkey" FOREIGN KEY ("benefactorId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayInCustodialToken" ADD CONSTRAINT "PayInCustodialToken_payInId_fkey" FOREIGN KEY ("payInId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PessimisticEnv" ADD CONSTRAINT "PessimisticEnv_payInId_fkey" FOREIGN KEY ("payInId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubPayOutCustodialToken" ADD CONSTRAINT "SubPayOutCustodialToken_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubPayOutCustodialToken" ADD CONSTRAINT "SubPayOutCustodialToken_payOutCustodialTokenId_fkey" FOREIGN KEY ("payOutCustodialTokenId") REFERENCES "PayOutCustodialToken"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayOutCustodialToken" ADD CONSTRAINT "PayOutCustodialToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayOutCustodialToken" ADD CONSTRAINT "PayOutCustodialToken_payInId_fkey" FOREIGN KEY ("payInId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayInBolt11" ADD CONSTRAINT "PayInBolt11_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayInBolt11" ADD CONSTRAINT "PayInBolt11_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayInBolt11" ADD CONSTRAINT "PayInBolt11_payInId_fkey" FOREIGN KEY ("payInId") REFERENCES "PayIn"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayOutBolt11" ADD CONSTRAINT "PayOutBolt11_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayOutBolt11" ADD CONSTRAINT "PayOutBolt11_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayOutBolt11" ADD CONSTRAINT "PayOutBolt11_payInId_fkey" FOREIGN KEY ("payInId") REFERENCES "PayIn"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayInBolt11Lud18" ADD CONSTRAINT "PayInBolt11Lud18_payInBolt11Id_fkey" FOREIGN KEY ("payInBolt11Id") REFERENCES "PayInBolt11"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayInBolt11NostrNote" ADD CONSTRAINT "PayInBolt11NostrNote_payInBolt11Id_fkey" FOREIGN KEY ("payInBolt11Id") REFERENCES "PayInBolt11"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PayInBolt11Comment" ADD CONSTRAINT "PayInBolt11Comment_payInBolt11Id_fkey" FOREIGN KEY ("payInBolt11Id") REFERENCES "PayInBolt11"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE OR REPLACE FUNCTION check_pending_bolt11s() +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE +BEGIN + UPDATE pgboss.schedule SET name = 'checkPendingPayInBolt11s', cron = '*/5 * * * *' WHERE name = 'checkPendingDeposits'; + UPDATE pgboss.schedule SET name = 'checkPendingPayOutBolt11s', cron = '*/5 * * * *' WHERE name = 'checkPendingWithdrawals'; + return 0; +EXCEPTION WHEN OTHERS THEN + return 0; +END; +$$; + +SELECT check_pending_bolt11s(); +DROP FUNCTION check_pending_bolt11s(); + +-- migrates these functions to use payIn instead of invoice + +CREATE OR REPLACE FUNCTION item_comments(_item_id int, _level int, _where text, _order_by text) + RETURNS jsonb + LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS +$$ +DECLARE + result jsonb; +BEGIN + IF _level < 1 THEN + RETURN '[]'::jsonb; + END IF; + + EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS' + || ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", ' + || ' to_jsonb("PayIn".*) || jsonb_build_object(''payInStateChangedAt'', "PayIn"."payInStateChangedAt" at time zone ''UTC'') as "payIn", ' + || ' to_jsonb(users.*) as user, ' + || ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" ' + || ' FROM "Item" ' + || ' JOIN users ON users.id = "Item"."userId" ' + || ' JOIN LATERAL ( ' + || ' SELECT "PayIn".* ' + || ' FROM "ItemPayIn" ' + || ' JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" AND "PayIn"."payInType" = ''ITEM_CREATE'' ' + || ' WHERE "ItemPayIn"."itemId" = "Item".id AND "PayIn"."payInState" = ''PAID'' ' + || ' ORDER BY "PayIn"."created_at" DESC ' + || ' LIMIT 1 ' + || ' ) "PayIn" ON "PayIn".id IS NOT NULL ' + || ' LEFT JOIN hot_score_view g ON g.id = "Item".id ' + || ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where + USING _item_id, _level, _where, _order_by; + + EXECUTE '' + || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments ' + || 'FROM ( ' + || ' SELECT "Item".*, item_comments("Item".id, $2 - 1, $3, $4) AS comments ' + || ' FROM t_item "Item"' + || ' WHERE "Item"."parentId" = $1 ' + || _order_by + || ' ) sub' + INTO result USING _item_id, _level, _where, _order_by; + RETURN result; +END +$$; + +CREATE OR REPLACE FUNCTION item_comments_limited( + _item_id int, _limit int, _offset int, _grandchild_limit int, + _level int, _where text, _order_by text) + RETURNS jsonb + LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS +$$ +DECLARE + result jsonb; +BEGIN + IF _level < 1 THEN + RETURN '[]'::jsonb; + END IF; + + EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS ' + || 'WITH RECURSIVE base AS ( ' + || ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn ' + || ' FROM "Item" ' + || ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id ' + || ' WHERE "Item"."parentId" = $1 ' + || _order_by || ' ' + || ' LIMIT $2 ' + || ' OFFSET $3) ' + || ' UNION ALL ' + || ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') ' + || ' FROM "Item" ' + || ' JOIN base b ON "Item"."parentId" = b.id ' + || ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id ' + || ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4)) ' + || ') ' + || 'SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", ' + || ' to_jsonb("PayIn".*) || jsonb_build_object(''payInStateChangedAt'', "PayIn"."payInStateChangedAt" at time zone ''UTC'') as "payIn", ' + || ' to_jsonb(users.*) as user, ' + || ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" ' + || 'FROM base "Item" ' + || 'JOIN users ON users.id = "Item"."userId" ' + || 'JOIN LATERAL ( ' + || ' SELECT "PayIn".* ' + || ' FROM "ItemPayIn" ' + || ' JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" AND "PayIn"."payInType" = ''ITEM_CREATE'' ' + || ' WHERE "ItemPayIn"."itemId" = "Item".id AND "PayIn"."payInState" = ''PAID'' ' + || ' ORDER BY "PayIn"."created_at" DESC ' + || ' LIMIT 1 ' + || ') "PayIn" ON "PayIn".id IS NOT NULL ' + || 'LEFT JOIN hot_score_view g ON g.id = "Item".id ' + || 'WHERE ("Item".level = 1 OR "Item".rn <= $4 - "Item".level + 2) ' || _where + USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by; + + + EXECUTE '' + || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments ' + || 'FROM ( ' + || ' SELECT "Item".*, item_comments_limited("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments ' + || ' FROM t_item "Item" ' + || ' WHERE "Item"."parentId" = $1 ' + || _order_by + || ' ) sub' + INTO result USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by; + RETURN result; +END +$$; + +-- add cowboy credits +CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me(_item_id int, _global_seed int, _me_id int, _level int, _where text, _order_by text) + RETURNS jsonb + LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS +$$ +DECLARE + result jsonb; +BEGIN + IF _level < 1 THEN + RETURN '[]'::jsonb; + END IF; + + EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS' + || ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", ' + || ' to_jsonb("PayIn".*) || jsonb_build_object(''payInStateChangedAt'', "PayIn"."payInStateChangedAt" at time zone ''UTC'') as "payIn", ' + || ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, ' + || ' COALESCE("MeItemPayIn"."meMsats", 0) AS "meMsats", COALESCE("MeItemPayIn"."mePendingMsats", 0) as "mePendingMsats", COALESCE("MeItemPayIn"."meDontLikeMsats", 0) AS "meDontLikeMsats", ' + || ' COALESCE("MeItemPayIn"."meMcredits", 0) AS "meMcredits", COALESCE("MeItemPayIn"."mePendingMcredits", 0) as "mePendingMcredits", ' + || ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", ' + || ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" ' + || ' FROM "Item" ' + || ' JOIN users ON users.id = "Item"."userId" ' + || ' JOIN LATERAL ( ' + || ' SELECT "PayIn".* ' + || ' FROM "ItemPayIn" ' + || ' JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" AND "PayIn"."payInType" = ''ITEM_CREATE'' ' + || ' WHERE "ItemPayIn"."itemId" = "Item".id AND ("PayIn"."userId" = $5 OR "PayIn"."payInState" = ''PAID'') ' + || ' ORDER BY "PayIn"."created_at" DESC ' + || ' LIMIT 1 ' + || ' ) "PayIn" ON "PayIn".id IS NOT NULL ' + || ' LEFT JOIN "Mute" ON "Mute"."muterId" = $5 AND "Mute"."mutedId" = "Item"."userId"' + || ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $5 AND "Bookmark"."itemId" = "Item".id ' + || ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $5 AND "ThreadSubscription"."itemId" = "Item".id ' + || ' LEFT JOIN LATERAL ( ' + || ' SELECT "itemId", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> ''FAILED'' AND "PayOutBolt11".id IS NOT NULL AND "PayIn"."payInType" = ''ZAP'') AS "meMsats", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> ''FAILED'' AND "PayOutBolt11".id IS NULL AND "PayIn"."payInType" = ''ZAP'') AS "meMcredits", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" = ''PENDING'' AND "PayOutBolt11".id IS NOT NULL AND "PayIn"."payInType" = ''ZAP'') AS "mePendingMsats", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" = ''PENDING'' AND "PayOutBolt11".id IS NULL AND "PayIn"."payInType" = ''ZAP'') AS "mePendingMcredits", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> ''FAILED'' AND "PayIn"."payInType" = ''DOWN_ZAP'') AS "meDontLikeMsats" ' + || ' FROM "ItemPayIn" ' + || ' JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" ' + || ' LEFT JOIN "PayOutBolt11" ON "PayOutBolt11"."payInId" = "PayIn".id ' + || ' WHERE "ItemPayIn"."itemId" = "Item".id AND "PayIn"."userId" = $5 ' + || ' GROUP BY "ItemPayIn"."itemId" ' + || ' ) "MeItemPayIn" ON true ' + || ' LEFT JOIN hot_score_view g ON g.id = "Item".id ' + || ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where || ' ' + USING _item_id, _level, _where, _order_by, _me_id, _global_seed; + + EXECUTE '' + || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments ' + || 'FROM ( ' + || ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $6, $5, $2 - 1, $3, $4) AS comments ' + || ' FROM t_item "Item" ' + || ' WHERE "Item"."parentId" = $1 ' + || _order_by + || ' ) sub' + INTO result USING _item_id, _level, _where, _order_by, _me_id, _global_seed; + + RETURN result; +END +$$; + + +-- add limit and offset +CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me_limited( + _item_id int, _global_seed int, _me_id int, _limit int, _offset int, _grandchild_limit int, + _level int, _where text, _order_by text) + RETURNS jsonb + LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS +$$ +DECLARE + result jsonb; +BEGIN + IF _level < 1 THEN + RETURN '[]'::jsonb; + END IF; + + EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS ' + || 'WITH RECURSIVE base AS ( ' + || ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn ' + || ' FROM "Item" ' + || ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id ' + || ' WHERE "Item"."parentId" = $1 ' + || _order_by || ' ' + || ' LIMIT $4 ' + || ' OFFSET $5) ' + || ' UNION ALL ' + || ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn ' + || ' FROM "Item" ' + || ' JOIN base b ON "Item"."parentId" = b.id ' + || ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id ' + || ' WHERE b.level < $7 AND (b.level = 1 OR b.rn <= $6)) ' + || ') ' + || 'SELECT "Item".*, ' + || ' "Item".created_at at time zone ''UTC'' AS "createdAt", ' + || ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", ' + || ' to_jsonb("PayIn".*) || jsonb_build_object(''payInStateChangedAt'', "PayIn"."payInStateChangedAt" at time zone ''UTC'') as "payIn", ' + || ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, ' + || ' COALESCE("MeItemPayIn"."meMsats", 0) AS "meMsats", ' + || ' COALESCE("MeItemPayIn"."mePendingMsats", 0) as "mePendingMsats", ' + || ' COALESCE("MeItemPayIn"."meDontLikeMsats", 0) AS "meDontLikeMsats", ' + || ' COALESCE("MeItemPayIn"."meMcredits", 0) AS "meMcredits", ' + || ' COALESCE("MeItemPayIn"."mePendingMcredits", 0) as "mePendingMcredits", ' + || ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", ' + || ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", ' + || ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" ' + || 'FROM base "Item" ' + || 'JOIN users ON users.id = "Item"."userId" ' + || 'JOIN LATERAL ( ' + || ' SELECT "PayIn".* ' + || ' FROM "ItemPayIn" ' + || ' JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" AND "PayIn"."payInType" = ''ITEM_CREATE'' ' + || ' WHERE "ItemPayIn"."itemId" = "Item".id AND ("PayIn"."userId" = $3 OR "PayIn"."payInState" = ''PAID'') ' + || ' ORDER BY "PayIn"."created_at" DESC ' + || ' LIMIT 1 ' + || ') "PayIn" ON "PayIn".id IS NOT NULL ' + || 'LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" ' + || 'LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id ' + || 'LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".id ' + || 'LEFT JOIN hot_score_view g ON g.id = "Item".id ' + || 'LEFT JOIN LATERAL ( ' + || ' SELECT "itemId", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> ''FAILED'' AND "PayOutBolt11".id IS NOT NULL AND "PayIn"."payInType" = ''ZAP'') AS "meMsats", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> ''FAILED'' AND "PayOutBolt11".id IS NULL AND "PayIn"."payInType" = ''ZAP'') AS "meMcredits", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" = ''PENDING'' AND "PayOutBolt11".id IS NOT NULL AND "PayIn"."payInType" = ''ZAP'') AS "mePendingMsats", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" = ''PENDING'' AND "PayOutBolt11".id IS NULL AND "PayIn"."payInType" = ''ZAP'') AS "mePendingMcredits", ' + || ' sum("PayIn".mcost) FILTER (WHERE "PayIn"."payInState" <> ''FAILED'' AND "PayIn"."payInType" = ''DOWN_ZAP'') AS "meDontLikeMsats" ' + || ' FROM "ItemPayIn" ' + || ' JOIN "PayIn" ON "PayIn".id = "ItemPayIn"."payInId" ' + || ' LEFT JOIN "PayOutBolt11" ON "PayOutBolt11"."payInId" = "PayIn".id ' + || ' WHERE "ItemPayIn"."itemId" = "Item".id AND "PayIn"."userId" = $3 ' + || ' GROUP BY "ItemPayIn"."itemId" ' + || ') "MeItemPayIn" ON true ' + || 'WHERE ("Item".level = 1 OR "Item".rn <= $6 - "Item".level + 2) ' || _where || ' ' + USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by; + + EXECUTE '' + || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments ' + || 'FROM ( ' + || ' SELECT "Item".*, item_comments_zaprank_with_me_limited("Item".id, $2, $3, $4, $5, $6, $7 - 1, $8, $9) AS comments ' + || ' FROM t_item "Item" ' + || ' WHERE "Item"."parentId" = $1 ' + || _order_by + || ' ) sub' + INTO result USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by; + + RETURN result; +END +$$; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cd30c8c358..efade85571 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,80 +13,80 @@ model Snl { } model User { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - name String? @unique(map: "users.name_unique") @db.Citext - email String? @unique(map: "users.email_unique") - emailVerified DateTime? @map("email_verified") - emailHash String? @unique(map: "users.email_hash_unique") + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + name String? @unique(map: "users.name_unique") @db.Citext + email String? @unique(map: "users.email_unique") + emailVerified DateTime? @map("email_verified") + emailHash String? @unique(map: "users.email_hash_unique") image String? - msats BigInt @default(0) - freeComments Int @default(5) - freePosts Int @default(2) + msats BigInt @default(0) + freeComments Int @default(5) + freePosts Int @default(2) checkedNotesAt DateTime? foundNotesAt DateTime? - pubkey String? @unique(map: "users.pubkey_unique") - apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64) - apiKeyEnabled Boolean @default(false) - tipDefault Int @default(100) + pubkey String? @unique(map: "users.pubkey_unique") + apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64) + apiKeyEnabled Boolean @default(false) + tipDefault Int @default(100) tipRandomMin Int? tipRandomMax Int? bioId Int? inviteId String? - tipPopover Boolean @default(false) - upvotePopover Boolean @default(false) - trust Float @default(0) + tipPopover Boolean @default(false) + upvotePopover Boolean @default(false) + trust Float @default(0) lastSeenAt DateTime? - stackedMsats BigInt @default(0) - stackedMcredits BigInt @default(0) - noteAllDescendants Boolean @default(true) - noteDeposits Boolean @default(true) - noteWithdrawals Boolean @default(true) - noteEarning Boolean @default(true) - noteInvites Boolean @default(true) - noteItemSats Boolean @default(true) - noteMentions Boolean @default(true) - noteItemMentions Boolean @default(true) - noteForwardedSats Boolean @default(true) + stackedMsats BigInt @default(0) + stackedMcredits BigInt @default(0) + noteAllDescendants Boolean @default(true) + noteDeposits Boolean @default(true) + noteWithdrawals Boolean @default(true) + noteEarning Boolean @default(true) + noteInvites Boolean @default(true) + noteItemSats Boolean @default(true) + noteMentions Boolean @default(true) + noteItemMentions Boolean @default(true) + noteForwardedSats Boolean @default(true) lastCheckedJobs DateTime? - noteJobIndicator Boolean @default(true) + noteJobIndicator Boolean @default(true) photoId Int? - upvoteTrust Float @default(0) - hideInvoiceDesc Boolean @default(false) - wildWestMode Boolean @default(false) - satsFilter Int @default(10) - nsfwMode Boolean @default(false) - fiatCurrency String @default("USD") - withdrawMaxFeeDefault Int @default(10) - autoDropBolt11s Boolean @default(false) - hideFromTopUsers Boolean @default(false) - turboTipping Boolean @default(false) + upvoteTrust Float @default(0) + hideInvoiceDesc Boolean @default(false) + wildWestMode Boolean @default(false) + satsFilter Int @default(10) + nsfwMode Boolean @default(false) + fiatCurrency String @default("USD") + withdrawMaxFeeDefault Int @default(10) + autoDropBolt11s Boolean @default(false) + hideFromTopUsers Boolean @default(false) + turboTipping Boolean @default(false) zapUndos Int? - imgproxyOnly Boolean @default(false) - showImagesAndVideos Boolean @default(true) - hideWalletBalance Boolean @default(false) + imgproxyOnly Boolean @default(false) + showImagesAndVideos Boolean @default(true) + hideWalletBalance Boolean @default(false) disableFreebies Boolean? referrerId Int? nostrPubkey String? - greeterMode Boolean @default(false) - nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique") - nostrCrossposting Boolean @default(false) - slashtagId String? @unique(map: "users.slashtagId_unique") - noteCowboyHat Boolean @default(true) + greeterMode Boolean @default(false) + nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique") + nostrCrossposting Boolean @default(false) + slashtagId String? @unique(map: "users.slashtagId_unique") + noteCowboyHat Boolean @default(true) streak Int? gunStreak Int? horseStreak Int? - hasSendWallet Boolean @default(false) - hasRecvWallet Boolean @default(false) + hasSendWallet Boolean @default(false) + hasRecvWallet Boolean @default(false) subs String[] - hideCowboyHat Boolean @default(false) + hideCowboyHat Boolean @default(false) Bookmarks Bookmark[] Donation Donation[] Earn Earn[] - invites Invite[] @relation("Invites") + invites Invite[] @relation("Invites") invoices Invoice[] - items Item[] @relation("UserItems") + items Item[] @relation("UserItems") actions ItemAct[] mentions Mention[] messages Message[] @@ -95,63 +95,67 @@ model User { Streak Streak[] ThreadSubscriptions ThreadSubscription[] SubSubscriptions SubSubscription[] - Upload Upload[] @relation("Uploads") + Upload Upload[] @relation("Uploads") nostrRelays UserNostrRelay[] withdrawls Withdrawl[] - bio Item? @relation(fields: [bioId], references: [id]) - invite Invite? @relation(fields: [inviteId], references: [id]) - photo Upload? @relation(fields: [photoId], references: [id]) - referrer User? @relation("referrals", fields: [referrerId], references: [id]) - referrees User[] @relation("referrals") + bio Item? @relation(fields: [bioId], references: [id]) + invite Invite? @relation(fields: [inviteId], references: [id]) + photo Upload? @relation(fields: [photoId], references: [id]) + referrer User? @relation("referrals", fields: [referrerId], references: [id]) + referrees User[] @relation("referrals") Account Account[] Session Session[] itemForwards ItemForward[] - hideBookmarks Boolean @default(false) - hideGithub Boolean @default(true) - hideNostr Boolean @default(true) - hideTwitter Boolean @default(true) - noReferralLinks Boolean @default(false) + hideBookmarks Boolean @default(false) + hideGithub Boolean @default(true) + hideNostr Boolean @default(true) + hideTwitter Boolean @default(true) + noReferralLinks Boolean @default(false) githubId String? twitterId String? - followers UserSubscription[] @relation("follower") - followees UserSubscription[] @relation("followee") - hideWelcomeBanner Boolean @default(false) - hideWalletRecvPrompt Boolean @default(false) - diagnostics Boolean @default(false) - hideIsContributor Boolean @default(false) + followers UserSubscription[] @relation("follower") + followees UserSubscription[] @relation("followee") + hideWelcomeBanner Boolean @default(false) + hideWalletRecvPrompt Boolean @default(false) + diagnostics Boolean @default(false) + hideIsContributor Boolean @default(false) lnAddr String? autoWithdrawMaxFeePercent Float? autoWithdrawThreshold Int? autoWithdrawMaxFeeTotal Int? - mcredits BigInt @default(0) - receiveCreditsBelowSats Int @default(10) - sendCreditsBelowSats Int @default(10) - muters Mute[] @relation("muter") - muteds Mute[] @relation("muted") - ArcOut Arc[] @relation("fromUser") - ArcIn Arc[] @relation("toUser") + mcredits BigInt @default(0) + receiveCreditsBelowSats Int @default(10) + sendCreditsBelowSats Int @default(10) + muters Mute[] @relation("muter") + muteds Mute[] @relation("muted") + ArcOut Arc[] @relation("fromUser") + ArcIn Arc[] @relation("toUser") Sub Sub[] SubAct SubAct[] MuteSub MuteSub[] wallets Wallet[] - TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser") - TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser") - AncestorReplies Reply[] @relation("AncestorReplyUser") + TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser") + TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser") + AncestorReplies Reply[] @relation("AncestorReplyUser") Replies Reply[] walletLogs WalletLog[] Reminder Reminder[] PollBlindVote PollBlindVote[] ItemUserAgg ItemUserAgg[] - oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") - oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") - vaultKeyHash String @default("") + oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") + oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") + vaultKeyHash String @default("") vaultKeyHashUpdatedAt DateTime? - showPassphrase Boolean @default(true) + showPassphrase Boolean @default(true) walletsUpdatedAt DateTime? - proxyReceive Boolean @default(true) - DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") - DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") + proxyReceive Boolean @default(true) + DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") + DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") UserSubTrust UserSubTrust[] + PayIn PayIn[] + PayInBolt11 PayInBolt11[] + PayOutBolt11 PayOutBolt11[] + PayOutCustodialToken PayOutCustodialToken[] @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -222,19 +226,21 @@ model Vault { } model WalletLog { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - protocolId Int? - protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: Cascade) - level LogLevel - message String - invoiceId Int? - invoice Invoice? @relation(fields: [invoiceId], references: [id]) - withdrawalId Int? - withdrawal Withdrawl? @relation(fields: [withdrawalId], references: [id]) - context Json? @db.JsonB + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + level LogLevel + message String + invoiceId Int? + invoice Invoice? @relation(fields: [invoiceId], references: [id]) + withdrawalId Int? + withdrawal Withdrawl? @relation(fields: [withdrawalId], references: [id]) + context Json? @db.JsonB + PayOutBolt11 PayOutBolt11? @relation(fields: [payOutBolt11Id], references: [id]) + payOutBolt11Id Int? + protocolId Int? + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: Cascade) @@index([userId, createdAt]) } @@ -301,6 +307,7 @@ model UserNostrRelay { @@id([userId, nostrRelayAddr]) } +// TODO: payIn replaces the need for this model model Donation { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") @@ -398,6 +405,7 @@ model Invite { @@index([userId], map: "Invite.userId_index") } +// TODO: remove this model, it's not used model Message { id Int @id @default(autoincrement()) text String @@ -490,6 +498,7 @@ model Item { ItemUserAgg ItemUserAgg[] AutoSocialPost AutoSocialPost[] randPollOptions Boolean @default(false) + itemPayIns ItemPayIn[] @@index([uploadId]) @@index([lastZapAt]) @@ -613,6 +622,8 @@ model PollVote { pollOptionId Int invoiceId Int? invoiceActionState InvoiceActionState? + payIn PayIn? @relation(fields: [payInId], references: [id]) + payInId Int? @unique invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade) @@ -622,6 +633,7 @@ model PollVote { @@index([invoiceActionState]) } +// TODO: this can be replaced by ItemPayIn model PollBlindVote { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") @@ -677,15 +689,17 @@ model Sub { moderatedCount Int @default(0) nsfw Boolean @default(false) - parent Sub? @relation("ParentChildren", fields: [parentName], references: [name]) - children Sub[] @relation("ParentChildren") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - Item Item[] - SubAct SubAct[] - MuteSub MuteSub[] - SubSubscription SubSubscription[] - TerritoryTransfer TerritoryTransfer[] - UserSubTrust UserSubTrust[] + parent Sub? @relation("ParentChildren", fields: [parentName], references: [name]) + children Sub[] @relation("ParentChildren") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + Item Item[] + SubAct SubAct[] + MuteSub MuteSub[] + SubSubscription SubSubscription[] + TerritoryTransfer TerritoryTransfer[] + UserSubTrust UserSubTrust[] + subPayIn SubPayIn[] + SubPayOutCustodialToken SubPayOutCustodialToken[] @@index([parentName]) @@index([createdAt]) @@ -736,6 +750,7 @@ model Pin { Item Item[] } +// TODO: this is defunct, migrate to a daily referral reward model ReferralAct { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") @@ -1317,9 +1332,11 @@ model WalletProtocol { walletRecvLightningAddress WalletRecvLightningAddress? walletRecvCLNRest WalletRecvCLNRest? walletRecvLNDGRPC WalletRecvLNDGRPC? + PayOutBolt11 PayOutBolt11[] + PayInBolt11 PayInBolt11[] - @@index([walletId]) @@unique(name: "WalletProtocol_walletId_send_name_key", [walletId, send, name]) + @@index([walletId]) } model WalletSendNWC { @@ -1459,3 +1476,269 @@ model WalletRecvLNDGRPC { macaroon String cert String? } + +// payIn playground +// TODO: add constraints on all sat value fields + +enum PayInType { + BUY_CREDITS + ITEM_CREATE + ITEM_UPDATE + ZAP + DOWN_ZAP + BOOST + DONATE + POLL_VOTE + INVITE_GIFT + TERRITORY_CREATE + TERRITORY_UPDATE + TERRITORY_BILLING + TERRITORY_UNARCHIVE + PROXY_PAYMENT + REWARDS + WITHDRAWAL + AUTO_WITHDRAWAL +} + +enum PayInState { + PENDING_INVOICE_CREATION + PENDING_INVOICE_WRAP + PENDING_WITHDRAWAL + PENDING + PENDING_HELD + HELD + PAID + FAILED + FORWARDING + FORWARDED + FAILED_FORWARD + CANCELLED +} + +enum PayInFailureReason { + INVOICE_CREATION_FAILED + INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE + INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY + INVOICE_WRAPPING_FAILED_UNKNOWN + INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW + INVOICE_FORWARDING_FAILED + HELD_INVOICE_UNEXPECTED_ERROR + HELD_INVOICE_SETTLED_TOO_SLOW + WITHDRAWAL_FAILED + USER_CANCELLED + SYSTEM_CANCELLED + INVOICE_EXPIRED + EXECUTION_FAILED + UNKNOWN_FAILURE +} + +model ItemPayIn { + id Int @id @default(autoincrement()) + itemId Int + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + payInId Int @unique + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + @@unique([itemId, payInId]) + @@index([itemId]) + @@index([payInId]) +} + +model SubPayIn { + id Int @id @default(autoincrement()) + subName String @db.Citext + sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade) + payInId Int @unique + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + @@unique([subName, payInId]) + @@index([subName]) + @@index([payInId]) +} + +model PayIn { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + mcost BigInt + + payInType PayInType + payInState PayInState + payInFailureReason PayInFailureReason? // TODO: add check constraint + payInStateChangedAt DateTime? // TODO: set with a trigger? + genesisId Int? // the original payIn that was retried + successorId Int? @unique // this ensures a payIn can only be retried once + benefactorId Int? + + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + pessimisticEnv PessimisticEnv? + payInCustodialTokens PayInCustodialToken[] + payOutCustodialTokens PayOutCustodialToken[] + payOutBolt11 PayOutBolt11? + + genesis PayIn? @relation("PayInGenesis", fields: [genesisId], references: [id], onDelete: Cascade) + successor PayIn? @relation("PayInSuccessor", fields: [successorId], references: [id], onDelete: Cascade) + predecessor PayIn? @relation("PayInSuccessor") + benefactor PayIn? @relation("PayInBenefactor", fields: [benefactorId], references: [id], onDelete: Cascade) + beneficiaries PayIn[] @relation("PayInBenefactor") + itemPayIn ItemPayIn? + subPayIn SubPayIn? + progeny PayIn[] @relation("PayInGenesis") + payInBolt11 PayInBolt11? + pollVote PollVote? + + @@index([userId]) + @@index([payInType]) + @@index([successorId]) + @@index([payInStateChangedAt]) +} + +enum CustodialTokenType { + CREDITS + SATS +} + +model PayInCustodialToken { + id Int @id @default(autoincrement()) + payInId Int + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + mtokens BigInt + mtokensAfter BigInt? + custodialTokenType CustodialTokenType +} + +model PessimisticEnv { + id Int @id @default(autoincrement()) + payInId Int @unique + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + args Json? @db.JsonB + error String? + result Json? @db.JsonB +} + +enum PayOutType { + TERRITORY_REVENUE + REWARDS_POOL + ROUTING_FEE + ROUTING_FEE_REFUND + PROXY_PAYMENT + ZAP + REWARD + INVITE_GIFT + WITHDRAWAL + SYSTEM_REVENUE + BUY_CREDITS +} + +model SubPayOutCustodialToken { + id Int @id @default(autoincrement()) + subName String @db.Citext + sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade) + payOutCustodialTokenId Int @unique + payOutCustodialToken PayOutCustodialToken @relation(fields: [payOutCustodialTokenId], references: [id], onDelete: Cascade) + + @@index([subName]) +} + +model PayOutCustodialToken { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payOutType PayOutType + + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + payInId Int + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + mtokens BigInt + mtokensAfter BigInt? + custodialTokenType CustodialTokenType + + subPayOutCustodialToken SubPayOutCustodialToken? +} + +model PayInBolt11 { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payInId Int @unique + hash String @unique + preimage String? @unique + bolt11 String + expiresAt DateTime + confirmedAt DateTime? + confirmedIndex BigInt? + cancelledAt DateTime? + msatsRequested BigInt + msatsReceived BigInt? + expiryHeight Int? + acceptHeight Int? + User User? @relation(fields: [userId], references: [id]) + userId Int? + protocolId Int? + + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: SetNull) + lud18Data PayInBolt11Lud18? + nostrNote PayInBolt11NostrNote? + comment PayInBolt11Comment? + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + @@index([createdAt]) + @@index([confirmedIndex]) + @@index([confirmedAt]) + @@index([cancelledAt]) +} + +model PayOutBolt11 { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payOutType PayOutType + userId Int + hash String? + preimage String? + bolt11 String? + msats BigInt + status WithdrawlStatus? + protocolId Int? + payInId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: SetNull) + payIn PayIn @relation(fields: [payInId], references: [id]) + WalletLog WalletLog[] + + @@index([createdAt]) + @@index([userId]) + @@index([hash]) + @@index([protocolId]) + @@index([status]) +} + +model PayInBolt11Lud18 { + id Int @id @default(autoincrement()) + payInBolt11Id Int @unique + payInBolt11 PayInBolt11 @relation(fields: [payInBolt11Id], references: [id]) + + name String? + identifier String? + email String? + pubkey String? +} + +model PayInBolt11NostrNote { + id Int @id @default(autoincrement()) + payInBolt11Id Int @unique + payInBolt11 PayInBolt11 @relation(fields: [payInBolt11Id], references: [id]) + note Json +} + +model PayInBolt11Comment { + id Int @id @default(autoincrement()) + payInBolt11Id Int @unique + payInBolt11 PayInBolt11 @relation(fields: [payInBolt11Id], references: [id]) + comment String +} diff --git a/scripts/newsletter.js b/scripts/newsletter.js index f7d7b9f79e..6d286ee405 100644 --- a/scripts/newsletter.js +++ b/scripts/newsletter.js @@ -162,7 +162,7 @@ async function main () { const ama = await client.query({ query: ITEMS, - variables: { sort: 'top', when: 'custom', from, to, sub: 'ama' } + variables: { sort: 'top', when: 'custom', from, to, sub: 'booksandarticles' } }) const boosts = await client.query({ @@ -187,12 +187,12 @@ ${top.data.items.items.map((item, i) => `${i + 1}. [${item.title}](https://stacker.news/items/${item.id}) - ${abbrNum(item.sats)} sats${item.boost ? ` \\ ${abbrNum(item.boost)} boost` : ''} \\ ${item.ncomments} comments \\ [@${item.user.name}](https://stacker.news/${item.user.name})\n`).join('')} -##### Top AMAs -${ama.data.items.items.slice(0, 3).map((item, i) => +##### Top Fiction Month +${ama.data.items.items.slice(0, 10).map((item, i) => `${i + 1}. [${item.title}](https://stacker.news/items/${item.id}) - ${abbrNum(item.sats)} sats${item.boost ? ` \\ ${abbrNum(item.boost)} boost` : ''} \\ ${item.ncomments} comments \\ [@${item.user.name}](https://stacker.news/${item.user.name})\n`).join('')} -[**all of this week's AMAs**](https://stacker.news/~AMA/top/posts/week) +[**all of this week's fiction month**](https://stacker.news/~booksandarticles/top/posts/week) ##### Don't miss ${top.data.items.items.map((item, i) => diff --git a/styles/satistics_old.module.css b/styles/satistics_old.module.css new file mode 100644 index 0000000000..85a8df2bdc --- /dev/null +++ b/styles/satistics_old.module.css @@ -0,0 +1,60 @@ +.type { + text-align: left; + border-right: 2px solid var(--theme-clickToContextColor); + padding-right: 15px; + border-bottom: 2px solid var(--theme-clickToContextColor); + display: grid; + align-items: center; + margin-left: -15px; + padding-left: 15px; +} + +.sats { + text-align: right; + border-left: 2px solid var(--theme-clickToContextColor); + padding-left: 15px; + border-bottom: 2px solid var(--theme-clickToContextColor); + display: grid; + align-items: center; + margin-right: -15px; + padding-right: 15px; + font-family: monospace; +} + +.badge { + color: var(--theme-grey) !important; + background: var(--theme-clickToContextColor) !important; + vertical-align: middle; + margin-left: 0.5rem; +} + +.detail { + border-bottom: 2px solid var(--theme-clickToContextColor); + padding: .5rem; + display: grid; + align-items: center; +} + +.detail.head { + text-align: center; +} + +.sats.head { + font-family: inherit; +} + +.head { + font-weight: bold; + text-transform: uppercase; + padding-bottom: .75rem; +} + +.failed { + text-decoration: line-through; +} + +.rows { + display: grid; + grid-template-columns: max-content 1fr max-content; +} + diff --git a/wallets/client/context/hooks.js b/wallets/client/context/hooks.js index 3841cd0b0e..18e3902009 100644 --- a/wallets/client/context/hooks.js +++ b/wallets/client/context/hooks.js @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { useLazyQuery } from '@apollo/client' import { FAILED_INVOICES } from '@/fragments/invoice' import { NORMAL_POLL_INTERVAL } from '@/lib/constants' -import useInvoice from '@/components/use-invoice' +import useInvoice from '@/components/payIn/hooks/use-pay-in-helper' import { useMe } from '@/components/me' import { useWalletsQuery, useWalletPayment, useGenerateRandomKey, useSetKey, useLoadKey, useLoadOldKey, diff --git a/wallets/client/errors.js b/wallets/client/errors.js index 11a9d7beac..3dfb90347b 100644 --- a/wallets/client/errors.js +++ b/wallets/client/errors.js @@ -1,17 +1,23 @@ -export class InvoiceCanceledError extends Error { +export class InvoiceError extends Error { + constructor (invoice, message) { + super(message) + this.name = 'InvoiceError' + this.invoice = invoice + } +} + +export class InvoiceCanceledError extends InvoiceError { constructor (invoice, actionError) { - super(actionError ?? `invoice canceled: ${invoice.hash}`) + super(invoice, actionError ?? `invoice canceled: ${invoice?.hash}`) this.name = 'InvoiceCanceledError' - this.invoice = invoice this.actionError = actionError } } -export class InvoiceExpiredError extends Error { +export class InvoiceExpiredError extends InvoiceError { constructor (invoice) { - super(`invoice expired: ${invoice.hash}`) + super(invoice, `invoice expired: ${invoice.hash}`) this.name = 'InvoiceExpiredError' - this.invoice = invoice } } diff --git a/wallets/client/hooks/payment.js b/wallets/client/hooks/payment.js index fd8c359281..d7ebd7ed34 100644 --- a/wallets/client/hooks/payment.js +++ b/wallets/client/hooks/payment.js @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useSendProtocols, useWalletLoggerFactory } from '@/wallets/client/hooks' -import useInvoice from '@/components/use-invoice' +import useInvoice from '@/components/payIn/hooks/use-pay-in-helper' import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' import { AnonWalletError, WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError, diff --git a/wallets/server/receive.js b/wallets/server/receive.js index 48751cd474..252ec9a023 100644 --- a/wallets/server/receive.js +++ b/wallets/server/receive.js @@ -1,25 +1,17 @@ import { parsePaymentRequest } from 'ln-service' -import { formatMsats, formatSats, msatsToSats, toPositiveBigInt, toPositiveNumber } from '@/lib/format' -import { PAID_ACTION_TERMINAL_STATES, WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { formatMsats, formatSats, msatsToSats, toPositiveNumber } from '@/lib/format' +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { timeoutSignal, withTimeout } from '@/lib/time' -import { wrapInvoice } from '@/wallets/server/wrap' import { walletLogger } from '@/wallets/server/logger' import { protocolCreateInvoice } from '@/wallets/server/protocols' const MAX_PENDING_INVOICES_PER_WALLET = 25 -export async function * createUserInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { paymentAttempt, predecessorId, models }) { - // get the protocols in order of priority - const protocols = await getInvoiceableWallets(userId, { - paymentAttempt, - predecessorId, - models - }) - +export async function * createBolt11FromWalletProtocols (walletProtocols, { msats, description, descriptionHash, expiry = 360 }, { models }) { msats = toPositiveNumber(msats) - for (const protocol of protocols) { - const logger = walletLogger({ protocolId: protocol.id, userId, models }) + for (const protocol of walletProtocols) { + const logger = walletLogger({ protocolId: protocol.id, userId: protocol.userId, models }) try { logger.info( @@ -27,9 +19,9 @@ export async function * createUserInvoice (userId, { msats, description, descrip amount: formatMsats(msats) }) - let invoice + let bolt11 try { - invoice = await _protocolCreateInvoice( + bolt11 = await _protocolCreateInvoice( protocol, { msats, description, descriptionHash, expiry }, { models }) @@ -37,25 +29,25 @@ export async function * createUserInvoice (userId, { msats, description, descrip throw new Error('failed to create invoice: ' + err.message) } - const bolt11 = await parsePaymentRequest({ request: invoice }) + const invoice = await parsePaymentRequest({ request: bolt11 }) - logger.info(`created invoice for ${formatSats(msatsToSats(bolt11.mtokens))}`, { - bolt11: invoice + logger.info(`created invoice for ${formatSats(msatsToSats(invoice.mtokens))}`, { + bolt11 }) - if (BigInt(bolt11.mtokens) !== BigInt(msats)) { - if (BigInt(bolt11.mtokens) > BigInt(msats)) { + if (BigInt(invoice.mtokens) !== BigInt(msats)) { + if (BigInt(invoice.mtokens) > BigInt(msats)) { throw new Error('invoice invalid: amount too big') } - if (BigInt(bolt11.mtokens) === 0n) { + if (BigInt(invoice.mtokens) === 0n) { throw new Error('invoice invalid: amount is 0 msats') } - if (BigInt(msats) - BigInt(bolt11.mtokens) >= 1000n) { + if (BigInt(msats) - BigInt(invoice.mtokens) >= 1000n) { throw new Error('invoice invalid: amount too small') } } - yield { invoice, protocol, logger } + yield { bolt11, protocol, logger } } catch (err) { console.error('failed to create user invoice:', err) logger.error(err.message, { updateStatus: true }) @@ -63,113 +55,25 @@ export async function * createUserInvoice (userId, { msats, description, descrip } } -export async function createWrappedInvoice (userId, - { msats, feePercent, description, descriptionHash, expiry = 360 }, - { paymentAttempt, predecessorId, models, me, lnd }) { - // loop over all receiver wallet invoices until we successfully wrapped one - for await (const { invoice, logger, protocol } of createUserInvoice(userId, { - // this is the amount the stacker will receive, the other (feePercent)% is our fee - msats: toPositiveBigInt(msats) * (100n - feePercent) / 100n, - description, - descriptionHash, - expiry - }, { paymentAttempt, predecessorId, models })) { - let bolt11 - try { - bolt11 = invoice - const { invoice: wrappedInvoice, maxFee } = await wrapInvoice({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) - return { - invoice, - wrappedInvoice: wrappedInvoice.request, - protocol, - maxFee - } - } catch (e) { - console.error('failed to wrap invoice:', e) - logger?.warn('failed to wrap invoice: ' + e.message, { bolt11 }) - } - } - - throw new Error('no wallet to receive available') -} - -export async function getInvoiceableWallets (userId, { paymentAttempt, predecessorId, models }) { - // filter out all wallets that have already been tried by recursively following the retry chain of predecessor invoices. - // the current predecessor invoice is in state 'FAILED' and not in state 'RETRYING' because we are currently retrying it - // so it has not been updated yet. - // if predecessorId is not provided, the subquery will be empty and thus no wallets are filtered out. - return await models.$queryRaw` - SELECT - "WalletProtocol".*, - jsonb_build_object( - 'id', "users"."id", - 'hideInvoiceDesc', "users"."hideInvoiceDesc" - ) AS "user" - FROM "WalletProtocol" - JOIN "Wallet" ON "WalletProtocol"."walletId" = "Wallet"."id" - JOIN "users" ON "users"."id" = "Wallet"."userId" - WHERE - "Wallet"."userId" = ${userId} - AND "WalletProtocol"."enabled" = true - AND "WalletProtocol"."send" = false - AND "WalletProtocol"."id" NOT IN ( - WITH RECURSIVE "Retries" AS ( - -- select the current failed invoice that we are currently retrying - -- this failed invoice will be used to start the recursion - SELECT "Invoice"."id", "Invoice"."predecessorId" - FROM "Invoice" - WHERE "Invoice"."id" = ${predecessorId} AND "Invoice"."actionState" = 'FAILED' - UNION ALL - -- recursive part: use predecessorId to select the previous invoice that failed in the chain - -- until there is no more previous invoice - SELECT "Invoice"."id", "Invoice"."predecessorId" - FROM "Invoice" - JOIN "Retries" ON "Invoice"."id" = "Retries"."predecessorId" - WHERE "Invoice"."actionState" = 'RETRYING' - AND "Invoice"."paymentAttempt" = ${paymentAttempt} - ) - SELECT - "InvoiceForward"."protocolId" - FROM "Retries" - JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Retries"."id" - JOIN "Withdrawl" ON "Withdrawl".id = "InvoiceForward"."withdrawlId" - WHERE "Withdrawl"."status" IS DISTINCT FROM 'CONFIRMED' - ) - ORDER BY "Wallet"."priority" ASC, "Wallet"."id" ASC` -} - async function _protocolCreateInvoice (protocol, { msats, description, descriptionHash, expiry = 360 }, { logger, models }) { - // check for pending withdrawals - - // TODO(wallet-v2): make sure this still works as intended - const pendingWithdrawals = await models.withdrawl.count({ + // check for pending payouts + const pendingPayOutBolt11Count = await models.payOutBolt11.count({ where: { protocolId: protocol.id, - status: null - } - }) - - // and pending forwards - // TODO(wallet-v2): make sure this still works as intended - const pendingForwards = await models.invoiceForward.count({ - where: { - protocolId: protocol.id, - invoice: { - actionState: { - notIn: PAID_ACTION_TERMINAL_STATES - } + status: null, + payIn: { + payInState: { notIn: ['PAID', 'FAILED'] } } } }) - const pending = pendingWithdrawals + pendingForwards - if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) { - throw new Error(`too many pending invoices: has ${pending}, max ${MAX_PENDING_INVOICES_PER_WALLET}`) + if (pendingPayOutBolt11Count >= MAX_PENDING_INVOICES_PER_WALLET) { + throw new Error(`too many pending invoices: has ${pendingPayOutBolt11Count}, max ${MAX_PENDING_INVOICES_PER_WALLET}`) } return await withTimeout( @@ -177,7 +81,7 @@ async function _protocolCreateInvoice (protocol, { protocol, { msats, - description: protocol.user.hideInvoiceDesc ? undefined : description, + description, descriptionHash, expiry }, diff --git a/wallets/server/wrap.js b/wallets/server/wrap.js index c43b1a2121..09c965c71f 100644 --- a/wallets/server/wrap.js +++ b/wallets/server/wrap.js @@ -1,6 +1,6 @@ import { createHodlInvoice, parsePaymentRequest } from 'ln-service' -import { estimateRouteFee, getBlockHeight } from '@/api/lnd' -import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/format' +import lnd, { estimateRouteFee, getBlockHeight } from '@/api/lnd' +import { toPositiveBigInt, toPositiveNumber } from '@/lib/format' const MIN_OUTGOING_MSATS = BigInt(700) // the minimum msats we'll allow for the outgoing invoice const MAX_OUTGOING_MSATS = BigInt(700_000_000) // the maximum msats we'll allow for the outgoing invoice @@ -9,28 +9,23 @@ const INCOMING_EXPIRATION_BUFFER_MSECS = 120_000 // the buffer enforce for the i const MAX_OUTGOING_CLTV_DELTA = 1000 // the maximum cltv delta we'll allow for the outgoing invoice export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request -const MAX_FEE_ESTIMATE_PERCENT = 3n // the maximum fee relative to outgoing we'll allow for the fee estimate /* The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice. @param args {object} { - bolt11: {string} the bolt11 invoice to wrap - feePercent: {bigint} the fee percent to use for the incoming invoice - } - @param options {object} { msats: {bigint} the amount in msats to use for the incoming invoice + bolt11: {string} the bolt11 invoice to wrap + maxRoutingFeeMsats: {bigint} the maximum routing fee in msats to use for the incoming invoice, + hideInvoiceDesc: {boolean} whether to hide the invoice description description: {string} the description to use for the incoming invoice descriptionHash: {string} the description hash to use for the incoming invoice } - @returns { - invoice: the wrapped incoming invoice, - maxFee: number - } + @returns bolt11 {string} the wrapped incoming invoice */ -export async function wrapInvoice ({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) { +export async function wrapBolt11 ({ msats, bolt11, maxRoutingFeeMsats, hideInvoiceDesc, description }) { try { - console.group('wrapInvoice', description) + console.group('wrapInvoice', description, 'msats', msats, 'maxRoutingFeeMsats', maxRoutingFeeMsats) // create a new object to hold the wrapped invoice values const wrapped = {} @@ -44,10 +39,9 @@ export async function wrapInvoice ({ bolt11, feePercent }, { msats, description, console.log('invoice', inv.id, inv.mtokens, inv.expires_at, inv.cltv_delta, inv.destination) - // validate fee percent - if (feePercent) { - // assert the fee percent is in the range 0-100 - feePercent = toBigInt(feePercent, 0n, 100n) + // validate fee + if (maxRoutingFeeMsats) { + maxRoutingFeeMsats = toPositiveBigInt(maxRoutingFeeMsats) } else { throw new Error('Fee percent is missing') } @@ -68,10 +62,8 @@ export async function wrapInvoice ({ bolt11, feePercent }, { msats, description, // validate incoming amount if (msats) { msats = toPositiveBigInt(msats) - // outgoing amount should be smaller than the incoming amount - // by a factor of exactly 100n / (100n - feePercent) - const incomingMsats = outgoingMsat * 100n / (100n - feePercent) - if (incomingMsats > msats) { + // outgoing amount + routing fee should be smaller than the incoming amount + if (outgoingMsat + maxRoutingFeeMsats > msats) { throw new Error('Sybil fee is too low') } } else { @@ -113,27 +105,17 @@ export async function wrapInvoice ({ bolt11, feePercent }, { msats, description, } // validate the description - if (description && descriptionHash) { - throw new Error('Only one of description or descriptionHash is allowed') - } else if (description) { + if (inv.description_hash) { + // use the invoice description hash in case this is an lnurlp invoice + wrapped.description_hash = inv.description_hash + } else if (description && !hideInvoiceDesc) { // use our wrapped description wrapped.description = description - } else if (descriptionHash) { - // use our wrapped description hash - wrapped.description_hash = descriptionHash - } else if (inv.description_hash) { - // use the invoice description hash - wrapped.description_hash = inv.description_hash - } else { + } else if (!hideInvoiceDesc) { // use the invoice description wrapped.description = inv.description } - if (me?.hideInvoiceDesc) { - wrapped.description = undefined - wrapped.description_hash = undefined - } - // validate the expiration if (new Date(inv.expires_at) < new Date(Date.now() + INCOMING_EXPIRATION_BUFFER_MSECS)) { throw new Error('Invoice expiration is too soon') @@ -179,19 +161,14 @@ export async function wrapInvoice ({ bolt11, feePercent }, { msats, description, // validate the fee budget const minEstFees = toPositiveNumber(routingFeeMsat) - const outgoingMaxFeeMsat = Math.ceil(toPositiveNumber(msats * MAX_FEE_ESTIMATE_PERCENT) / 100) - if (minEstFees > outgoingMaxFeeMsat) { - throw new Error('Estimated fees are too high (' + minEstFees + ' > ' + outgoingMaxFeeMsat + ')') + if (minEstFees > maxRoutingFeeMsats) { + throw new Error('Estimated fees are too high (' + minEstFees + ' > ' + maxRoutingFeeMsats + ')') } // calculate the incoming invoice amount, without fees wrapped.mtokens = String(msats) - console.log('outgoingMaxFeeMsat', outgoingMaxFeeMsat, 'wrapped', wrapped) - return { - invoice: await createHodlInvoice({ lnd, ...wrapped }), - maxFee: outgoingMaxFeeMsat - } + return (await createHodlInvoice({ lnd, ...wrapped })).request } finally { console.groupEnd() } diff --git a/worker/autoDropBolt11.js b/worker/autoDropBolt11.js index 43248736a2..7596514e18 100644 --- a/worker/autoDropBolt11.js +++ b/worker/autoDropBolt11.js @@ -1,43 +1,42 @@ import { deletePayment } from 'ln-service' import { INVOICE_RETENTION_DAYS } from '@/lib/constants' +import { Prisma } from '@prisma/client' -export async function autoDropBolt11s ({ models, lnd }) { +// TODO: test this +export async function dropBolt11 ({ userId, hash } = {}, { models, lnd }) { const retention = `${INVOICE_RETENTION_DAYS} days` // This query will update the withdrawls and return what the hash and bol11 values were before the update - const invoices = await models.$queryRaw` + const payOutBolt11s = await models.$queryRaw` WITH to_be_updated AS ( SELECT id, hash, bolt11 - FROM "Withdrawl" - WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s") + FROM "PayOutBolt11" + WHERE "userId" ${userId ? Prisma.sql`= ${userId}` : Prisma.sql`(SELECT id FROM users WHERE "autoDropBolt11s")`} AND now() > created_at + ${retention}::INTERVAL - AND hash IS NOT NULL + AND hash ${hash ? Prisma.sql`= ${hash}` : Prisma.sql`IS NOT NULL`} AND status IS NOT NULL ), updated_rows AS ( - UPDATE "Withdrawl" + UPDATE "PayOutBolt11" SET hash = NULL, bolt11 = NULL, preimage = NULL FROM to_be_updated - WHERE "Withdrawl".id = to_be_updated.id) + WHERE "PayOutBolt11".id = to_be_updated.id) SELECT * FROM to_be_updated;` - if (invoices.length > 0) { - for (const invoice of invoices) { + if (payOutBolt11s.length > 0) { + for (const payOutBolt11 of payOutBolt11s) { try { - await deletePayment({ id: invoice.hash, lnd }) + await deletePayment({ id: payOutBolt11.hash, lnd }) } catch (error) { - console.error(`Error removing invoice with hash ${invoice.hash}:`, error) - await models.withdrawl.update({ - where: { id: invoice.id }, - data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage } + console.error(`Error removing invoice with hash ${payOutBolt11.hash}:`, error) + await models.payOutBolt11.update({ + where: { id: payOutBolt11.id }, + data: { hash: payOutBolt11.hash, bolt11: payOutBolt11.bolt11, preimage: payOutBolt11.preimage } }) } } } +} - await models.$queryRaw` - UPDATE "DirectPayment" - SET hash = NULL, bolt11 = NULL, preimage = NULL - WHERE "receiverId" IN (SELECT id FROM users WHERE "autoDropBolt11s") - AND now() > created_at + ${retention}::INTERVAL - AND hash IS NOT NULL` +export async function autoDropBolt11s ({ models, lnd }) { + await dropBolt11(undefined, { models, lnd }) } diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index a87c7a9d6b..2468792d89 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -1,6 +1,5 @@ -import { msatsSatsFloor, msatsToSats, satsToMsats } from '@/lib/format' -import { createWithdrawal } from '@/api/resolvers/wallet' -import { createUserInvoice } from '@/wallets/server' +import { msatsSatsFloor, satsToMsats } from '@/lib/format' +import pay from '@/api/payIn' export async function autoWithdraw ({ data: { id }, models, lnd }) { const user = await models.user.findUnique({ where: { id } }) @@ -32,30 +31,14 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { const [pendingOrFailed] = await models.$queryRaw` SELECT EXISTS( SELECT * - FROM "Withdrawl" + FROM "PayOutBolt11" WHERE "userId" = ${id} - AND "autoWithdraw" AND status IS DISTINCT FROM 'CONFIRMED' AND now() < created_at + interval '1 hour' - AND "msatsFeePaying" >= ${maxFeeMsats} + AND "msats" >= ${msats} )` if (pendingOrFailed.exists) return - for await (const { invoice, protocol, logger } of createUserInvoice(id, { - msats, - description: 'SN: autowithdrawal', - expiry: 360 - }, { models })) { - try { - return await createWithdrawal(null, - { invoice, maxFee: msatsToSats(maxFeeMsats) }, - { me: { id }, models, lnd, protocol, logger }) - } catch (err) { - console.error('failed to create autowithdrawal:', err) - logger?.warn('incoming payment failed: ' + err.message, { bolt11: invoice }) - } - } - - throw new Error('no wallet to receive available') + await pay('AUTO_WITHDRAWAL', { msats, maxFeeMsats }, { models, me: { id: user.id } }) } diff --git a/worker/earn.js b/worker/earn.js index 7b856196c2..a505b34196 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -1,7 +1,7 @@ import { notifyEarner } from '@/lib/webPush' import createPrisma from '@/lib/create-prisma' -import { PAID_ACTION_PAYMENT_METHODS, SN_NO_REWARDS_IDS, USER_ID } from '@/lib/constants' -import performPaidAction from '@/api/paidAction' +import { SN_NO_REWARDS_IDS, USER_ID } from '@/lib/constants' +import pay from '@/api/payIn' const TOTAL_UPPER_BOUND_MSATS = 1_000_000_000 @@ -192,12 +192,10 @@ function earnStmts (data, { models }) { const DAILY_STIMULUS_SATS = 25_000 export async function earnRefill ({ models, lnd }) { - return await performPaidAction('DONATE', + return await pay('DONATE', { sats: DAILY_STIMULUS_SATS }, { models, - me: { id: USER_ID.sn }, - lnd, - forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT + me: { id: USER_ID.sn } }) } diff --git a/worker/expireBoost.js b/worker/expireBoost.js index e46898b2a7..96be2f7bb9 100644 --- a/worker/expireBoost.js +++ b/worker/expireBoost.js @@ -9,12 +9,13 @@ export async function expireBoost ({ data: { id }, models }) { [ models.$executeRaw` WITH boost AS ( - SELECT sum(msats) FILTER (WHERE created_at <= now() - interval '30 days') as old_msats, - sum(msats) FILTER (WHERE created_at > now() - interval '30 days') as cur_msats - FROM "ItemAct" - WHERE act = 'BOOST' + SELECT sum("mcost") FILTER (WHERE "PayIn"."payInStateChangedAt" <= now() - interval '30 days') as old_msats, + sum("mcost") FILTER (WHERE "PayIn"."payInStateChangedAt" > now() - interval '30 days') as cur_msats + FROM "PayIn" + JOIN "ItemPayIn" ON "ItemPayIn"."payInId" = "PayIn"."id" + WHERE "payInType" = 'BOOST' AND "itemId" = ${Number(id)}::INTEGER - AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID') + AND "payInState" = 'PAID' ) UPDATE "Item" SET boost = COALESCE(boost.cur_msats, 0) / 1000, "oldBoost" = COALESCE(boost.old_msats, 0) / 1000 diff --git a/worker/index.js b/worker/index.js index 20848e23c8..59904da498 100644 --- a/worker/index.js +++ b/worker/index.js @@ -3,9 +3,12 @@ import './loadenv' import PgBoss from 'pg-boss' import createPrisma from '@/lib/create-prisma' import { - checkInvoice, checkPendingDeposits, checkPendingWithdrawals, - checkWithdrawal, finalizeHodlInvoice, subscribeToWallet -} from './wallet' + subscribeToBolt11s, + checkPendingPayInBolt11s, + checkPendingPayOutBolt11s, + checkPayInBolt11, + checkPayOutBolt11 +} from './payIn' import { repin } from './repin' import { trust } from './trust' import { earn, earnRefill } from './earn' @@ -30,6 +33,10 @@ import { paidActionFailedForward, paidActionHeld, paidActionFailed, paidActionCanceling } from './paidAction' +import { + payInFailedForward, payInForwarded, payInForwarding, + payInHeld, payInCancel, payInFailed, payInPaid, payInWithdrawalPaid, payInWithdrawalFailed +} from '@/api/payIn/transitions' import { thisDay } from './thisDay' import { isServiceEnabled } from '@/lib/sndev' import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts' @@ -40,6 +47,7 @@ import { postToSocial } from './socialPoster' // WebSocket polyfill import ws from 'isomorphic-ws' + if (typeof WebSocket === 'undefined') { global.WebSocket = ws } @@ -94,14 +102,14 @@ async function work () { await boss.start() if (isServiceEnabled('payments')) { - await subscribeToWallet(args) - await boss.work('finalizeHodlInvoice', jobWrapper(finalizeHodlInvoice)) - await boss.work('checkPendingDeposits', jobWrapper(checkPendingDeposits)) - await boss.work('checkPendingWithdrawals', jobWrapper(checkPendingWithdrawals)) await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s)) await boss.work('autoWithdraw', jobWrapper(autoWithdraw)) - await boss.work('checkInvoice', jobWrapper(checkInvoice)) - await boss.work('checkWithdrawal', jobWrapper(checkWithdrawal)) + // TODO: most of these need to be migrated to payIn jobs + // including any existing jobs or recurring, scheduled jobs + await boss.work('checkPendingPayInBolt11s', jobWrapper(checkPendingPayInBolt11s)) + await boss.work('checkPendingPayOutBolt11s', jobWrapper(checkPendingPayOutBolt11s)) + await boss.work('checkPayInBolt11', jobWrapper(checkPayInBolt11)) + await boss.work('checkPayOutBolt11', jobWrapper(checkPayOutBolt11)) // paidAction jobs await boss.work('paidActionForwarding', jobWrapper(paidActionForwarding)) await boss.work('paidActionForwarded', jobWrapper(paidActionForwarded)) @@ -113,6 +121,19 @@ async function work () { // payingAction jobs await boss.work('payingActionFailed', jobWrapper(payingActionFailed)) await boss.work('payingActionConfirmed', jobWrapper(payingActionConfirmed)) + + // payIn jobs + await subscribeToBolt11s(args) + await boss.work('payInForwarding', jobWrapper(payInForwarding)) + await boss.work('payInForwarded', jobWrapper(payInForwarded)) + await boss.work('payInFailedForward', jobWrapper(payInFailedForward)) + await boss.work('payInHeld', jobWrapper(payInHeld)) + await boss.work('payInCancel', jobWrapper(payInCancel)) + await boss.work('payInFailed', jobWrapper(payInFailed)) + await boss.work('payInPaid', jobWrapper(payInPaid)) + await boss.work('payInCancel', jobWrapper(payInCancel)) + await boss.work('payInWithdrawalPaid', jobWrapper(payInWithdrawalPaid)) + await boss.work('payInWithdrawalFailed', jobWrapper(payInWithdrawalFailed)) } if (isServiceEnabled('search')) { await boss.work('indexItem', jobWrapper(indexItem)) diff --git a/worker/payIn.js b/worker/payIn.js new file mode 100644 index 0000000000..b956416a3d --- /dev/null +++ b/worker/payIn.js @@ -0,0 +1,271 @@ +import { + getInvoice, + subscribeToInvoices, subscribeToPayments, subscribeToInvoice +} from 'ln-service' +import { getPaymentOrNotSent } from '@/api/lnd' +import { sleep } from '@/lib/time' +import retry from 'async-retry' +import { + payInWithdrawalPaid, payInWithdrawalFailed, payInPaid, payInForwarding, payInForwarded, payInFailedForward, payInHeld, payInFailed, + PAY_IN_TERMINAL_STATES +} from '@/api/payIn/transitions' +import { isWithdrawal } from '@/api/payIn/lib/is' + +export async function subscribeToBolt11s (args) { + await subscribeToPayInBolt11s(args) + await subscribeToPayOutBolt11s(args) +} + +// lnd subscriptions can fail, so they need to be retried +function subscribeForever (subscribe) { + retry(async bail => { + let sub + try { + return await new Promise((resolve, reject) => { + sub = subscribe(resolve, reject) + if (!sub) { + return bail(new Error('function passed to subscribeForever must return a subscription object or promise')) + } + if (sub.then) { + // sub is promise + sub.then(resolved => { + sub = resolved + sub.on('error', reject) + }) + } else { + sub.on('error', reject) + } + }) + } catch (error) { + console.error('error subscribing', error) + throw new Error('error subscribing - trying again') + } finally { + sub?.removeAllListeners() + } + }, + // retry every .1-10 seconds forever + { forever: true, minTimeout: 100, maxTimeout: 10000, onRetry: e => console.error(e.message) }) +} + +const logEvent = (name, args) => console.log(`event ${name} triggered with args`, args) +const logEventError = (name, error) => console.error(`error running ${name}`, error) + +async function subscribeToPayInBolt11s (args) { + const { models, lnd } = args + + subscribeForever(async () => { + const [lastConfirmed] = await models.$queryRaw` + SELECT "confirmedIndex" + FROM "PayInBolt11" + ORDER BY "confirmedIndex" DESC NULLS LAST + LIMIT 1` + const sub = subscribeToInvoices({ lnd, confirmed_after: lastConfirmed?.confirmedIndex }) + + sub.on('invoice_updated', async (inv) => { + try { + logEvent('invoice_updated', inv) + if (inv.secret) { + // subscribeToInvoices only returns when added or settled + await checkPayInBolt11({ data: { hash: inv.id, invoice: inv }, ...args }) + } else { + // this is a HODL invoice. We need to use SubscribeToInvoice which has is_held transitions + // and is_canceled transitions https://api.lightning.community/api/lnd/invoices/subscribe-single-invoice + // SubscribeToInvoices is only for invoice creation and settlement transitions + // https://api.lightning.community/api/lnd/lightning/subscribe-invoices + subscribeToHodlInvoice({ hash: inv.id, ...args }) + } + } catch (error) { + logEventError('invoice_updated', error) + } + }) + + return sub + }) + + // check pending deposits as a redundancy in case we failed to rehcord + // an invoice_updated event + await checkPendingPayInBolt11s(args) +} + +function subscribeToHodlInvoice (args) { + const { lnd, hash } = args + + subscribeForever((resolve, reject) => { + const sub = subscribeToInvoice({ id: hash, lnd }) + + sub.on('invoice_updated', async (inv) => { + logEvent('hodl_invoice_updated', inv) + try { + await checkPayInBolt11({ data: { hash: inv.id, invoice: inv }, ...args }) + // after settle or confirm we can stop listening for updates + if (inv.is_confirmed || inv.is_canceled) { + resolve() + } + } catch (error) { + logEventError('hodl_invoice_updated', error) + reject(error) + } + }) + + return sub + }) +} + +// if we already have the invoice from a subscription event or previous call, +// we can skip a getInvoice call +export async function checkPayInBolt11 ({ data: { hash, invoice }, boss, models, lnd }) { + const inv = invoice ?? await getInvoice({ id: hash, lnd }) + + console.log('inv', inv.id, 'is_confirmed', inv.is_confirmed, 'is_held', inv.is_held, 'is_canceled', inv.is_canceled) + + // invoice could be created by LND but wasn't inserted into the database yet + // this is expected and the function will be called again with the updates + const payIn = await models.payIn.findFirst({ + where: { payInBolt11: { hash } }, + include: { + payInBolt11: true, + payOutBolt11: true + } + }) + if (!payIn) { + console.log('invoice not found in database', hash) + return + } + + if (inv.is_confirmed) { + return await payInPaid({ data: { payInId: payIn.id, invoice: inv }, models, lnd, boss }) + } + + if (inv.is_held) { + if (payIn.payOutBolt11) { + if (payIn.payInState === 'PENDING_HELD') { + return await payInForwarding({ data: { payInId: payIn.id, invoice: inv }, models, lnd, boss }) + } + // transitions after held are dependent on the withdrawl status + return await checkPayOutBolt11({ data: { hash, invoice: inv }, models, lnd, boss }) + } + return await payInHeld({ data: { payInId: payIn.id, invoice: inv }, models, lnd, boss }) + } + + if (inv.is_canceled) { + return await payInFailed({ data: { payInId: payIn.id, invoice: inv }, models, lnd, boss }) + } +} + +async function subscribeToPayOutBolt11s (args) { + const { lnd } = args + + // https://www.npmjs.com/package/ln-service#subscribetopayments + subscribeForever(() => { + const sub = subscribeToPayments({ lnd }) + + sub.on('confirmed', async (payment) => { + logEvent('confirmed', payment) + try { + // see https://github.com/alexbosworth/lightning/blob/ddf1f214ebddf62e9e19fd32a57fbeeba713340d/lnd_methods/offchain/subscribe_to_payments.js + const withdrawal = { payment, is_confirmed: true } + await checkPayOutBolt11({ data: { hash: payment.id, withdrawal }, ...args }) + } catch (error) { + logEventError('confirmed', error) + } + }) + + sub.on('failed', async (payment) => { + logEvent('failed', payment) + try { + // see https://github.com/alexbosworth/lightning/blob/ddf1f214ebddf62e9e19fd32a57fbeeba713340d/lnd_methods/offchain/subscribe_to_payments.js + const withdrawal = { failed: payment, is_failed: true } + await checkPayOutBolt11({ data: { hash: payment.id, withdrawal }, ...args }) + } catch (error) { + logEventError('failed', error) + } + }) + + return sub + }) + + // check pending withdrawals since they might have been paid while worker was down + await checkPendingPayOutBolt11s(args) +} + +// if we already have the payment from a subscription event or previous call, +// we can skip a getPayment call +export async function checkPayOutBolt11 ({ data: { hash, withdrawal, invoice }, boss, models, lnd }) { + // get the withdrawl if pending or it's an invoiceForward + const payIn = await models.payIn.findFirst({ + where: { + payOutBolt11: { hash }, + payInState: { notIn: PAY_IN_TERMINAL_STATES } + }, + include: { + payOutBolt11: true + } + }) + + // nothing to do if the withdrawl is already recorded and it isn't an invoiceForward + if (!payIn) return + + // TODO: I'm not sure notSent is accurate given that payOutBolt11 is created when the payIn is created + const wdrwl = withdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt: payIn.payOutBolt11.createdAt }) + + console.log('wdrwl', hash, 'is_confirmed', wdrwl?.is_confirmed, 'is_failed', wdrwl?.is_failed, 'notSent', wdrwl?.notSent) + + if (wdrwl?.is_confirmed) { + if (payIn.payInState === 'FORWARDING') { + return await payInForwarded({ data: { payInId: payIn.id, withdrawal: wdrwl, invoice }, models, lnd, boss }) + } + + return await payInWithdrawalPaid({ data: { payInId: payIn.id, withdrawal: wdrwl }, models, lnd, boss }) + } else if (wdrwl?.is_failed || wdrwl?.notSent) { + if (isWithdrawal(payIn)) { + return await payInWithdrawalFailed({ data: { payInId: payIn.id, withdrawal: wdrwl }, models, lnd, boss }) + } + + // TODO: if can properly handle afterBegin failures, this can be removed + if (payIn.payInState === 'PENDING_INVOICE_WRAP') { + return await payInFailed({ data: { payInId: payIn.id, withdrawal: wdrwl, invoice }, models, lnd, boss }) + } + + return await payInFailedForward({ data: { payInId: payIn.id, withdrawal: wdrwl, invoice }, models, lnd, boss }) + } +} + +export async function checkPendingPayInBolt11s (args) { + const { models } = args + const pendingPayIns = await models.payIn.findMany({ + where: { + payInState: { notIn: PAY_IN_TERMINAL_STATES }, + payInBolt11: { isNot: null } + }, + include: { payInBolt11: true } + }) + + for (const payIn of pendingPayIns) { + try { + await checkPayInBolt11({ ...args, data: { hash: payIn.payInBolt11.hash } }) + await sleep(10) + } catch (err) { + console.error('error checking invoice', payIn.payInBolt11.hash, err) + } + } +} + +export async function checkPendingPayOutBolt11s (args) { + const { models } = args + const pendingPayOuts = await models.payIn.findMany({ + where: { + payInState: { notIn: PAY_IN_TERMINAL_STATES }, + payOutBolt11: { isNot: null } + }, + include: { payOutBolt11: true } + }) + + for (const payIn of pendingPayOuts) { + try { + await checkPayOutBolt11({ ...args, data: { hash: payIn.payOutBolt11.hash } }) + await sleep(10) + } catch (err) { + console.error('error checking withdrawal', payIn.payOutBolt11.hash, err) + } + } +} diff --git a/worker/streak.js b/worker/streak.js index 6b72bd7c6a..a7f2e568f3 100644 --- a/worker/streak.js +++ b/worker/streak.js @@ -91,29 +91,13 @@ function getStreakQuery (type, userId) { : Prisma.sql`(now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date` return Prisma.sql` - SELECT "userId" - FROM - ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent - FROM "ItemAct" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} - AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') - ${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty} - GROUP BY "userId") - UNION ALL - (SELECT "userId", sats as sats_spent - FROM "Donation" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} - ${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty} - ) - UNION ALL - (SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent - FROM "SubAct" - WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} - ${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty} - AND "type" = 'BILLING' - GROUP BY "userId")) spending - GROUP BY "userId" - HAVING sum(sats_spent) >= ${COWBOY_HAT_STREAK_THRESHOLD}` + SELECT "PayIn"."userId" + FROM "PayIn" + WHERE "PayIn"."payInState" = 'PAID' + AND ("PayIn"."payInStateChangedAt" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} + ${userId ? Prisma.sql`AND "PayIn"."userId" = ${userId}` : Prisma.empty} + GROUP BY "PayIn"."userId" + HAVING sum("PayIn"."mcost") / 1000.0 >= ${COWBOY_HAT_STREAK_THRESHOLD}` } function isStreakActive (type, user) { diff --git a/worker/territory.js b/worker/territory.js index 3aa386f96d..b22331c2eb 100644 --- a/worker/territory.js +++ b/worker/territory.js @@ -1,6 +1,4 @@ -import lnd from '@/api/lnd' -import performPaidAction from '@/api/paidAction' -import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import pay from '@/api/payIn' import { nextBillingWithGrace } from '@/lib/territory' import { datePivot } from '@/lib/time' @@ -35,12 +33,10 @@ export async function territoryBilling ({ data: { subName }, boss, models }) { } try { - const { result } = await performPaidAction('TERRITORY_BILLING', + const { result } = await pay('TERRITORY_BILLING', { name: subName }, { models, - me: sub.user, - lnd, - forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT + me: sub.user }) if (!result) { throw new Error('not enough fee credits to auto-renew territory') @@ -50,44 +46,3 @@ export async function territoryBilling ({ data: { subName }, boss, models }) { await territoryStatusUpdate() } } - -export async function territoryRevenue ({ models }) { - // this is safe nonserializable because it only acts on old data that won't - // be affected by concurrent updates ... and the update takes a lock on the - // users table - await models.$executeRaw` - WITH revenue AS ( - SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId" - FROM ( - SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * (1 - (COALESCE("Sub"."rewardsPct", 100) * 0.01)) as msats, - "Sub"."name" as "subName", "Sub"."userId" as "userId" - FROM "ItemAct" - JOIN "Item" ON "Item"."id" = "ItemAct"."itemId" - LEFT JOIN "Item" root ON "Item"."rootId" = root.id - JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName") - LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id - WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() AT TIME ZONE 'America/Chicago' - interval '1 day')) - AND "ItemAct".act <> 'TIP' - AND "Sub".status <> 'STOPPED' - AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') - ) subquery - GROUP BY "subName", "userId" - ), - "SubActResult" AS ( - INSERT INTO "SubAct" (msats, "subName", "userId", type) - SELECT revenue, "subName", "userId", 'REVENUE' - FROM revenue - WHERE revenue > 1000 - RETURNING * - ), - "SubActResultTotal" AS ( - SELECT coalesce(sum(msats), 0) as total_msats, "userId" - FROM "SubActResult" - GROUP BY "userId" - ) - UPDATE users - SET msats = users.msats + "SubActResultTotal".total_msats, - "stackedMsats" = users."stackedMsats" + "SubActResultTotal".total_msats - FROM "SubActResultTotal" - WHERE users.id = "SubActResultTotal"."userId"` -} diff --git a/worker/trust.js b/worker/trust.js index 6387f1dfb0..ee67745d91 100644 --- a/worker/trust.js +++ b/worker/trust.js @@ -173,25 +173,26 @@ async function getGraph (models, subName, postTrust = true, seeds = GLOBAL_SEEDS 'trust', CASE WHEN total_trust > 0 THEN trust / total_trust::float ELSE 0 END)) AS hops FROM ( WITH user_votes AS ( - SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, max("ItemAct".created_at) AS act_at, - users.created_at AS user_at, "ItemAct".act = 'DONT_LIKE_THIS' AS against, - count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count, - sum("ItemAct".msats) as user_msats - FROM "ItemAct" - JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS') - AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId" + SELECT "PayIn"."userId" AS user_id, users.name AS name, "ItemPayIn"."itemId" AS item_id, max("PayIn"."payInStateChangedAt") AS act_at, + users.created_at AS user_at, "PayIn"."payInType" = 'DOWN_ZAP' AS against, + count(*) OVER (partition by "PayIn"."userId") AS user_vote_count, + sum("PayIn"."mcost") as user_msats + FROM "PayIn" + JOIN "ItemPayIn" ON "ItemPayIn"."payInId" = "PayIn"."id" + JOIN "Item" ON "Item".id = "ItemPayIn"."itemId" AND "PayIn"."payInType" IN ('ZAP', 'DOWN_ZAP') + AND "PayIn"."payInState" = 'PAID' + AND NOT "Item".bio AND "Item"."userId" <> "PayIn"."userId" AND ${postTrust ? Prisma.sql`"Item"."parentId" IS NULL AND "Item"."subName" = ${subName}::TEXT` : Prisma.sql` "Item"."parentId" IS NOT NULL JOIN "Item" root ON "Item"."rootId" = root.id AND root."subName" = ${subName}::TEXT` } - JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon} - WHERE ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') + JOIN users ON "PayIn"."userId" = users.id AND users.id <> ${USER_ID.anon} GROUP BY user_id, users.name, item_id, user_at, against HAVING CASE WHEN - "ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN} - ELSE sum("ItemAct".msats) > ${MSAT_MIN} END + "PayIn"."payInType" = 'DOWN_ZAP' THEN sum("PayIn"."mcost") > ${AGAINST_MSAT_MIN} + ELSE sum("PayIn"."mcost") > ${MSAT_MIN} END ), user_pair AS ( SELECT a.user_id AS a_id, b.user_id AS b_id, diff --git a/worker/weeklyPosts.js b/worker/weeklyPosts.js index 3310254ce3..274463ba8c 100644 --- a/worker/weeklyPosts.js +++ b/worker/weeklyPosts.js @@ -1,16 +1,14 @@ -import performPaidAction from '@/api/paidAction' -import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import pay from '@/api/payIn' +import { 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', + return await pay('ITEM_CREATE', { subName: 'meta', ...item, userId: USER_ID.sn, apiKey: true }, { models, - me: { id: USER_ID.sn }, - lnd, - forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT + me: { id: USER_ID.sn } }) } @@ -52,12 +50,10 @@ export async function payWeeklyPostBounty ({ data: { id }, models, apollo, lnd } throw new Error('No winner') } - await performPaidAction('ZAP', + await pay('ZAP', { id: winner.id, sats: item.bounty }, { models, - me: { id: USER_ID.sn }, - lnd, - forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT + me: { id: USER_ID.sn } }) }