From 7e932001665c3f5268b1e3d64bb7403f71021b7c Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 8 Apr 2025 18:26:28 -0500 Subject: [PATCH 01/10] basic wip schema additions --- prisma/schema.prisma | 99 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca2c7c276..a2e976391 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -152,6 +152,7 @@ model User { DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") UserSubTrust UserSubTrust[] + PayIn PayIn[] @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -1297,3 +1298,101 @@ enum LogLevel { ERROR SUCCESS } + +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 +} + +enum PayInState { + PENDING + PENDING_HELD + HELD + PAID + FAILED + FORWARDING + FORWARDED + FAILED_FORWARD + CANCELING +} + +model PayIn { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + cost Int + + payInType PayInType + payInState PayInState + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + mcreditsAfter BigInt + msatsAfter BigInt + + PayMethod PayMethod[] + PessimisticEnv PessimisticEnv[] + PayOut PayOut[] + + @@index([userId]) + @@index([payInType]) +} + +enum PayInMethodType { + COWBOY_CREDITS + REWARD_SATS +} + +model PayMethod { + id Int @id @default(autoincrement()) + payId Int + payIn PayIn @relation(fields: [payId], references: [id], onDelete: Cascade) + msats BigInt + payMethodType PayInMethodType +} + +model PessimisticEnv { + id Int @id @default(autoincrement()) + payInId Int + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + args Json? @db.JsonB + result Json? @db.JsonB + error String? +} + +enum PayOutType { + TERRITORY_REVENUE + REWARDS_POOL + PROXY_PAYMENT_RECEIVE + ZAP_RECEIVE + REWARDS_RECEIVE + INVITE_GIFT_RECEIVE +} + +model PayOut { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + userId Int + payInId Int + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + msats BigInt + payOutType PayOutType + + msatsAfter BigInt + mcreditsAfter BigInt +} From 366c069a42762626bd36ef1cedbef90ae5ba7115 Mon Sep 17 00:00:00 2001 From: k00b Date: Sun, 13 Apr 2025 11:13:24 -0500 Subject: [PATCH 02/10] WIP execution logic --- api/pay/README.md | 371 ++++++++++++++++++++++++++++++++++ api/pay/boost.js | 82 ++++++++ api/pay/buyCredits.js | 32 +++ api/pay/donate.js | 29 +++ api/pay/downZap.js | 100 +++++++++ api/pay/index.js | 278 +++++++++++++++++++++++++ api/pay/inviteGift.js | 60 ++++++ api/pay/itemCreate.js | 309 ++++++++++++++++++++++++++++ api/pay/itemUpdate.js | 183 +++++++++++++++++ api/pay/lib/assert.js | 56 +++++ api/pay/lib/item.js | 89 ++++++++ api/pay/lib/territory.js | 27 +++ api/pay/pollVote.js | 70 +++++++ api/pay/receive.js | 80 ++++++++ api/pay/territoryBilling.js | 73 +++++++ api/pay/territoryCreate.js | 56 +++++ api/pay/territoryUnarchive.js | 90 +++++++++ api/pay/territoryUpdate.js | 83 ++++++++ api/pay/zap.js | 245 ++++++++++++++++++++++ prisma/schema.prisma | 78 ++++--- 20 files changed, 2365 insertions(+), 26 deletions(-) create mode 100644 api/pay/README.md create mode 100644 api/pay/boost.js create mode 100644 api/pay/buyCredits.js create mode 100644 api/pay/donate.js create mode 100644 api/pay/downZap.js create mode 100644 api/pay/index.js create mode 100644 api/pay/inviteGift.js create mode 100644 api/pay/itemCreate.js create mode 100644 api/pay/itemUpdate.js create mode 100644 api/pay/lib/assert.js create mode 100644 api/pay/lib/item.js create mode 100644 api/pay/lib/territory.js create mode 100644 api/pay/pollVote.js create mode 100644 api/pay/receive.js create mode 100644 api/pay/territoryBilling.js create mode 100644 api/pay/territoryCreate.js create mode 100644 api/pay/territoryUnarchive.js create mode 100644 api/pay/territoryUpdate.js create mode 100644 api/pay/zap.js diff --git a/api/pay/README.md b/api/pay/README.md new file mode 100644 index 000000000..a32588076 --- /dev/null +++ b/api/pay/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/pay/boost.js b/api/pay/boost.js new file mode 100644 index 000000000..2030f8100 --- /dev/null +++ b/api/pay/boost.js @@ -0,0 +1,82 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { msatsToSats, satsToMsats } from '@/lib/format' + +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 getCost ({ sats }) { + return satsToMsats(sats) +} + +export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) { + itemId = parseInt(itemId) + + let invoiceData = {} + if (invoiceId) { + invoiceData = { invoiceId, invoiceActionState: 'PENDING' } + // store a reference to the item in the invoice + await tx.invoice.update({ + where: { id: invoiceId }, + data: { actionId: itemId } + }) + } + + const act = await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'BOOST', ...invoiceData } }) + + const [{ path }] = await tx.$queryRaw` + SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` + return { id: itemId, sats, act: 'BOOST', path, actId: act.id } +} + +export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + const [{ id, path }] = await tx.$queryRaw` + SELECT "Item".id, ltree2text(path) as path + FROM "Item" + JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" + WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + return { id, sats: msatsToSats(cost), act: 'BOOST', path } +} + +export async function onPaid ({ invoice, actId }, { tx }) { + let itemAct + if (invoice) { + await tx.itemAct.updateMany({ + where: { invoiceId: invoice.id }, + data: { + invoiceActionState: 'PAID' + } + }) + itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } }) + } else if (actId) { + itemAct = await tx.itemAct.findFirst({ where: { id: actId } }) + } else { + throw new Error('No invoice or actId') + } + + // increment boost on item + await tx.item.update({ + where: { id: itemAct.itemId }, + data: { + boost: { increment: msatsToSats(itemAct.msats) } + } + }) + + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) + VALUES ('expireBoost', jsonb_build_object('id', ${itemAct.itemId}::INTEGER), 21, true, + now() + interval '30 days', now() + interval '40 days')` +} + +export async function onFail ({ invoice }, { tx }) { + await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) +} + +export async function describe ({ id: itemId, sats }, { actionId, cost }) { + return `SN: boost ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}` +} diff --git a/api/pay/buyCredits.js b/api/pay/buyCredits.js new file mode 100644 index 000000000..b0851817c --- /dev/null +++ b/api/pay/buyCredits.js @@ -0,0 +1,32 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getCost ({ credits }) { + return satsToMsats(credits) +} + +export async function perform ({ credits }, { me, cost, tx }) { + await tx.user.update({ + where: { id: me.id }, + data: { + mcredits: { + increment: cost + } + } + }) + + return { + credits + } +} + +export async function describe () { + return 'SN: buy fee credits' +} diff --git a/api/pay/donate.js b/api/pay/donate.js new file mode 100644 index 000000000..20f4e7e63 --- /dev/null +++ b/api/pay/donate.js @@ -0,0 +1,29 @@ +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { 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 getCost ({ sats }) { + return satsToMsats(sats) +} + +export async function perform ({ sats }, { me, tx }) { + await tx.donation.create({ + data: { + sats, + userId: me?.id ?? USER_ID.anon + } + }) + + return { sats } +} + +export async function describe (args, context) { + return 'SN: donate to rewards pool' +} diff --git a/api/pay/downZap.js b/api/pay/downZap.js new file mode 100644 index 000000000..968f822e3 --- /dev/null +++ b/api/pay/downZap.js @@ -0,0 +1,100 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { msatsToSats, satsToMsats } from '@/lib/format' +import { Prisma } from '@prisma/client' + +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 getCost ({ sats }) { + return satsToMsats(sats) +} + +export async function perform ({ invoiceId, sats, id: itemId }, { me, cost, tx }) { + itemId = parseInt(itemId) + + let invoiceData = {} + if (invoiceId) { + invoiceData = { invoiceId, invoiceActionState: 'PENDING' } + // store a reference to the item in the invoice + await tx.invoice.update({ + where: { id: invoiceId }, + data: { actionId: itemId } + }) + } + + const itemAct = await tx.itemAct.create({ + data: { msats: cost, itemId, userId: me.id, act: 'DONT_LIKE_THIS', ...invoiceData } + }) + + const [{ path }] = await tx.$queryRaw`SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` + return { id: itemId, sats, act: 'DONT_LIKE_THIS', path, actId: itemAct.id } +} + +export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + const [{ id, path }] = await tx.$queryRaw` + SELECT "Item".id, ltree2text(path) as path + FROM "Item" + JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" + WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + return { id, sats: msatsToSats(cost), act: 'DONT_LIKE_THIS', path } +} + +export async function onPaid ({ invoice, actId }, { tx }) { + let itemAct + if (invoice) { + await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) + itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id }, include: { item: true } }) + } else if (actId) { + itemAct = await tx.itemAct.findUnique({ where: { id: actId }, include: { item: true } }) + } else { + throw new Error('No invoice or actId') + } + + const msats = BigInt(itemAct.msats) + const sats = msatsToSats(msats) + + // 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 = ${itemAct.itemId}::INTEGER + ), zapper AS ( + SELECT + COALESCE(${itemAct.item.parentId + ? Prisma.sql`"zapCommentTrust"` + : Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust", + COALESCE(${itemAct.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" = ${itemAct.userId}::INTEGER + ), zap AS ( + INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats") + VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::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 = ${itemAct.itemId}::INTEGER` +} + +export async function onFail ({ invoice }, { tx }) { + await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) +} + +export async function describe ({ id: itemId, sats }, { cost, actionId }) { + return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}` +} diff --git a/api/pay/index.js b/api/pay/index.js new file mode 100644 index 000000000..8e56d16ec --- /dev/null +++ b/api/pay/index.js @@ -0,0 +1,278 @@ +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { Prisma } from '@prisma/client' + +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 './receive' +import * as BUY_CREDITS from './buyCredits' +import * as INVITE_GIFT from './inviteGift' + +export const payInTypeModules = { + 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 +} + +export default async function payIn (payInType, payInArgs, context) { + try { + const { me } = context + 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') + } + + const payIn = { + payInType, + userId: me?.id ?? USER_ID.anon, + cost: await payInModule.getCost(payInArgs, context) + } + + return await payInPerform(payIn, payInArgs, context) + } catch (e) { + console.error('performPaidAction failed', e) + throw e + } finally { + console.groupEnd() + } +} + +export async function payInRetry (payInId, { models, me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId, payInState: 'FAILED' } }) + if (!payIn) { + throw new Error('PayIn not found') + } + // TODO: add predecessorId to payInSuccessor + // if payInFailureReason is INVOICE_CREATION_FAILED, we need to force custodial tokens +} + +async function getPayInCustodialTokens (tx, mCustodialCost, { me, models }) { + if (!me) { + return [] + } + const { mcredits, msats, mcreditsBefore, msatsBefore } = await tx.$queryRaw` + UPDATE users + SET + -- if we have enough mcredits, subtract the cost from mcredits + -- otherwise, set mcredits to 0 and subtract the rest from msats + mcredits = CASE + WHEN mcredits >= ${mCustodialCost} THEN mcredits - ${mCustodialCost} + ELSE 0 + END, + -- if we have enough msats, subtract the remaining cost from msats + -- otherwise, set msats to 0 + msats = CASE + WHEN mcredits >= ${mCustodialCost} THEN msats + WHEN msats >= ${mCustodialCost} - mcredits THEN msats - (${mCustodialCost} - mcredits) + ELSE 0 + END + FROM (SELECT id, mcredits, msats FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING mcredits, msats, before.mcredits as mcreditsBefore, before.msats as msatsBefore` + + const payInAssets = [] + if (mcreditsBefore > mcredits) { + payInAssets.push({ + payInAssetType: 'CREDITS', + masset: mcreditsBefore - mcredits, + massetBefore: mcreditsBefore + }) + } + if (msatsBefore > msats) { + payInAssets.push({ + payInAssetType: 'SATS', + masset: msatsBefore - msats, + massetBefore: msatsBefore + }) + } + return payInAssets +} + +async function payInPerform (payIn, payInArgs, { me, models }) { + const payInModule = payInTypeModules[payIn.payInType] + + const payOuts = await payInModule.getPayOuts(models, payIn, payInArgs, { me }) + // if there isn't a custodial token for a payOut, it's a p2p payOut + const mCostP2P = payOuts.find(payOut => !payOut.custodialTokenType)?.mtokens ?? 0n + // we deduct the p2p payOut from what can be paid with custodial tokens + const mCustodialCost = payIn.mcost - mCostP2P + + const result = await models.$transaction(async tx => { + const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, { me, models }) + const mCustodialPaying = payInCustodialTokens.reduce((acc, token) => acc + token.mtokens, 0n) + + // TODO: what if remainingCost < 1000n or not a multiple of 1000n? + // the remaining cost will be paid with an invoice + const remainingCost = mCustodialCost - mCustodialPaying + mCostP2P + + const payInResult = await tx.payIn.create({ + data: { + payInType: payIn.payInType, + mcost: payIn.mcost, + payInState: remainingCost > 0n ? 'PENDING_INVOICE_CREATION' : 'PAID', + payInStateChangedAt: new Date(), // TODO: set with a trigger + userId: payIn.userId, + payInCustodialTokens: { + createMany: { + data: payInCustodialTokens + } + } + }, + include: { + payInCustodialTokens: true + } + }) + + // if it's pessimistic, we don't perform the action until the invoice is held + if (remainingCost > 0n && (!me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC))) { + return { + payIn: payInResult, + remainingCost + } + } + + // if it's optimistic or already paid, we perform the action + const result = await payInModule.perform(tx, payInResult, payInArgs, { models, me }) + // if there's remaining cost, we return the result but don't run onPaid or payOuts + if (remainingCost > 0n) { + // transactionally insert a job to check if the required invoice is added + // we can't do it before because we don't know the amount of the invoice + // and we want to refund the custodial tokens if the invoice creation fails + // TODO: consider timeouts of wrapped invoice creation ... ie 30 seconds might not be enough + await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ('checkPayIn', jsonb_build_object('id', ${payInResult.id}::INTEGER), now() + interval '30 seconds', 1000)` + return { + payIn: payInResult, + result, + remainingCost + } + } + + // if it's already paid, we run onPaid and do payOuts in the same transaction + await onPaid(tx, payInResult, payInArgs, { models, me }) + return { + payIn: payInResult, + result, + remainingCost: 0n + } + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + + if (result.remainingCost > 0n) { + try { + let invoice = null + if (mCostP2P > 0n) { + // TODO: if creating a p2p invoice fails, we'll want to fallback to paying with custodial tokens or creating a normal invoice + // I think we'll want to fail the payIn, refund them, then retry with forced custodial tokens + invoice = await payInAddP2PInvoice(result.remainingCost, result.payIn, payInArgs, { models, me }) + } else { + invoice = await payInAddInvoice(result.remainingCost, result.payIn, payInArgs, { models, me }) + } + return { + payIn: result.payIn, + result, + invoice + } + } catch (e) { + // if we fail to add an invoice, we transition the payIn to failed + models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ('payInCancel', jsonb_build_object('id', ${result.payIn.id}::INTEGER), now() + interval '30 seconds', 1000)`.catch(console.error) + console.error('payInAddInvoice failed', e) + throw e + } + } + + return result.result +} + +// in the case of a zap getPayOuts will return +async function payInAddInvoice (remainingCost, payIn, payInArgs, { models, me }) { + // TODO: add invoice + return null +} + +async function payInAddP2PInvoice (remainingCost, payIn, payInArgs, { models, me }) { + try { + // TODO: add p2p invoice + } catch (e) { + console.error('payInAddP2PInvoice failed', e) + try { + await models.$transaction(async tx => { + await tx.payIn.update({ + where: { id: payIn.id }, + data: { payInState: 'FAILED', payInFailureReason: 'INVOICE_CREATION_FAILED', payInStateChangedAt: new Date() } + }) + await onFail(tx, payIn, payInArgs, { models, me }) + }) + // probably need to check if we've timed out already, in which case we should skip the retry + await payInRetry(payIn.id, { models, me }) + } catch (e) { + console.error('payInAddP2PInvoice failed to update payIn', e) + } + } + return null +} + +export async function onFail (tx, payIn, payInArgs, { me }) { + const payInModule = payInTypeModules[payIn.payInType] + // refund the custodial tokens + for (const payInCustodialToken of payIn.payInCustodialTokens) { + await tx.$queryRaw` + UPDATE users + SET msats = msats + ${payInCustodialToken.custodialTokenType === 'SATS' ? payInCustodialToken.mtokens : 0}, + mcredits = mcredits + ${payInCustodialToken.custodialTokenType === 'CREDITS' ? payInCustodialToken.mtokens : 0} + WHERE id = ${payIn.userId}` + } + await payInModule.onFail(tx, payIn, payInArgs, { me }) +} + +// maybe if payIn has an invoiceForward associated with it, we can use credits or not +async function onPaid (tx, payIn, payInArgs, { models, me }) { + const payInModule = payInTypeModules[payIn.payInType] + const payOuts = await payInModule.getPayOuts(tx, payIn, payInArgs, { me }) + + for (const payOut of payOuts) { + await tx.$queryRaw` + WITH user AS ( + UPDATE users + SET msats = msats + ${payOut.custodialTokenType === 'SATS' ? payOut.mtokens : 0}, + mcredits = mcredits + ${payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0} + FROM (SELECT id, mcredits, msats FROM users WHERE id = ${payOut.userId} FOR UPDATE) before + WHERE users.id = before.id + RETURNING mcredits, msats, mcreditsBefore, msatsBefore + ) + INSERT INTO "payOuts" ("payInId", "payOutType", "mtokens", "custodialTokenType", "msatsBefore", "mcreditsBefore") + VALUES (${payIn.id}, ${payOut.payOutType}, ${payOut.mtokens}, ${payOut.custodialTokenType}, ${payOut.msatsBefore}, ${payOut.mcreditsBefore})` + } + + await payInModule.onPaid(tx, payIn, payInArgs, { me }) + // run non critical side effects in the background + // now that everything is paid + payInModule.nonCriticalSideEffects?.(payIn, payInArgs, { models, me }).catch(console.error) +} diff --git a/api/pay/inviteGift.js b/api/pay/inviteGift.js new file mode 100644 index 000000000..2c24ac401 --- /dev/null +++ b/api/pay/inviteGift.js @@ -0,0 +1,60 @@ +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 getCost ({ id }, { models, me }) { + const invite = await models.invite.findUnique({ where: { id, userId: me.id, revoked: false } }) + if (!invite) { + throw new Error('invite not found') + } + return satsToMsats(invite.gift) +} + +export async function perform ({ id, userId }, { me, cost, tx }) { + const invite = await tx.invite.findUnique({ + where: { id, userId: me.id, 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: { + mcredits: { + increment: cost + }, + inviteId: id, + referrerId: me.id + } + }) + + return await tx.invite.update({ + where: { id, userId: me.id, revoked: false, ...(invite.limit ? { giftedCount: { lt: invite.limit } } : {}) }, + data: { + giftedCount: { + increment: 1 + } + } + }) +} + +export async function nonCriticalSideEffects (_, { me }) { + notifyInvite(me.id) +} diff --git a/api/pay/itemCreate.js b/api/pay/itemCreate.js new file mode 100644 index 000000000..d6fb603f7 --- /dev/null +++ b/api/pay/itemCreate.js @@ -0,0 +1,309 @@ +import { 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 } from './lib/item' +import { msatsToSats, satsToMsats } from '@/lib/format' +import { GqlInputError } from '@/lib/error' + +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 +] + +export const DEFAULT_ITEM_COST = 1000n + +export async function getBaseCost ({ models, bio, parentId, subName }) { + if (bio) return DEFAULT_ITEM_COST + + if (parentId) { + // the subname is stored in the root item of the thread + const [sub] = await models.$queryRaw` + SELECT s."replyCost" + FROM "Item" i + LEFT JOIN "Item" r ON r.id = i."rootId" + LEFT JOIN "Sub" s ON s.name = COALESCE(r."subName", i."subName") + WHERE i.id = ${Number(parentId)}` + + if (sub?.replyCost) return satsToMsats(sub.replyCost) + return DEFAULT_ITEM_COST + } + + const sub = await models.sub.findUnique({ where: { name: subName } }) + return satsToMsats(sub.baseCost) +} + +export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, 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 ?? USER_ID.anon}::INTEGER, + ${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL)) + * ${me ? 1 : 100}::INTEGER + + (SELECT "nUnpaid" * "uploadFeesMsats" + FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[])) + + ${satsToMsats(boost)}::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 && + me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost + + return freebie ? BigInt(0) : BigInt(cost) +} + +export async function perform (args, context) { + const { invoiceId, parentId, uploadIds = [], forwardUsers = [], options: pollOptions = [], boost = 0, ...data } = args + const { tx, me, cost } = context + const boostMsats = satsToMsats(boost) + + 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.`) + } + + let invoiceData = {} + if (invoiceId) { + invoiceData = { invoiceId, invoiceActionState: 'PENDING' } + await tx.upload.updateMany({ + where: { id: { in: uploadIds } }, + data: invoiceData + }) + } + + const itemActs = [] + if (boostMsats > 0) { + itemActs.push({ + msats: boostMsats, act: 'BOOST', userId: data.userId, ...invoiceData + }) + } + if (cost > 0) { + itemActs.push({ + msats: cost - boostMsats, act: 'FEE', userId: data.userId, ...invoiceData + }) + data.cost = msatsToSats(cost - boostMsats) + } + + const mentions = await getMentions(args, context) + const itemMentions = await getItemMentions(args, context) + + // start with median vote + if (me) { + const [row] = await tx.$queryRaw`SELECT + COALESCE(percentile_cont(0.5) WITHIN GROUP( + ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + AS median FROM "Item" WHERE "userId" = ${me.id}::INTEGER` + if (row?.median < 0) { + data.weightedDownVotes = -row.median + } + } + + const itemData = { + parentId: parentId ? parseInt(parentId) : null, + ...data, + ...invoiceData, + boost, + 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 })) + }, + itemActs: { + createMany: { + data: itemActs + } + }, + mentions: { + createMany: { + data: mentions + } + }, + itemReferrers: { + create: itemMentions + } + } + + let item + if (data.bio && me) { + 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 + } + } + + // store a reference to the item in the invoice + if (invoiceId) { + await tx.invoice.update({ + where: { id: invoiceId }, + data: { actionId: item.id } + }) + } + + await performBotBehavior(item, context) + + // ltree is unsupported in Prisma, so we have to query it manually (FUCK!) + return (await tx.$queryRaw` + SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" + FROM "Item" WHERE id = ${item.id}::INTEGER` + )[0] +} + +export async function retry ({ invoiceId, newInvoiceId }, { tx }) { + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + await tx.item.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + await tx.upload.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + return (await tx.$queryRaw` + SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" + FROM "Item" WHERE "invoiceId" = ${newInvoiceId}::INTEGER` + )[0] +} + +export async function onPaid ({ invoice, id }, context) { + const { tx } = context + let item + + if (invoice) { + item = await tx.item.findFirst({ + where: { invoiceId: invoice.id }, + include: { + user: true + } + }) + await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) + await tx.item.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID', invoicePaidAt: new Date() } }) + await tx.upload.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID', paid: true } }) + } else if (id) { + item = await tx.item.findUnique({ + where: { id }, + include: { + user: true, + itemUploads: { include: { upload: true } } + } + }) + await tx.upload.updateMany({ + where: { id: { in: item.itemUploads.map(({ uploadId }) => uploadId) } }, + data: { + paid: true + } + }) + } else { + throw new Error('No item 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.boost > 0) { + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) + VALUES ('expireBoost', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, + now() + interval '30 days', now() + interval '40 days')` + } + + if (item.parentId) { + // denormalize ncomments, lastCommentAt, and "weightedComments" 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 nonCriticalSideEffects ({ invoice, id }, { models }) { + const item = await models.item.findFirst({ + where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) }, + 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 onFail ({ invoice }, { tx }) { + await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) + await tx.item.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) + await tx.upload.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) +} + +export async function describe ({ parentId }, context) { + return `SN: create ${parentId ? `reply to #${parentId}` : 'item'}` +} diff --git a/api/pay/itemUpdate.js b/api/pay/itemUpdate.js new file mode 100644 index 000000000..8d63bed76 --- /dev/null +++ b/api/pay/itemUpdate.js @@ -0,0 +1,183 @@ +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { uploadFees } from '../resolvers/upload' +import { getItemMentions, getMentions, performBotBehavior } from './lib/item' +import { notifyItemMention, notifyMention } from '@/lib/webPush' +import { satsToMsats } from '@/lib/format' + +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }) { + // the only reason updating items costs anything is when it has new uploads + // or more boost + const old = await models.item.findUnique({ where: { id: parseInt(id) } }) + const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) + const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost) + + if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') { + throw new Error('creation invoice not paid') + } + + return cost +} + +export async function perform (args, context) { + const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args + const { tx, me } = context + const old = await tx.item.findUnique({ + where: { id: parseInt(id) }, + include: { + threadSubscriptions: true, + mentions: true, + itemForwards: true, + itemReferrers: true, + itemUploads: true + } + }) + + const newBoost = boost - old.boost + const itemActs = [] + if (newBoost > 0) { + const boostMsats = satsToMsats(newBoost) + itemActs.push({ + msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon + }) + } + + // 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(args, context) + const itemMentions = await getItemMentions(args, context) + const itemUploads = uploadIds.map(id => ({ uploadId: id })) + + await tx.upload.updateMany({ + where: { id: { in: uploadIds } }, + data: { paid: true } + }) + + // 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), boost: old.boost }, + data: { + ...data, + boost: { + increment: newBoost + }, + 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) + } + } + }, + itemActs: { + createMany: { + data: itemActs + } + }, + 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')` + + if (newBoost > 0) { + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) + VALUES ('expireBoost', jsonb_build_object('id', ${id}::INTEGER), 21, true, + now() + interval '30 days', now() + interval '40 days')` + } + + await performBotBehavior(args, context) + + // ltree is unsupported in Prisma, so we have to query it manually (FUCK!) + return (await tx.$queryRaw` + SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" + FROM "Item" WHERE id = ${parseInt(id)}::INTEGER` + )[0] +} + +export async function nonCriticalSideEffects ({ invoice, id }, { models }) { + const item = await models.item.findFirst({ + where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) }, + 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 ({ id, parentId }, context) { + return `SN: update ${parentId ? `reply to #${parentId}` : 'post'}` +} diff --git a/api/pay/lib/assert.js b/api/pay/lib/assert.js new file mode 100644 index 000000000..a4d599c52 --- /dev/null +++ b/api/pay/lib/assert.js @@ -0,0 +1,56 @@ +import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants' +import { datePivot } from '@/lib/time' + +const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 +const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10 +const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100 + +export async function assertBelowMaxPendingInvoices (context) { + const { models, me } = context + const pendingInvoices = await models.invoice.count({ + where: { + userId: me?.id ?? USER_ID.anon, + actionState: { + notIn: PAID_ACTION_TERMINAL_STATES + } + } + }) + + if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) { + throw new Error('You have too many pending paid actions, cancel some or wait for them to expire') + } +} + +export async function assertBelowMaxPendingDirectPayments (userId, context) { + const { models, me } = context + + if (me?.id !== userId) { + const pendingSenderInvoices = await models.directPayment.count({ + where: { + senderId: me?.id ?? USER_ID.anon, + createdAt: { + gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES }) + } + } + }) + + if (pendingSenderInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) { + throw new Error('You\'ve sent too many direct payments') + } + } + + if (!userId) return + + const pendingReceiverInvoices = await models.directPayment.count({ + where: { + receiverId: userId, + createdAt: { + gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES }) + } + } + }) + + if (pendingReceiverInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) { + throw new Error('Receiver has too many direct payments') + } +} diff --git a/api/pay/lib/item.js b/api/pay/lib/item.js new file mode 100644 index 000000000..879b1cb53 --- /dev/null +++ b/api/pay/lib/item.js @@ -0,0 +1,89 @@ +import { USER_ID } from '@/lib/constants' +import { deleteReminders, getDeleteAt, getRemindAt } from '@/lib/item' +import { parseInternalLinks } from '@/lib/url' + +export async function getMentions ({ text }, { me, tx }) { + 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: me?.id || USER_ID.anon + } + } + }) + return users.map(user => ({ userId: user.id })) + } + return [] +} + +export const getItemMentions = async ({ text }, { me, tx }) => { + 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: me?.id || USER_ID.anon } + } + }) + return referee.map(r => ({ refereeId: r.id })) + } + + return [] +} + +export async function performBotBehavior ({ text, id }, { me, tx }) { + // delete any existing deleteItem or reminder jobs for this item + const userId = me?.id || USER_ID.anon + 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/pay/lib/territory.js b/api/pay/lib/territory.js new file mode 100644 index 000000000..849ff11c1 --- /dev/null +++ b/api/pay/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/pay/pollVote.js b/api/pay/pollVote.js new file mode 100644 index 000000000..d2eb41785 --- /dev/null +++ b/api/pay/pollVote.js @@ -0,0 +1,70 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' + +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 getCost ({ id }, { me, models }) { + const pollOption = await models.pollOption.findUnique({ + where: { id: parseInt(id) }, + include: { item: true } + }) + return satsToMsats(pollOption.item.pollCost) +} + +export async function perform ({ invoiceId, id }, { me, cost, tx }) { + const pollOption = await tx.pollOption.findUnique({ + where: { id: parseInt(id) } + }) + const itemId = parseInt(pollOption.itemId) + + let invoiceData = {} + if (invoiceId) { + invoiceData = { invoiceId, invoiceActionState: 'PENDING' } + // store a reference to the item in the invoice + await tx.invoice.update({ + where: { id: invoiceId }, + data: { actionId: itemId } + }) + } + + // the unique index on userId, itemId will prevent double voting + await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'POLL', ...invoiceData } }) + await tx.pollBlindVote.create({ data: { userId: me.id, itemId, ...invoiceData } }) + await tx.pollVote.create({ data: { pollOptionId: pollOption.id, itemId, ...invoiceData } }) + + return { id } +} + +export async function retry ({ invoiceId, newInvoiceId }, { tx }) { + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + await tx.pollBlindVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + await tx.pollVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + + const { pollOptionId } = await tx.pollVote.findFirst({ where: { invoiceId: newInvoiceId } }) + return { id: pollOptionId } +} + +export async function onPaid ({ invoice }, { tx }) { + if (!invoice) return + + await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) + await tx.pollBlindVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) + // anonymize the vote + await tx.pollVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceId: null, invoiceActionState: null } }) +} + +export async function onFail ({ invoice }, { tx }) { + await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) + await tx.pollBlindVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) + await tx.pollVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) +} + +export async function describe ({ id }, { actionId }) { + return `SN: vote on poll #${id ?? actionId}` +} diff --git a/api/pay/receive.js b/api/pay/receive.js new file mode 100644 index 000000000..51945a858 --- /dev/null +++ b/api/pay/receive.js @@ -0,0 +1,80 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format' +import { notifyDeposit } from '@/lib/webPush' +import { getInvoiceableWallets } from '@/wallets/server' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.P2P, + PAID_ACTION_PAYMENT_METHODS.DIRECT +] + +export async function getCost ({ msats }) { + return toPositiveBigInt(msats) +} + +export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) { + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null + + const wallets = await getInvoiceableWallets(me.id, { models }) + if (wallets.length === 0) { + return null + } + + return me.id +} + +export async function getSybilFeePercent () { + return 10n +} + +export async function perform ({ + invoiceId, + comment, + lud18Data, + noteStr +}, { me, tx }) { + return await tx.invoice.update({ + where: { id: invoiceId }, + data: { + comment, + lud18Data, + ...(noteStr ? { desc: noteStr } : {}) + }, + include: { invoiceForward: true } + }) +} + +export async function describe ({ description }, { me, cost, paymentMethod, sybilFeePercent }) { + const fee = paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P + ? cost * BigInt(sybilFeePercent) / 100n + : 0n + return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}` +} + +export async function onPaid ({ invoice }, { tx }) { + if (!invoice) { + throw new Error('invoice is required') + } + + // P2P lnurlp does not need to update the user's balance + if (invoice?.invoiceForward) return + + await tx.user.update({ + where: { id: invoice.userId }, + data: { + mcredits: { + increment: invoice.msatsReceived + } + } + }) +} + +export async function nonCriticalSideEffects ({ invoice }, { models }) { + await notifyDeposit(invoice.userId, invoice) + await models.$executeRaw` + INSERT INTO pgboss.job (name, data) + VALUES ('nip57', jsonb_build_object('hash', ${invoice.hash}))` +} diff --git a/api/pay/territoryBilling.js b/api/pay/territoryBilling.js new file mode 100644 index 000000000..526816f7c --- /dev/null +++ b/api/pay/territoryBilling.js @@ -0,0 +1,73 @@ +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 getCost ({ name }, { models }) { + const sub = await models.sub.findUnique({ + where: { + name + } + }) + + return satsToMsats(TERRITORY_PERIOD_COST(sub.billingType)) +} + +export async function perform ({ name }, { cost, tx }) { + 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', + SubAct: { + create: { + msats: cost, + type: 'BILLING', + userId: sub.userId + } + } + } + }) +} + +export async function describe ({ name }) { + return `SN: billing for territory ${name}` +} diff --git a/api/pay/territoryCreate.js b/api/pay/territoryCreate.js new file mode 100644 index 000000000..a9316cdb2 --- /dev/null +++ b/api/pay/territoryCreate.js @@ -0,0 +1,56 @@ +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 getCost ({ billingType }) { + return satsToMsats(TERRITORY_PERIOD_COST(billingType)) +} + +export async function perform ({ invoiceId, ...data }, { me, cost, tx }) { + const { billingType } = data + const billingCost = TERRITORY_PERIOD_COST(billingType) + const billedLastAt = new Date() + const billPaidUntil = nextBilling(billedLastAt, billingType) + + const sub = await tx.sub.create({ + data: { + ...data, + billedLastAt, + billPaidUntil, + billingCost, + rankingType: 'WOT', + userId: me.id, + SubAct: { + create: { + msats: cost, + type: 'BILLING', + userId: me.id + } + }, + SubSubscription: { + create: { + userId: me.id + } + } + } + }) + + await tx.userSubTrust.createMany({ + data: initialTrust({ name: sub.name, userId: sub.userId }) + }) + + return sub +} + +export async function describe ({ name }) { + return `SN: create territory ${name}` +} diff --git a/api/pay/territoryUnarchive.js b/api/pay/territoryUnarchive.js new file mode 100644 index 000000000..9f79963eb --- /dev/null +++ b/api/pay/territoryUnarchive.js @@ -0,0 +1,90 @@ +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 getCost ({ billingType }) { + return satsToMsats(TERRITORY_PERIOD_COST(billingType)) +} + +export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) { + const sub = await tx.sub.findUnique({ + where: { + name + } + }) + + data.billingCost = TERRITORY_PERIOD_COST(data.billingType) + + // we never want to bill them again if they are changing to ONCE + if (data.billingType === 'ONCE') { + data.billPaidUntil = null + data.billingAutoRenew = false + } + + data.billedLastAt = new Date() + data.billPaidUntil = nextBilling(data.billedLastAt, data.billingType) + data.status = 'ACTIVE' + data.userId = me.id + + if (sub.userId !== me.id) { + await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } }) + await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } }) + } + + await tx.subAct.create({ + data: { + userId: me.id, + subName: name, + msats: cost, + type: 'BILLING' + } + }) + + await tx.subSubscription.upsert({ + where: { + userId_subName: { + userId: me.id, + subName: name + } + }, + update: { + userId: me.id, + subName: name + }, + create: { + userId: me.id, + subName: name + } + }) + + const updatedSub = await tx.sub.update({ + data, + // optimistic concurrency control + // make sure none of the relevant fields have changed since we fetched the sub + where: { + ...sub, + postTypes: { + equals: sub.postTypes + } + } + }) + + await tx.userSubTrust.createMany({ + data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId }) + }) + + return updatedSub +} + +export async function describe ({ name }, context) { + return `SN: unarchive territory ${name}` +} diff --git a/api/pay/territoryUpdate.js b/api/pay/territoryUpdate.js new file mode 100644 index 000000000..30040a804 --- /dev/null +++ b/api/pay/territoryUpdate.js @@ -0,0 +1,83 @@ +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 getCost ({ oldName, billingType }, { models }) { + const oldSub = await models.sub.findUnique({ + where: { + name: oldName + } + }) + + const cost = proratedBillingCost(oldSub, billingType) + if (!cost) { + return 0n + } + + return satsToMsats(cost) +} + +export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx }) { + const oldSub = await tx.sub.findUnique({ + where: { + name: oldName + } + }) + + data.billingCost = TERRITORY_PERIOD_COST(data.billingType) + + // we never want to bill them again if they are changing to ONCE + if (data.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' && data.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' + } + + if (cost > 0n) { + await tx.subAct.create({ + data: { + userId: me.id, + subName: oldName, + msats: cost, + type: 'BILLING' + } + }) + } + + return await tx.sub.update({ + data, + 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: me.id + } + }) +} + +export async function describe ({ name }, context) { + return `SN: update territory billing ${name}` +} diff --git a/api/pay/zap.js b/api/pay/zap.js new file mode 100644 index 000000000..66b4d6a0c --- /dev/null +++ b/api/pay/zap.js @@ -0,0 +1,245 @@ +import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { msatsToSats, satsToMsats } from '@/lib/format' +import { notifyZapped } from '@/lib/webPush' +import { getInvoiceableWallets } from '@/wallets/server' +import { Prisma } from '@prisma/client' + +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 +] + +export async function getCost ({ sats }) { + return satsToMsats(sats) +} + +export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, me, cost }) { + // 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 < me?.sendCreditsBelowSats || + (me && !hasSendWallet && (me.mcredits >= cost || me.msats >= cost))) { + return null + } + + const item = await models.item.findUnique({ + where: { id: parseInt(id) }, + include: { + itemForwards: true, + user: true + } + }) + + // bios don't get sats + if (item.bio) { + return null + } + + const wallets = await getInvoiceableWallets(item.userId, { models }) + + // request peer invoice if they have an attached wallet and have not forwarded the item + // and the receiver doesn't want to receive credits + if (wallets.length > 0 && + item.itemForwards.length === 0 && + sats >= item.user.receiveCreditsBelowSats) { + return item.userId + } + + return null +} + +export async function getSybilFeePercent () { + return 30n +} + +export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) { + const feeMsats = cost * sybilFeePercent / 100n + const zapMsats = cost - feeMsats + itemId = parseInt(itemId) + + let invoiceData = {} + if (invoiceId) { + invoiceData = { invoiceId, invoiceActionState: 'PENDING' } + // store a reference to the item in the invoice + await tx.invoice.update({ + where: { id: invoiceId }, + data: { actionId: itemId } + }) + } + + const acts = await tx.itemAct.createManyAndReturn({ + data: [ + { msats: feeMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'FEE', ...invoiceData }, + { msats: zapMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'TIP', ...invoiceData } + ] + }) + + const [{ path }] = await tx.$queryRaw` + SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` + return { id: itemId, sats, act: 'TIP', path, actIds: acts.map(act => act.id) } +} + +export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) + const [{ id, path }] = await tx.$queryRaw` + SELECT "Item".id, ltree2text(path) as path + FROM "Item" + JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" + WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + return { id, sats: msatsToSats(cost), act: 'TIP', path } +} + +export async function onPaid ({ invoice, actIds }, { tx }) { + let acts + if (invoice) { + await tx.itemAct.updateMany({ + where: { invoiceId: invoice.id }, + data: { + invoiceActionState: 'PAID' + } + }) + acts = await tx.itemAct.findMany({ where: { invoiceId: invoice.id }, include: { item: true } }) + actIds = acts.map(act => act.id) + } else if (actIds) { + acts = await tx.itemAct.findMany({ where: { id: { in: actIds } }, include: { item: true } }) + } else { + throw new Error('No invoice or actIds') + } + + const msats = acts.reduce((a, b) => a + BigInt(b.msats), BigInt(0)) + const sats = msatsToSats(msats) + const itemAct = acts.find(act => act.act === 'TIP') + + if (invoice?.invoiceForward) { + // only the op got sats and we need to add it to their stackedMsats + // because the sats were p2p + await tx.user.update({ + where: { id: itemAct.item.userId }, + data: { stackedMsats: { increment: itemAct.msats } } + }) + } else { + // splits only use mcredits + await tx.$executeRaw` + WITH forwardees AS ( + SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits + FROM "ItemForward" + WHERE "itemId" = ${itemAct.itemId}::INTEGER + ), total_forwarded AS ( + SELECT COALESCE(SUM(mcredits), 0) as mcredits + FROM forwardees + ), recipients AS ( + SELECT "userId", mcredits FROM forwardees + UNION + SELECT ${itemAct.item.userId}::INTEGER as "userId", + ${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits + ORDER BY "userId" ASC -- order to prevent deadlocks + ) + UPDATE users + SET + mcredits = users.mcredits + recipients.mcredits, + "stackedMsats" = users."stackedMsats" + recipients.mcredits, + "stackedMcredits" = users."stackedMcredits" + recipients.mcredits + FROM recipients + WHERE users.id = recipients."userId"` + } + + // 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 = ${itemAct.itemId}::INTEGER + ), zapper AS ( + SELECT + COALESCE(${itemAct.item.parentId + ? Prisma.sql`"zapCommentTrust"` + : Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust", + COALESCE(${itemAct.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" = ${itemAct.userId}::INTEGER + ), zap AS ( + INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats") + VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::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 + ${invoice?.invoiceForward ? 0n : msats}::BIGINT, + "lastZapAt" = now() + FROM zap, zapper + WHERE "Item".id = ${itemAct.itemId}::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" + ${invoice?.invoiceForward ? 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" = ${itemAct.userId}::INTEGER + AND "ItemUserAgg"."itemId" = ${itemAct.itemId}::INTEGER + AND root."userId" = ${itemAct.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 nonCriticalSideEffects ({ invoice, actIds }, { models }) { + const itemAct = await models.itemAct.findFirst({ + where: invoice ? { invoiceId: invoice.id } : { id: { in: actIds } }, + include: { item: true } + }) + // avoid duplicate notifications with the same zap amount + // by checking if there are any other pending acts on the item + const pendingActs = await models.itemAct.count({ + where: { + itemId: itemAct.itemId, + createdAt: { + gt: itemAct.createdAt + } + } + }) + if (pendingActs === 0) notifyZapped({ models, item: itemAct.item }).catch(console.error) +} + +export async function onFail ({ invoice }, { tx }) { + await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) +} + +export async function describe ({ id: itemId, sats }, { actionId, cost }) { + return `SN: zap ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}` +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a2e976391..710efef3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -153,6 +153,7 @@ model User { DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") UserSubTrust UserSubTrust[] PayIn PayIn[] + PayOut PayOut[] @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -1072,6 +1073,7 @@ model Withdrawl { wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) invoiceForward InvoiceForward? WalletLog WalletLog[] + PayOut PayOut? @@index([createdAt], map: "Withdrawl.created_at_index") @@index([userId], map: "Withdrawl.userId_index") @@ -1318,6 +1320,7 @@ enum PayInType { } enum PayInState { + PENDING_INVOICE_CREATION PENDING PENDING_HELD HELD @@ -1327,46 +1330,63 @@ enum PayInState { FORWARDED FAILED_FORWARD CANCELING + RETRYING +} + +enum PayInFailureReason { + INVOICE_CREATION_FAILED + USER_CANCELLED + SYSTEM_CANCELLED + INVOICE_EXPIRED + EXECUTION_FAILED } model PayIn { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - cost Int + mcost BigInt - payInType PayInType - payInState PayInState - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + payInType PayInType + payInState PayInState + payInFailureReason PayInFailureReason? // TODO: add check constraint + payInStateChangedAt DateTime? // TODO: set with a trigger + predecessorId Int? @unique + predecessor PayIn? @relation("PayInPredecessor", fields: [predecessorId], references: [id], onDelete: Cascade) + + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - mcreditsAfter BigInt - msatsAfter BigInt + msatsBefore BigInt? + mcreditsBefore BigInt? - PayMethod PayMethod[] - PessimisticEnv PessimisticEnv[] - PayOut PayOut[] + pessimisticEnv PessimisticEnv? + payInCustodialTokens PayInCustodialToken[] + payOuts PayOut[] + successor PayIn? @relation("PayInPredecessor") @@index([userId]) @@index([payInType]) + @@index([predecessorId]) + @@index([payInStateChangedAt]) } -enum PayInMethodType { - COWBOY_CREDITS - REWARD_SATS +enum CustodialTokenType { + CREDITS + SATS } -model PayMethod { - id Int @id @default(autoincrement()) - payId Int - payIn PayIn @relation(fields: [payId], references: [id], onDelete: Cascade) - msats BigInt - payMethodType PayInMethodType +model PayInCustodialToken { + id Int @id @default(autoincrement()) + payInId Int + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + mtokens BigInt + custodialTokenType CustodialTokenType } model PessimisticEnv { id Int @id @default(autoincrement()) - payInId Int + payInId Int @unique payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) args Json? @db.JsonB @@ -1387,12 +1407,18 @@ model PayOut { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - userId Int - payInId Int - payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) - msats BigInt payOutType PayOutType - msatsAfter BigInt - mcreditsAfter BigInt + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + payInId Int + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + mtokens BigInt + custodialTokenType CustodialTokenType? + withdrawlId Int? @unique + withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: Cascade) + + msatsBefore BigInt? + mcreditsBefore BigInt? } From 5f7ad5e45c8e27fa09876ff4b8f150bc33e41af2 Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 17 Apr 2025 20:01:18 -0500 Subject: [PATCH 03/10] invoice wrapping/creation flow --- api/{pay => payIn}/README.md | 0 api/{pay => payIn}/index.js | 184 ++++++------ api/{pay => payIn}/lib/assert.js | 0 api/{pay => payIn}/lib/item.js | 0 api/{pay => payIn}/lib/territory.js | 0 api/payIn/transitions.js | 31 ++ api/{pay => payIn/types}/boost.js | 0 api/{pay => payIn/types}/buyCredits.js | 0 api/{pay => payIn/types}/donate.js | 0 api/{pay => payIn/types}/downZap.js | 0 api/payIn/types/index.js | 32 ++ api/{pay => payIn/types}/inviteGift.js | 0 api/{pay => payIn/types}/itemCreate.js | 2 +- api/{pay => payIn/types}/itemUpdate.js | 4 +- api/{pay => payIn/types}/pollVote.js | 0 api/{pay => payIn/types}/receive.js | 0 api/{pay => payIn/types}/territoryBilling.js | 0 api/{pay => payIn/types}/territoryCreate.js | 2 +- .../types}/territoryUnarchive.js | 2 +- api/{pay => payIn/types}/territoryUpdate.js | 0 api/{pay => payIn/types}/zap.js | 0 prisma/schema.prisma | 284 +++++++++++------- 22 files changed, 331 insertions(+), 210 deletions(-) rename api/{pay => payIn}/README.md (100%) rename api/{pay => payIn}/index.js (65%) rename api/{pay => payIn}/lib/assert.js (100%) rename api/{pay => payIn}/lib/item.js (100%) rename api/{pay => payIn}/lib/territory.js (100%) create mode 100644 api/payIn/transitions.js rename api/{pay => payIn/types}/boost.js (100%) rename api/{pay => payIn/types}/buyCredits.js (100%) rename api/{pay => payIn/types}/donate.js (100%) rename api/{pay => payIn/types}/downZap.js (100%) create mode 100644 api/payIn/types/index.js rename api/{pay => payIn/types}/inviteGift.js (100%) rename api/{pay => payIn/types}/itemCreate.js (99%) rename api/{pay => payIn/types}/itemUpdate.js (98%) rename api/{pay => payIn/types}/pollVote.js (100%) rename api/{pay => payIn/types}/receive.js (100%) rename api/{pay => payIn/types}/territoryBilling.js (100%) rename api/{pay => payIn/types}/territoryCreate.js (96%) rename api/{pay => payIn/types}/territoryUnarchive.js (97%) rename api/{pay => payIn/types}/territoryUpdate.js (100%) rename api/{pay => payIn/types}/zap.js (100%) diff --git a/api/pay/README.md b/api/payIn/README.md similarity index 100% rename from api/pay/README.md rename to api/payIn/README.md diff --git a/api/pay/index.js b/api/payIn/index.js similarity index 65% rename from api/pay/index.js rename to api/payIn/index.js index 8e56d16ec..46ee82255 100644 --- a/api/pay/index.js +++ b/api/payIn/index.js @@ -1,38 +1,10 @@ import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { Prisma } from '@prisma/client' - -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 './receive' -import * as BUY_CREDITS from './buyCredits' -import * as INVITE_GIFT from './inviteGift' - -export const payInTypeModules = { - 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 -} +import { wrapBolt11 } from '@/wallets/server' +import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' +import { datePivot } from '@/lib/time' +import lnd from '../lnd' +import payInTypeModules from './types' export default async function payIn (payInType, payInArgs, context) { try { @@ -64,15 +36,6 @@ export default async function payIn (payInType, payInArgs, context) { } } -export async function payInRetry (payInId, { models, me }) { - const payIn = await models.payIn.findUnique({ where: { id: payInId, payInState: 'FAILED' } }) - if (!payIn) { - throw new Error('PayIn not found') - } - // TODO: add predecessorId to payInSuccessor - // if payInFailureReason is INVOICE_CREATION_FAILED, we need to force custodial tokens -} - async function getPayInCustodialTokens (tx, mCustodialCost, { me, models }) { if (!me) { return [] @@ -115,53 +78,69 @@ async function getPayInCustodialTokens (tx, mCustodialCost, { me, models }) { return payInAssets } +async function isPessimistic (payIn, { me }) { + const payInModule = payInTypeModules[payIn.payInType] + return !me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) +} + async function payInPerform (payIn, payInArgs, { me, models }) { const payInModule = payInTypeModules[payIn.payInType] - const payOuts = await payInModule.getPayOuts(models, payIn, payInArgs, { me }) - // if there isn't a custodial token for a payOut, it's a p2p payOut - const mCostP2P = payOuts.find(payOut => !payOut.custodialTokenType)?.mtokens ?? 0n + const { payOutCustodialTokens, payOutBolt11 } = await payInModule.getPayOuts(models, payIn, payInArgs, { me }) // we deduct the p2p payOut from what can be paid with custodial tokens - const mCustodialCost = payIn.mcost - mCostP2P + const mCustodialCost = payIn.mcost - (payOutBolt11?.msats ?? 0n) const result = await models.$transaction(async tx => { const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, { me, models }) - const mCustodialPaying = payInCustodialTokens.reduce((acc, token) => acc + token.mtokens, 0n) + const mCustodialPaid = payInCustodialTokens.reduce((acc, token) => acc + token.mtokens, 0n) // TODO: what if remainingCost < 1000n or not a multiple of 1000n? // the remaining cost will be paid with an invoice - const remainingCost = mCustodialCost - mCustodialPaying + mCostP2P + const mCostRemaining = mCustodialCost - mCustodialPaid + (payOutBolt11?.msats ?? 0n) const payInResult = await tx.payIn.create({ data: { payInType: payIn.payInType, mcost: payIn.mcost, - payInState: remainingCost > 0n ? 'PENDING_INVOICE_CREATION' : 'PAID', + payInState: 'PENDING_INVOICE_CREATION', payInStateChangedAt: new Date(), // TODO: set with a trigger userId: payIn.userId, + pessimisticEnv: { + create: mCostRemaining > 0n && isPessimistic(payIn, { me }) ? { args: payInArgs } : null + }, payInCustodialTokens: { createMany: { data: payInCustodialTokens } + }, + payOutCustodialTokens: { + createMany: { + data: payOutCustodialTokens + } + }, + payOutBolt11: { + create: payOutBolt11 } }, include: { - payInCustodialTokens: true + payInCustodialTokens: true, + user: true } }) // if it's pessimistic, we don't perform the action until the invoice is held - if (remainingCost > 0n && (!me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC))) { + if (payInResult.pessimisticEnv) { return { payIn: payInResult, - remainingCost + mCostRemaining } } // if it's optimistic or already paid, we perform the action const result = await payInModule.perform(tx, payInResult, payInArgs, { models, me }) - // if there's remaining cost, we return the result but don't run onPaid or payOuts - if (remainingCost > 0n) { + + // if there's remaining cost, we return the result but don't run onPaid + if (mCostRemaining > 0n) { // transactionally insert a job to check if the required invoice is added // we can't do it before because we don't know the amount of the invoice // and we want to refund the custodial tokens if the invoice creation fails @@ -171,7 +150,7 @@ async function payInPerform (payIn, payInArgs, { me, models }) { return { payIn: payInResult, result, - remainingCost + mCostRemaining } } @@ -180,29 +159,24 @@ async function payInPerform (payIn, payInArgs, { me, models }) { return { payIn: payInResult, result, - remainingCost: 0n + mCostRemaining: 0n } }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) - if (result.remainingCost > 0n) { + if (result.mCostRemaining > 0n) { try { - let invoice = null - if (mCostP2P > 0n) { - // TODO: if creating a p2p invoice fails, we'll want to fallback to paying with custodial tokens or creating a normal invoice - // I think we'll want to fail the payIn, refund them, then retry with forced custodial tokens - invoice = await payInAddP2PInvoice(result.remainingCost, result.payIn, payInArgs, { models, me }) - } else { - invoice = await payInAddInvoice(result.remainingCost, result.payIn, payInArgs, { models, me }) - } return { - payIn: result.payIn, - result, - invoice + payIn: await payInAddInvoice(result.mCostRemaining, result.payIn, payInArgs, { models, me }), + result: result.result } } catch (e) { - // if we fail to add an invoice, we transition the payIn to failed - models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) - VALUES ('payInCancel', jsonb_build_object('id', ${result.payIn.id}::INTEGER), now() + interval '30 seconds', 1000)`.catch(console.error) + await models.$transaction(async tx => { + await tx.payIn.update({ + where: { id: payIn.id, payInState: 'PENDING_INVOICE_CREATION' }, + data: { payInState: 'FAILED', payInFailureReason: 'INVOICE_CREATION_FAILED', payInStateChangedAt: new Date() } + }) + await onFail(tx, payIn, payInArgs, { models, me }) + }) console.error('payInAddInvoice failed', e) throw e } @@ -211,32 +185,54 @@ async function payInPerform (payIn, payInArgs, { me, models }) { return result.result } -// in the case of a zap getPayOuts will return -async function payInAddInvoice (remainingCost, payIn, payInArgs, { models, me }) { - // TODO: add invoice - return null +const INVOICE_EXPIRE_SECS = 600 + +async function createBolt11 (mCostRemaining, payIn, payInArgs, { models, me }) { + const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice + const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) + const invoice = await createLNDinvoice({ + description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(payIn, payInArgs, { models, me }), + mtokens: String(mCostRemaining), + expires_at: expiresAt, + lnd + }) + return invoice.request } -async function payInAddP2PInvoice (remainingCost, payIn, payInArgs, { models, me }) { - try { - // TODO: add p2p invoice - } catch (e) { - console.error('payInAddP2PInvoice failed', e) - try { - await models.$transaction(async tx => { - await tx.payIn.update({ - where: { id: payIn.id }, - data: { payInState: 'FAILED', payInFailureReason: 'INVOICE_CREATION_FAILED', payInStateChangedAt: new Date() } - }) - await onFail(tx, payIn, payInArgs, { models, me }) - }) - // probably need to check if we've timed out already, in which case we should skip the retry - await payInRetry(payIn.id, { models, me }) - } catch (e) { - console.error('payInAddP2PInvoice failed to update payIn', e) - } +// in the case of a zap getPayOuts will return +async function payInAddInvoice (mCostRemaining, payIn, payInArgs, { models, me }) { + let bolt11 = null + let payInState = null + if (payIn.payOutBolt11) { + bolt11 = await wrapBolt11({ msats: mCostRemaining, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models, me }) + payInState = 'PENDING_HELD' + } else { + bolt11 = await createBolt11(mCostRemaining, payIn, payInArgs, { models, me }) + payInState = payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING' } - return null + + const decodedBolt11 = parsePaymentRequest({ request: bolt11 }) + const expiresAt = new Date(decodedBolt11.expires_at) + const msatsRequested = BigInt(decodedBolt11.mtokens) + + return await models.payIn.update({ + where: { id: payIn.id, payInState: 'PENDING_INVOICE_CREATION' }, + data: { + payInState, + payInStateChangedAt: new Date(), + payInBolt11: { + create: { + hash: decodedBolt11.id, + bolt11, + msatsRequested, + expiresAt + } + } + }, + include: { + payInBolt11: true + } + }) } export async function onFail (tx, payIn, payInArgs, { me }) { diff --git a/api/pay/lib/assert.js b/api/payIn/lib/assert.js similarity index 100% rename from api/pay/lib/assert.js rename to api/payIn/lib/assert.js diff --git a/api/pay/lib/item.js b/api/payIn/lib/item.js similarity index 100% rename from api/pay/lib/item.js rename to api/payIn/lib/item.js diff --git a/api/pay/lib/territory.js b/api/payIn/lib/territory.js similarity index 100% rename from api/pay/lib/territory.js rename to api/payIn/lib/territory.js diff --git a/api/payIn/transitions.js b/api/payIn/transitions.js new file mode 100644 index 000000000..dc5920b14 --- /dev/null +++ b/api/payIn/transitions.js @@ -0,0 +1,31 @@ +const PAY_IN_TERMINAL_STATES = ['PAID', 'FAILED'] + +async function transitionPayIn (payInId, { fromStates, toState, transition }, { models }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId, payInState: { in: fromStates } } }) + if (!payIn) { + throw new Error('PayIn not found') + } + + if (PAY_IN_TERMINAL_STATES.includes(payIn.payInState)) { + throw new Error('PayIn is in a terminal state') + } + + if (!Array.isArray(fromStates)) { + fromStates = [fromStates] + } + + // TODO: retry on failure + await models.$transaction(async tx => { + const updatedPayIn = await tx.payIn.update({ + where: { id: payInId, payInState: { in: fromStates } }, + data: { payInState: toState }, + include: { + payInCustodialTokens: true, + payInBolt11: true, + pessimisticEnv: true + } + }) + await transition(tx, updatedPayIn) + }) +} +export default transitionPayIn diff --git a/api/pay/boost.js b/api/payIn/types/boost.js similarity index 100% rename from api/pay/boost.js rename to api/payIn/types/boost.js diff --git a/api/pay/buyCredits.js b/api/payIn/types/buyCredits.js similarity index 100% rename from api/pay/buyCredits.js rename to api/payIn/types/buyCredits.js diff --git a/api/pay/donate.js b/api/payIn/types/donate.js similarity index 100% rename from api/pay/donate.js rename to api/payIn/types/donate.js diff --git a/api/pay/downZap.js b/api/payIn/types/downZap.js similarity index 100% rename from api/pay/downZap.js rename to api/payIn/types/downZap.js diff --git a/api/payIn/types/index.js b/api/payIn/types/index.js new file mode 100644 index 000000000..0aecb8065 --- /dev/null +++ b/api/payIn/types/index.js @@ -0,0 +1,32 @@ +import * as ITEM_CREATE from './types/itemCreate' +import * as ITEM_UPDATE from './types/itemUpdate' +import * as ZAP from './types/zap' +import * as DOWN_ZAP from './types/downZap' +import * as POLL_VOTE from './types/pollVote' +import * as TERRITORY_CREATE from './types/territoryCreate' +import * as TERRITORY_UPDATE from './types/territoryUpdate' +import * as TERRITORY_BILLING from './types/territoryBilling' +import * as TERRITORY_UNARCHIVE from './types/territoryUnarchive' +import * as DONATE from './types/donate' +import * as BOOST from './types/boost' +import * as PROXY_PAYMENT from './types/receive' +import * as BUY_CREDITS from './types/buyCredits' +import * as INVITE_GIFT from './types/inviteGift' + +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 + // REWARDS +} diff --git a/api/pay/inviteGift.js b/api/payIn/types/inviteGift.js similarity index 100% rename from api/pay/inviteGift.js rename to api/payIn/types/inviteGift.js diff --git a/api/pay/itemCreate.js b/api/payIn/types/itemCreate.js similarity index 99% rename from api/pay/itemCreate.js rename to api/payIn/types/itemCreate.js index d6fb603f7..b3da730ec 100644 --- a/api/pay/itemCreate.js +++ b/api/payIn/types/itemCreate.js @@ -1,6 +1,6 @@ import { 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 } from './lib/item' +import { getItemMentions, getMentions, performBotBehavior } from '../lib/item' import { msatsToSats, satsToMsats } from '@/lib/format' import { GqlInputError } from '@/lib/error' diff --git a/api/pay/itemUpdate.js b/api/payIn/types/itemUpdate.js similarity index 98% rename from api/pay/itemUpdate.js rename to api/payIn/types/itemUpdate.js index 8d63bed76..6be16f3c9 100644 --- a/api/pay/itemUpdate.js +++ b/api/payIn/types/itemUpdate.js @@ -1,6 +1,6 @@ import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' -import { uploadFees } from '../resolvers/upload' -import { getItemMentions, getMentions, performBotBehavior } from './lib/item' +import { uploadFees } from '../../resolvers/upload' +import { getItemMentions, getMentions, performBotBehavior } from '../lib/item' import { notifyItemMention, notifyMention } from '@/lib/webPush' import { satsToMsats } from '@/lib/format' diff --git a/api/pay/pollVote.js b/api/payIn/types/pollVote.js similarity index 100% rename from api/pay/pollVote.js rename to api/payIn/types/pollVote.js diff --git a/api/pay/receive.js b/api/payIn/types/receive.js similarity index 100% rename from api/pay/receive.js rename to api/payIn/types/receive.js diff --git a/api/pay/territoryBilling.js b/api/payIn/types/territoryBilling.js similarity index 100% rename from api/pay/territoryBilling.js rename to api/payIn/types/territoryBilling.js diff --git a/api/pay/territoryCreate.js b/api/payIn/types/territoryCreate.js similarity index 96% rename from api/pay/territoryCreate.js rename to api/payIn/types/territoryCreate.js index a9316cdb2..683544aec 100644 --- a/api/pay/territoryCreate.js +++ b/api/payIn/types/territoryCreate.js @@ -1,7 +1,7 @@ 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 { initialTrust } from '../lib/territory' export const anonable = false diff --git a/api/pay/territoryUnarchive.js b/api/payIn/types/territoryUnarchive.js similarity index 97% rename from api/pay/territoryUnarchive.js rename to api/payIn/types/territoryUnarchive.js index 9f79963eb..7d3c0c1b7 100644 --- a/api/pay/territoryUnarchive.js +++ b/api/payIn/types/territoryUnarchive.js @@ -1,7 +1,7 @@ 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 { initialTrust } from '../lib/territory' export const anonable = false diff --git a/api/pay/territoryUpdate.js b/api/payIn/types/territoryUpdate.js similarity index 100% rename from api/pay/territoryUpdate.js rename to api/payIn/types/territoryUpdate.js diff --git a/api/pay/zap.js b/api/payIn/types/zap.js similarity index 100% rename from api/pay/zap.js rename to api/payIn/types/zap.js diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 710efef3c..42b02f3ea 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,65 +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("") walletsUpdatedAt DateTime? - vaultEntries VaultEntry[] @relation("VaultEntries") - proxyReceive Boolean @default(true) - directReceive Boolean @default(true) - DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") - DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") + vaultEntries VaultEntry[] @relation("VaultEntries") + proxyReceive Boolean @default(true) + directReceive Boolean @default(true) + DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") + DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") UserSubTrust UserSubTrust[] PayIn PayIn[] - PayOut PayOut[] + PayInBolt11 PayInBolt11[] + PayOutBolt11 PayOutBolt11[] + PayOutCustodialToken PayOutCustodialToken[] @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -247,6 +249,7 @@ model Wallet { withdrawals Withdrawl[] InvoiceForward InvoiceForward[] DirectPayment DirectPayment[] + PayOutBolt11 PayOutBolt11[] @@index([userId]) @@index([priority]) @@ -269,18 +272,20 @@ model VaultEntry { } 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) - wallet WalletType? - 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) + wallet WalletType? + 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? @@index([userId, createdAt]) } @@ -1045,9 +1050,10 @@ model InvoiceForward { invoiceId Int @unique withdrawlId Int? @unique - invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull) + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) + withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull) + PayOutBolt11 PayOutBolt11[] @@index([invoiceId]) @@index([walletId]) @@ -1073,7 +1079,6 @@ model Withdrawl { wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) invoiceForward InvoiceForward? WalletLog WalletLog[] - PayOut PayOut? @@index([createdAt], map: "Withdrawl.created_at_index") @@index([userId], map: "Withdrawl.userId_index") @@ -1301,6 +1306,8 @@ enum LogLevel { SUCCESS } +// payIn playground + enum PayInType { BUY_CREDITS ITEM_CREATE @@ -1335,6 +1342,8 @@ enum PayInState { enum PayInFailureReason { INVOICE_CREATION_FAILED + INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE + INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY USER_CANCELLED SYSTEM_CANCELLED INVOICE_EXPIRED @@ -1360,10 +1369,11 @@ model PayIn { msatsBefore BigInt? mcreditsBefore BigInt? - pessimisticEnv PessimisticEnv? - payInCustodialTokens PayInCustodialToken[] - payOuts PayOut[] - successor PayIn? @relation("PayInPredecessor") + pessimisticEnv PessimisticEnv? + payInCustodialTokens PayInCustodialToken[] + successor PayIn? @relation("PayInPredecessor") + payOutCustodialTokens PayOutCustodialToken[] + payOutBolt11 PayOutBolt11? @@index([userId]) @@index([payInType]) @@ -1403,7 +1413,7 @@ enum PayOutType { INVITE_GIFT_RECEIVE } -model PayOut { +model PayOutCustodialToken { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @@ -1416,9 +1426,61 @@ model PayOut { mtokens BigInt custodialTokenType CustodialTokenType? - withdrawlId Int? @unique - withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: Cascade) msatsBefore BigInt? mcreditsBefore BigInt? } + +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? + + @@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 + msatsFeePaying BigInt + msatsFeePaid BigInt? + status WithdrawlStatus? + walletId Int? + payInId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) + payIn PayIn @relation(fields: [payInId], references: [id]) + WalletLog WalletLog[] + InvoiceForward InvoiceForward? @relation(fields: [invoiceForwardId], references: [id]) + invoiceForwardId Int? + + @@index([createdAt]) + @@index([userId]) + @@index([hash]) + @@index([walletId]) + @@index([status]) +} From e7c93f4f6d95fd874668adafcbbfa2d7fa49e0bb Mon Sep 17 00:00:00 2001 From: k00b Date: Mon, 21 Apr 2025 15:50:32 -0500 Subject: [PATCH 04/10] conceptually working state machine --- api/payIn/index.js | 217 ++++++++----- api/payIn/transitions.js | 552 ++++++++++++++++++++++++++++++++-- api/payIn/types/withdrawal.js | 7 + prisma/schema.prisma | 69 +++-- 4 files changed, 712 insertions(+), 133 deletions(-) create mode 100644 api/payIn/types/withdrawal.js diff --git a/api/payIn/index.js b/api/payIn/index.js index 46ee82255..475451f69 100644 --- a/api/payIn/index.js +++ b/api/payIn/index.js @@ -1,10 +1,11 @@ -import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { Prisma } from '@prisma/client' import { wrapBolt11 } from '@/wallets/server' -import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' +import { createHodlInvoice, createInvoice, parsePaymentRequest, payViaPaymentRequest } from 'ln-service' import { datePivot } from '@/lib/time' import lnd from '../lnd' import payInTypeModules from './types' +import { msatsToSats } from '@/lib/format' export default async function payIn (payInType, payInArgs, context) { try { @@ -36,45 +37,49 @@ export default async function payIn (payInType, payInArgs, context) { } } -async function getPayInCustodialTokens (tx, mCustodialCost, { me, models }) { - if (!me) { +async function getPayInCustodialTokens (tx, mCustodialCost, payIn, { me, models }) { + if (!me || mCustodialCost <= 0n) { return [] } - const { mcredits, msats, mcreditsBefore, msatsBefore } = await tx.$queryRaw` - UPDATE users - SET - -- if we have enough mcredits, subtract the cost from mcredits - -- otherwise, set mcredits to 0 and subtract the rest from msats - mcredits = CASE - WHEN mcredits >= ${mCustodialCost} THEN mcredits - ${mCustodialCost} - ELSE 0 - END, - -- if we have enough msats, subtract the remaining cost from msats - -- otherwise, set msats to 0 - msats = CASE - WHEN mcredits >= ${mCustodialCost} THEN msats - WHEN msats >= ${mCustodialCost} - mcredits THEN msats - (${mCustodialCost} - mcredits) - ELSE 0 - END - FROM (SELECT id, mcredits, msats FROM users WHERE id = ${me.id} FOR UPDATE) before - WHERE users.id = before.id - RETURNING mcredits, msats, before.mcredits as mcreditsBefore, before.msats as msatsBefore` const payInAssets = [] - if (mcreditsBefore > mcredits) { - payInAssets.push({ - payInAssetType: 'CREDITS', - masset: mcreditsBefore - mcredits, - massetBefore: mcreditsBefore - }) + if (isPayableWithCredits(payIn)) { + const { mcreditsSpent, mcreditsBefore } = await tx.$queryRaw` + UPDATE users + SET mcredits = CASE + WHEN mcredits >= ${mCustodialCost} THEN mcredits - ${mCustodialCost} + ELSE mcredits - ((mcredits / 1000) * 1000) + END + FROM (SELECT id, mcredits FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING mcredits - before.mcredits as mcreditsSpent, before.mcredits as mcreditsBefore` + if (mcreditsSpent > 0n) { + payInAssets.push({ + payInAssetType: 'CREDITS', + masset: mcreditsSpent, + massetBefore: mcreditsBefore + }) + } + mCustodialCost -= mcreditsSpent } - if (msatsBefore > msats) { + + const { msatsSpent, msatsBefore } = await tx.$queryRaw` + UPDATE users + SET msats = CASE + WHEN msats >= ${mCustodialCost} THEN msats - ${mCustodialCost} + ELSE msats - ((msats / 1000) * 1000) + END + FROM (SELECT id, msats FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING msats - before.msats as msatsSpent, before.msats as msatsBefore` + if (msatsSpent > 0n) { payInAssets.push({ payInAssetType: 'SATS', - masset: msatsBefore - msats, + masset: msatsSpent, massetBefore: msatsBefore }) } + return payInAssets } @@ -83,27 +88,58 @@ async function isPessimistic (payIn, { me }) { return !me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) } +async function isPayableWithCredits (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) +} + +async 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) +} + +async function isP2P (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) +} + +async function isWithdrawal (payIn) { + return payIn.payInType === 'WITHDRAWAL' || payIn.payInType === 'AUTO_WITHDRAWAL' +} + async function payInPerform (payIn, payInArgs, { me, models }) { const payInModule = payInTypeModules[payIn.payInType] const { payOutCustodialTokens, payOutBolt11 } = await payInModule.getPayOuts(models, payIn, payInArgs, { me }) - // we deduct the p2p payOut from what can be paid with custodial tokens - const mCustodialCost = payIn.mcost - (payOutBolt11?.msats ?? 0n) + const mP2PCost = isP2P(payIn) ? (payOutBolt11?.msats ?? 0n) : 0n + const mCustodialCost = payIn.mcost - mP2PCost const result = await models.$transaction(async tx => { - const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, { me, models }) + const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, payIn, { me, models }) const mCustodialPaid = payInCustodialTokens.reduce((acc, token) => acc + token.mtokens, 0n) - // TODO: what if remainingCost < 1000n or not a multiple of 1000n? - // the remaining cost will be paid with an invoice - const mCostRemaining = mCustodialCost - mCustodialPaid + (payOutBolt11?.msats ?? 0n) + const mCostRemaining = mCustodialCost - mCustodialPaid + mP2PCost + + let payInState = null + if (mCostRemaining > 0n) { + if (!isInvoiceable(payIn)) { + throw new Error('Insufficient funds') + } + payInState = 'PENDING_INVOICE_CREATION' + } else if (isWithdrawal(payIn)) { + payInState = 'PENDING_WITHDRAWAL' + } else { + payInState = 'PAID' + } const payInResult = await tx.payIn.create({ data: { payInType: payIn.payInType, mcost: payIn.mcost, - payInState: 'PENDING_INVOICE_CREATION', - payInStateChangedAt: new Date(), // TODO: set with a trigger + payInState, + payInStateChangedAt: new Date(), userId: payIn.userId, pessimisticEnv: { create: mCostRemaining > 0n && isPessimistic(payIn, { me }) ? { args: payInArgs } : null @@ -124,7 +160,9 @@ async function payInPerform (payIn, payInArgs, { me, models }) { }, include: { payInCustodialTokens: true, - user: true + user: true, + payOutBolt11: true, + payOutCustodialTokens: true } }) @@ -137,49 +175,55 @@ async function payInPerform (payIn, payInArgs, { me, models }) { } // if it's optimistic or already paid, we perform the action - const result = await payInModule.perform(tx, payInResult, payInArgs, { models, me }) + const result = await payInModule.perform?.(tx, payInResult.id, payInArgs, { models, me }) - // if there's remaining cost, we return the result but don't run onPaid - if (mCostRemaining > 0n) { - // transactionally insert a job to check if the required invoice is added - // we can't do it before because we don't know the amount of the invoice - // and we want to refund the custodial tokens if the invoice creation fails - // TODO: consider timeouts of wrapped invoice creation ... ie 30 seconds might not be enough - await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) - VALUES ('checkPayIn', jsonb_build_object('id', ${payInResult.id}::INTEGER), now() + interval '30 seconds', 1000)` + // if it's already paid, we run onPaid and do payOuts in the same transaction + if (payInResult.payInState === 'PAID') { + await onPaid(tx, payInResult.id, { models, me }) + // run non critical side effects in the background + // now that everything is paid + payInModule.nonCriticalSideEffects?.(payInResult.id, { models }).catch(console.error) return { payIn: payInResult, result, - mCostRemaining + mCostRemaining: 0n } } - // if it's already paid, we run onPaid and do payOuts in the same transaction - await onPaid(tx, payInResult, payInArgs, { models, me }) + // transactionally insert a job to check if the required invoice/withdrawal is added + // we can't do it before because we don't know the amount of the invoice + // and we want to refund the custodial tokens if the invoice creation fails + // TODO: consider timeouts of wrapped invoice creation ... ie 30 seconds might not be enough + await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ('checkPayIn', jsonb_build_object('id', ${payInResult.id}::INTEGER), now() + interval '10 seconds', 1000)` return { payIn: payInResult, result, - mCostRemaining: 0n + mCostRemaining } }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) - if (result.mCostRemaining > 0n) { + if (result.payIn.payInState === 'PENDING_INVOICE_CREATION') { try { return { - payIn: await payInAddInvoice(result.mCostRemaining, result.payIn, payInArgs, { models, me }), + payIn: await payInAddInvoice(result.mCostRemaining, result.payIn, { models, me }), result: result.result } } catch (e) { - await models.$transaction(async tx => { - await tx.payIn.update({ - where: { id: payIn.id, payInState: 'PENDING_INVOICE_CREATION' }, - data: { payInState: 'FAILED', payInFailureReason: 'INVOICE_CREATION_FAILED', payInStateChangedAt: new Date() } - }) - await onFail(tx, payIn, payInArgs, { models, me }) - }) + models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ('payInFailed', jsonb_build_object('id', ${result.payIn.id}::INTEGER), now(), 1000)`.catch(console.error) console.error('payInAddInvoice failed', e) throw e } + } else if (result.payIn.payInState === 'PENDING_WITHDRAWAL') { + const { mtokens } = result.payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + payViaPaymentRequest({ + lnd, + request: result.payIn.payOutBolt11.bolt11, + max_fee: msatsToSats(mtokens), + pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, + confidence: LND_PATHFINDING_TIME_PREF_PPM + }).catch(console.error) } return result.result @@ -187,11 +231,11 @@ async function payInPerform (payIn, payInArgs, { me, models }) { const INVOICE_EXPIRE_SECS = 600 -async function createBolt11 (mCostRemaining, payIn, payInArgs, { models, me }) { +async function createBolt11 (mCostRemaining, payIn, { models, me }) { const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) const invoice = await createLNDinvoice({ - description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(payIn, payInArgs, { models, me }), + description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(payIn, { models, me }), mtokens: String(mCostRemaining), expires_at: expiresAt, lnd @@ -199,15 +243,15 @@ async function createBolt11 (mCostRemaining, payIn, payInArgs, { models, me }) { return invoice.request } -// in the case of a zap getPayOuts will return -async function payInAddInvoice (mCostRemaining, payIn, payInArgs, { models, me }) { +// TODO: throw errors that give us PayInFailureReason +async function payInAddInvoice (mCostRemaining, payIn, { models, me }) { let bolt11 = null let payInState = null if (payIn.payOutBolt11) { bolt11 = await wrapBolt11({ msats: mCostRemaining, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models, me }) payInState = 'PENDING_HELD' } else { - bolt11 = await createBolt11(mCostRemaining, payIn, payInArgs, { models, me }) + bolt11 = await createBolt11(mCostRemaining, payIn, { models, me }) payInState = payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING' } @@ -235,8 +279,12 @@ async function payInAddInvoice (mCostRemaining, payIn, payInArgs, { models, me } }) } -export async function onFail (tx, payIn, payInArgs, { me }) { - const payInModule = payInTypeModules[payIn.payInType] +export async function onFail (tx, payInId, { me }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payInCustodialTokens: true } }) + if (!payIn) { + throw new Error('PayIn not found') + } + // refund the custodial tokens for (const payInCustodialToken of payIn.payInCustodialTokens) { await tx.$queryRaw` @@ -245,15 +293,20 @@ export async function onFail (tx, payIn, payInArgs, { me }) { mcredits = mcredits + ${payInCustodialToken.custodialTokenType === 'CREDITS' ? payInCustodialToken.mtokens : 0} WHERE id = ${payIn.userId}` } - await payInModule.onFail(tx, payIn, payInArgs, { me }) + await payInTypeModules[payIn.payInType].onFail?.(tx, payInId, { me }) } -// maybe if payIn has an invoiceForward associated with it, we can use credits or not -async function onPaid (tx, payIn, payInArgs, { models, me }) { - const payInModule = payInTypeModules[payIn.payInType] - const payOuts = await payInModule.getPayOuts(tx, payIn, payInArgs, { me }) +export async function onPaid (tx, payInId) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payOutCustodialTokens: true } }) + if (!payIn) { + throw new Error('PayIn not found') + } - for (const payOut of payOuts) { + 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.$queryRaw` WITH user AS ( UPDATE users @@ -261,14 +314,14 @@ async function onPaid (tx, payIn, payInArgs, { models, me }) { mcredits = mcredits + ${payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0} FROM (SELECT id, mcredits, msats FROM users WHERE id = ${payOut.userId} FOR UPDATE) before WHERE users.id = before.id - RETURNING mcredits, msats, mcreditsBefore, msatsBefore + RETURNING before.mcredits as mcreditsBefore, before.msats as msatsBefore ) - INSERT INTO "payOuts" ("payInId", "payOutType", "mtokens", "custodialTokenType", "msatsBefore", "mcreditsBefore") - VALUES (${payIn.id}, ${payOut.payOutType}, ${payOut.mtokens}, ${payOut.custodialTokenType}, ${payOut.msatsBefore}, ${payOut.mcreditsBefore})` + UPDATE "PayOutCustodialToken" + SET "msatsBefore" = user.msatsBefore, "mcreditsBefore" = user.mcreditsBefore + FROM user + WHERE "id" = ${payOut.userId}` } - await payInModule.onPaid(tx, payIn, payInArgs, { me }) - // run non critical side effects in the background - // now that everything is paid - payInModule.nonCriticalSideEffects?.(payIn, payInArgs, { models, me }).catch(console.error) + const payInModule = payInTypeModules[payIn.payInType] + await payInModule.onPaid?.(tx, payInId) } diff --git a/api/payIn/transitions.js b/api/payIn/transitions.js index dc5920b14..d7892644a 100644 --- a/api/payIn/transitions.js +++ b/api/payIn/transitions.js @@ -1,31 +1,541 @@ +import { datePivot } from '@/lib/time' +import { Prisma } from '@prisma/client' +import { onFail, onPaid } from '.' +import { getInvoice, walletLogger } from '../resolvers/wallet' +import { payInTypeModules } from './types' +import { getPaymentFailureStatus, getPaymentOrNotSent, hodlInvoiceCltvDetails } from '../lnd' +import { cancelHodlInvoice, parsePaymentRequest, payViaPaymentRequest, settleHodlInvoice } from 'ln-service' +import { toPositiveNumber, formatSats, msatsToSats, toPositiveBigInt, formatMsats } from '@/lib/format' +import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' +import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' +import { notifyWithdrawal } from '@/lib/webPush' const PAY_IN_TERMINAL_STATES = ['PAID', 'FAILED'] +const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 } -async function transitionPayIn (payInId, { fromStates, toState, transition }, { models }) { - const payIn = await models.payIn.findUnique({ where: { id: payInId, payInState: { in: fromStates } } }) - if (!payIn) { - throw new Error('PayIn not found') - } +async function transitionPayIn (jobName, { payInId, fromStates, toState, transitionFunc, errorFunc, invoice, withdrawal }, { models, boss, lnd }) { + let payIn + try { + const include = { payInBolt11: true, payOutBolt11: true, pessimisticEnv: true, payOutCustodialTokens: true } + const currentPayIn = await models.payIn.findUnique({ where: { id: payInId }, include }) + + 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() }, + 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 + } + } - if (PAY_IN_TERMINAL_STATES.includes(payIn.payInState)) { - throw new Error('PayIn is in a terminal state') + console.error('unexpected error', error) + errorFunc?.(error, payIn.id, { models, boss }) + await boss.send( + jobName, + { payInId }, + { startAfter: datePivot(new Date(), { seconds: 30 }), priority: 1000 }) + console.error(`${jobName} failed for payIn ${payInId}: ${error}`) + throw error } +} + +// if we experience an unexpected error when holding an invoice, we need aggressively attempt to cancel it +// store the error in the invoice, nonblocking and outside of this tx, finalizing immediately +function errorFunc (error, payInId, { models, boss }) { + models.pessimisticEnv.update({ + where: { payInId }, + data: { + error: error.message + } + }).catch(e => console.error('failed to store payIn error', e)) + boss.send('payInCancel', { payInId, payInFailureReason: 'HELD_INVOICE_UNEXPECTED_ERROR' }, FINALIZE_OPTIONS) + .catch(e => console.error('failed to cancel payIn', e)) +} + +export async function payInWithdrawalPaid ({ data: { payInId, ...args }, models, lnd, boss }) { + const transitionedPayIn = await transitionPayIn('payInWithdrawalPaid', { + payInId, + fromState: 'PENDING_WITHDRAWAL', + toState: 'WITHDRAWAL_PAID', + transition: async (tx, payIn, lndPayOutBolt11) => { + if (!lndPayOutBolt11.is_confirmed) { + throw new Error('withdrawal is not confirmed') + } - if (!Array.isArray(fromStates)) { - fromStates = [fromStates] + // 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) + } + }) + await tx.payOutCustodialToken.create({ + data: { + mtokens: mtokens - toPositiveBigInt(lndPayOutBolt11.payment.fee_mtokens), + payOutType: 'ROUTING_FEE_REFUND', + custodialTokenType: 'SATS' + } + }) + + await onPaid(tx, payIn.id) + + return { + payOutBolt11: { + update: { + status: 'CONFIRMED', + preimage: lndPayOutBolt11.payment.secret + } + } + } + }, + ...args + }, { models, lnd, boss }) + + if (transitionedPayIn) { + await notifyWithdrawal(transitionedPayIn) + const logger = walletLogger({ wallet: transitionedPayIn.payOutBolt11.wallet, models }) + logger?.ok( + `↙ payment received: ${formatSats(msatsToSats(transitionedPayIn.payOutBolt11.msats))}`, { + withdrawalId: transitionedPayIn.payOutBolt11.id + }) } +} - // TODO: retry on failure - await models.$transaction(async tx => { - const updatedPayIn = await tx.payIn.update({ - where: { id: payInId, payInState: { in: fromStates } }, - data: { payInState: toState }, - include: { - payInCustodialTokens: true, - payInBolt11: true, - pessimisticEnv: true +export async function payInWithdrawalFailed ({ data: { payInId, ...args }, models, lnd, boss }) { + let message + const transitionedPayIn = await transitionPayIn('payInWithdrawalFailed', { + payInId, + fromState: 'PENDING_WITHDRAWAL', + toState: 'WITHDRAWAL_FAILED', + transition: async (tx, payIn, lndPayOutBolt11) => { + if (!lndPayOutBolt11?.is_failed) { + throw new Error('withdrawal is not failed') } - }) - await transition(tx, updatedPayIn) + + await onFail(tx, payIn.id) + + const { status, message: failureMessage } = getPaymentFailureStatus(lndPayOutBolt11) + message = failureMessage + + return { + payInFailureReason: 'WITHDRAWAL_FAILED', + payOutBolt11: { + update: { status } + } + } + } }) + + if (transitionedPayIn) { + const { mtokens } = transitionedPayIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + const logger = walletLogger({ wallet: transitionedPayIn.payOutBolt11.wallet, models }) + logger?.error(`incoming payment failed: ${message}`, { + bolt11: transitionedPayIn.payOutBolt11.bolt11, + max_fee: formatMsats(mtokens) + }) + } +} + +export async function payInPaid ({ data: { payInId, ...args }, models, lnd, boss }) { + const transitionedPayIn = await transitionPayIn('payInPaid', { + payInId, + fromState: ['HELD', 'PENDING', 'FORWARDED'], + toState: 'PAID', + transition: async (tx, payIn, lndPayInBolt11) => { + if (!lndPayInBolt11.is_confirmed) { + throw new Error('invoice is not confirmed') + } + + await onPaid(tx, payIn.id) + + // 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'))` + + return { + payInBolt11: { + update: { + confirmedAt: new Date(lndPayInBolt11.confirmed_at), + confirmedIndex: lndPayInBolt11.confirmed_index, + msatsReceived: BigInt(lndPayInBolt11.received_mtokens) + } + } + } + }, + ...args + }, { models, lnd, boss }) + + 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: { payInId, ...args }, models, lnd, boss }) { + const transitionedPayIn = await transitionPayIn('payInForwarding', { + payInId, + fromState: 'PENDING_HELD', + toState: 'FORWARDING', + transition: 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 + boss.send('payInCancel', { payInId, payInFailureReason: 'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW' }, FINALIZE_OPTIONS) + .catch(e => console.error('failed to cancel payIn', e)) + throw new Error('invoice has insufficient cltv delta for forward') + } + + // 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].perform(tx, payIn.id, payIn.pessimisticEnv.args) + } + } + } + + return { + payInBolt11: { + update: { + msatsReceived: BigInt(lndPayInBolt11.received_mtokens), + expiryHeight, + acceptHeight + } + }, + pessimisticEnv + } + }, + errorFunc, + ...args + }, { models, lnd, boss }) + + // 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 { bolt11, expiryHeight, acceptHeight } = transitionedPayIn.payInBolt11 + const { mtokens } = 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', mtokens, 'max_timeout_height', maxTimeoutHeight, + 'accept_height', acceptHeight, 'expiry_height', expiryHeight) + + payViaPaymentRequest({ + lnd, + request: bolt11, + max_fee_mtokens: String(mtokens), + pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, + confidence: LND_PATHFINDING_TIME_PREF_PPM, + max_timeout_height: maxTimeoutHeight + }).catch(console.error) + } +} + +// this finalizes the forward by settling the incoming invoice after the outgoing payment is confirmed +export async function payInForwarded ({ data: { payInId, withdrawal, ...args }, models, lnd, boss }) { + const transitionedPayIn = await transitionPayIn('payInForwarded', { + payInId, + fromState: 'FORWARDING', + toState: 'FORWARDED', + transition: async ({ tx, payIn, lndPayInBolt11 }) => { + if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_confirmed)) { + throw new Error('invoice is not held') + } + + const { hash, createdAt } = payIn.payOutBolt11 + const { payment, is_confirmed: isConfirmed } = withdrawal ?? + await getPaymentOrNotSent({ id: hash, lnd, createdAt }) + if (!isConfirmed) { + throw new Error('payment is not confirmed') + } + + // 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', + msatsFeePaid: BigInt(payment.fee_mtokens), + 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 } + } + ] + } + } + }, + ...args + }, { models, lnd, boss }) + + if (transitionedPayIn) { + const withdrawal = transitionedPayIn.payOutBolt11 + + const logger = walletLogger({ wallet: transitionedPayIn.payOutBolt11.wallet, models }) + logger.ok( + `↙ payment received: ${formatSats(msatsToSats(Number(withdrawal.msatsPaid)))}`, { + payInId: transitionedPayIn.id + }) + } + + return transitionedPayIn +} + +// when the pending forward fails, we need to cancel the incoming invoice +export async function payInFailedForward ({ data: { payInId, withdrawal: pWithdrawal, ...args }, models, lnd, boss }) { + let message + const transitionedPayIn = await transitionPayIn('payInFailedForward', { + payInId, + fromState: 'FORWARDING', + toState: 'FAILED_FORWARD', + transition: async ({ tx, payIn, lndPayInBolt11 }) => { + if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_cancelled)) { + throw new Error('invoice is not held') + } + + const { hash, createdAt } = payIn.payOutBolt11 + const withdrawal = pWithdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt }) + + if (!(withdrawal?.is_failed || withdrawal?.notSent)) { + throw new Error('payment has 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(withdrawal) + message = failureMessage + + return { + payOutBolt11: { + update: { + status + } + } + } + }, + ...args + }, { models, lnd, boss }) + + 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: { payInId, ...args }, models, lnd, boss }) { + return await transitionPayIn('payInHeld', { + payInId, + fromState: 'PENDING_HELD', + toState: 'HELD', + transition: 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 payInTypeModules[payIn.payInType].perform(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 + } + }, + errorFunc, + ...args + }, { models, lnd, boss }) +} + +export async function payInCancel ({ data: { payInId, payInFailureReason, ...args }, models, lnd, boss }) { + const transitionedPayIn = await transitionPayIn('payInCancel', { + payInId, + fromState: ['HELD', 'PENDING', 'PENDING_HELD', 'FAILED_FORWARD'], + toState: 'CANCELLED', + transition: async ({ tx, payIn, lndPayInBolt11 }) => { + if (lndPayInBolt11.is_confirmed) { + throw new Error('invoice is confirmed already') + } + + await cancelHodlInvoice({ id: payIn.payInBolt11.hash, lnd }) + + return { + payInFailureReason: payInFailureReason ?? 'SYSTEM_CANCELLED' + } + }, + ...args + }, { models, lnd, boss }) + + 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: { payInId, ...args }, models, lnd, boss }) { + return await transitionPayIn('payInFailed', { + payInId, + // any of these states can transition to FAILED + fromState: ['PENDING', 'PENDING_HELD', 'HELD', 'FAILED_FORWARD', 'CANCELLED', 'PENDING_INVOICE_CREATION'], + toState: 'FAILED', + transition: 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) + + const payInFailureReason = !lndPayInBolt11 + ? 'INVOICE_CREATION_FAILED' + : (payIn.payInFailureReason ?? 'INVOICE_EXPIRED') + + return { + payInFailureReason, + payInBolt11 + } + }, + ...args + }, { models, lnd, boss }) } -export default transitionPayIn diff --git a/api/payIn/types/withdrawal.js b/api/payIn/types/withdrawal.js new file mode 100644 index 000000000..84d11dbe6 --- /dev/null +++ b/api/payIn/types/withdrawal.js @@ -0,0 +1,7 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS +] diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 42b02f3ea..3e580aa56 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -519,6 +519,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 @@ -857,6 +858,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") @@ -1050,10 +1052,9 @@ model InvoiceForward { invoiceId Int @unique withdrawlId Int? @unique - invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull) - PayOutBolt11 PayOutBolt11[] + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) + withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull) @@index([invoiceId]) @@index([walletId]) @@ -1328,6 +1329,9 @@ enum PayInType { enum PayInState { PENDING_INVOICE_CREATION + PENDING_WITHDRAWAL + WITHDRAWAL_PAID + WITHDRAWAL_FAILED PENDING PENDING_HELD HELD @@ -1336,7 +1340,7 @@ enum PayInState { FORWARDING FORWARDED FAILED_FORWARD - CANCELING + CANCELLED RETRYING } @@ -1344,6 +1348,11 @@ enum PayInFailureReason { INVOICE_CREATION_FAILED INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY + 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 @@ -1407,10 +1416,14 @@ model PessimisticEnv { enum PayOutType { TERRITORY_REVENUE REWARDS_POOL - PROXY_PAYMENT_RECEIVE - ZAP_RECEIVE - REWARDS_RECEIVE - INVITE_GIFT_RECEIVE + ROUTING_FEE + ROUTING_FEE_REFUND + PROXY_PAYMENT + ZAP + REWARD + INVITE_GIFT + WITHDRAWAL + AUTO_WITHDRAWAL } model PayOutCustodialToken { @@ -1425,7 +1438,7 @@ model PayOutCustodialToken { payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) mtokens BigInt - custodialTokenType CustodialTokenType? + custodialTokenType CustodialTokenType msatsBefore BigInt? mcreditsBefore BigInt? @@ -1457,26 +1470,22 @@ model PayInBolt11 { } 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 - msatsFeePaying BigInt - msatsFeePaid BigInt? - status WithdrawlStatus? - walletId Int? - payInId Int @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) - payIn PayIn @relation(fields: [payInId], references: [id]) - WalletLog WalletLog[] - InvoiceForward InvoiceForward? @relation(fields: [invoiceForwardId], references: [id]) - invoiceForwardId Int? + 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? + walletId Int? + payInId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) + payIn PayIn @relation(fields: [payInId], references: [id]) + WalletLog WalletLog[] @@index([createdAt]) @@index([userId]) From 396d643b7d5311bc0ada4e7d2e7e9a81a1ddbf98 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 22 Apr 2025 08:36:21 -0500 Subject: [PATCH 05/10] lots of state machine work --- api/payIn/index.js | 182 ++++++++++++++------------ api/payIn/lib/item.js | 6 +- api/payIn/transitions.js | 5 - api/payIn/types/boost.js | 95 +++++++------- api/payIn/types/buyCredits.js | 29 ++-- api/payIn/types/donate.js | 28 ++-- api/payIn/types/downZap.js | 72 ++++------ api/payIn/types/index.js | 5 +- api/payIn/types/inviteGift.js | 10 +- api/payIn/types/itemCreate.js | 146 ++++++--------------- api/payIn/types/itemUpdate.js | 70 +++++----- api/payIn/types/pollVote.js | 49 ++----- api/payIn/types/proxyPayment.js | 81 ++++++++++++ api/payIn/types/receive.js | 80 ----------- api/payIn/types/territoryBilling.js | 23 +++- api/payIn/types/territoryCreate.js | 23 +++- api/payIn/types/territoryUnarchive.js | 24 +++- api/payIn/types/territoryUpdate.js | 20 +-- api/payIn/types/withdrawal.js | 33 +++++ api/payingAction/index.js | 64 --------- prisma/schema.prisma | 40 +++++- 21 files changed, 511 insertions(+), 574 deletions(-) create mode 100644 api/payIn/types/proxyPayment.js delete mode 100644 api/payIn/types/receive.js delete mode 100644 api/payingAction/index.js diff --git a/api/payIn/index.js b/api/payIn/index.js index 475451f69..32bc85c58 100644 --- a/api/payIn/index.js +++ b/api/payIn/index.js @@ -7,9 +7,8 @@ import lnd from '../lnd' import payInTypeModules from './types' import { msatsToSats } from '@/lib/format' -export default async function payIn (payInType, payInArgs, context) { +export default async function payIn (payInType, payInArgs, { models, me }) { try { - const { me } = context const payInModule = payInTypeModules[payInType] console.group('payIn', payInType, payInArgs) @@ -22,13 +21,17 @@ export default async function payIn (payInType, payInArgs, context) { throw new Error('You must be logged in to perform this action') } + // payIn = getInitialPayIn(models, payInArgs, { me }) + // onInitialize(models, payIn, payInArgs, { me }) + // then depending on payIn.payInState, we do what's required + const payIn = { payInType, userId: me?.id ?? USER_ID.anon, - cost: await payInModule.getCost(payInArgs, context) + mcost: await payInModule.getCost(models, payInArgs, { me }) } - return await payInPerform(payIn, payInArgs, context) + return await payInPerform(models, payIn, payInArgs, { me }) } catch (e) { console.error('performPaidAction failed', e) throw e @@ -37,79 +40,7 @@ export default async function payIn (payInType, payInArgs, context) { } } -async function getPayInCustodialTokens (tx, mCustodialCost, payIn, { me, models }) { - if (!me || mCustodialCost <= 0n) { - return [] - } - - const payInAssets = [] - if (isPayableWithCredits(payIn)) { - const { mcreditsSpent, mcreditsBefore } = await tx.$queryRaw` - UPDATE users - SET mcredits = CASE - WHEN mcredits >= ${mCustodialCost} THEN mcredits - ${mCustodialCost} - ELSE mcredits - ((mcredits / 1000) * 1000) - END - FROM (SELECT id, mcredits FROM users WHERE id = ${me.id} FOR UPDATE) before - WHERE users.id = before.id - RETURNING mcredits - before.mcredits as mcreditsSpent, before.mcredits as mcreditsBefore` - if (mcreditsSpent > 0n) { - payInAssets.push({ - payInAssetType: 'CREDITS', - masset: mcreditsSpent, - massetBefore: mcreditsBefore - }) - } - mCustodialCost -= mcreditsSpent - } - - const { msatsSpent, msatsBefore } = await tx.$queryRaw` - UPDATE users - SET msats = CASE - WHEN msats >= ${mCustodialCost} THEN msats - ${mCustodialCost} - ELSE msats - ((msats / 1000) * 1000) - END - FROM (SELECT id, msats FROM users WHERE id = ${me.id} FOR UPDATE) before - WHERE users.id = before.id - RETURNING msats - before.msats as msatsSpent, before.msats as msatsBefore` - if (msatsSpent > 0n) { - payInAssets.push({ - payInAssetType: 'SATS', - masset: msatsSpent, - massetBefore: msatsBefore - }) - } - - return payInAssets -} - -async function isPessimistic (payIn, { me }) { - const payInModule = payInTypeModules[payIn.payInType] - return !me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) -} - -async function isPayableWithCredits (payIn) { - const payInModule = payInTypeModules[payIn.payInType] - return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) -} - -async 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) -} - -async function isP2P (payIn) { - const payInModule = payInTypeModules[payIn.payInType] - return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) -} - -async function isWithdrawal (payIn) { - return payIn.payInType === 'WITHDRAWAL' || payIn.payInType === 'AUTO_WITHDRAWAL' -} - -async function payInPerform (payIn, payInArgs, { me, models }) { +async function payInPerform (models, payIn, payInArgs, { me }) { const payInModule = payInTypeModules[payIn.payInType] const { payOutCustodialTokens, payOutBolt11 } = await payInModule.getPayOuts(models, payIn, payInArgs, { me }) @@ -175,7 +106,7 @@ async function payInPerform (payIn, payInArgs, { me, models }) { } // if it's optimistic or already paid, we perform the action - const result = await payInModule.perform?.(tx, payInResult.id, payInArgs, { models, me }) + const result = await payInModule.onPending?.(tx, payInResult.id, payInArgs, { models, me }) // if it's already paid, we run onPaid and do payOuts in the same transaction if (payInResult.payInState === 'PAID') { @@ -229,13 +160,85 @@ async function payInPerform (payIn, payInArgs, { me, models }) { return result.result } +async function getPayInCustodialTokens (tx, mCustodialCost, payIn, { me, models }) { + if (!me || mCustodialCost <= 0n) { + return [] + } + + const payInAssets = [] + if (isPayableWithCredits(payIn)) { + const { mcreditsSpent, mcreditsBefore } = await tx.$queryRaw` + UPDATE users + SET mcredits = CASE + WHEN mcredits >= ${mCustodialCost} THEN mcredits - ${mCustodialCost} + ELSE mcredits - ((mcredits / 1000) * 1000) + END + FROM (SELECT id, mcredits FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING mcredits - before.mcredits as mcreditsSpent, before.mcredits as mcreditsBefore` + if (mcreditsSpent > 0n) { + payInAssets.push({ + payInAssetType: 'CREDITS', + masset: mcreditsSpent, + massetBefore: mcreditsBefore + }) + } + mCustodialCost -= mcreditsSpent + } + + const { msatsSpent, msatsBefore } = await tx.$queryRaw` + UPDATE users + SET msats = CASE + WHEN msats >= ${mCustodialCost} THEN msats - ${mCustodialCost} + ELSE msats - ((msats / 1000) * 1000) + END + FROM (SELECT id, msats FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING msats - before.msats as msatsSpent, before.msats as msatsBefore` + if (msatsSpent > 0n) { + payInAssets.push({ + payInAssetType: 'SATS', + masset: msatsSpent, + massetBefore: msatsBefore + }) + } + + return payInAssets +} + +async function isPessimistic (payIn, { me }) { + const payInModule = payInTypeModules[payIn.payInType] + return !me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) +} + +async function isPayableWithCredits (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) +} + +async 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) +} + +async function isP2P (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) +} + +async function isWithdrawal (payIn) { + return payIn.payInType === 'WITHDRAWAL' +} + const INVOICE_EXPIRE_SECS = 600 async function createBolt11 (mCostRemaining, payIn, { models, me }) { const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) const invoice = await createLNDinvoice({ - description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(payIn, { models, me }), + description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(models, payIn.id, { me }), mtokens: String(mCostRemaining), expires_at: expiresAt, lnd @@ -297,7 +300,7 @@ export async function onFail (tx, payInId, { me }) { } export async function onPaid (tx, payInId) { - const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payOutCustodialTokens: true } }) + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payOutCustodialTokens: true, payOutBolt11: true } }) if (!payIn) { throw new Error('PayIn not found') } @@ -311,7 +314,9 @@ export async function onPaid (tx, payInId) { WITH user AS ( UPDATE users SET msats = msats + ${payOut.custodialTokenType === 'SATS' ? payOut.mtokens : 0}, - mcredits = mcredits + ${payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0} + "stackedMsats" = "stackedMsats" + ${!isWithdrawal(payIn) ? payOut.mtokens : 0}, + mcredits = mcredits + ${payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0}, + "stackedMcredits" = "stackedMcredits" + ${!isWithdrawal(payIn) && payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0} FROM (SELECT id, mcredits, msats FROM users WHERE id = ${payOut.userId} FOR UPDATE) before WHERE users.id = before.id RETURNING before.mcredits as mcreditsBefore, before.msats as msatsBefore @@ -322,6 +327,21 @@ export async function onPaid (tx, payInId) { WHERE "id" = ${payOut.userId}` } + if (!isWithdrawal(payIn)) { + if (payIn.payOutBolt11) { + await tx.$queryRaw` + UPDATE users + SET msats = msats + ${payIn.payOutBolt11.msats}, + "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) } diff --git a/api/payIn/lib/item.js b/api/payIn/lib/item.js index 879b1cb53..f7ed0156a 100644 --- a/api/payIn/lib/item.js +++ b/api/payIn/lib/item.js @@ -2,7 +2,7 @@ import { USER_ID } from '@/lib/constants' import { deleteReminders, getDeleteAt, getRemindAt } from '@/lib/item' import { parseInternalLinks } from '@/lib/url' -export async function getMentions ({ text }, { me, tx }) { +export async function getMentions (tx, { text }, { me }) { const mentionPattern = /\B@[\w_]+/gi const names = text.match(mentionPattern)?.map(m => m.slice(1)) if (names?.length > 0) { @@ -21,7 +21,7 @@ export async function getMentions ({ text }, { me, tx }) { return [] } -export const getItemMentions = async ({ text }, { me, tx }) => { +export const getItemMentions = async (tx, { text }, { me }) => { const linkPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+[a-zA-Z0-9/?=]*`, 'gi') const refs = text.match(linkPattern)?.map(m => { try { @@ -45,7 +45,7 @@ export const getItemMentions = async ({ text }, { me, tx }) => { return [] } -export async function performBotBehavior ({ text, id }, { me, tx }) { +export async function performBotBehavior (tx, { text, id }, { me }) { // delete any existing deleteItem or reminder jobs for this item const userId = me?.id || USER_ID.anon id = Number(id) diff --git a/api/payIn/transitions.js b/api/payIn/transitions.js index d7892644a..be017e2e3 100644 --- a/api/payIn/transitions.js +++ b/api/payIn/transitions.js @@ -202,11 +202,6 @@ export async function payInPaid ({ data: { payInId, ...args }, models, lnd, boss await onPaid(tx, payIn.id) - // 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'))` - return { payInBolt11: { update: { diff --git a/api/payIn/types/boost.js b/api/payIn/types/boost.js index 2030f8100..7f0cf9666 100644 --- a/api/payIn/types/boost.js +++ b/api/payIn/types/boost.js @@ -1,5 +1,5 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' -import { msatsToSats, satsToMsats } from '@/lib/format' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' export const anonable = false @@ -9,55 +9,53 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC ] -export async function getCost ({ sats }) { +export async function getCost (models, { sats }, { me }) { return satsToMsats(sats) } -export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) { - itemId = parseInt(itemId) +export async function getPayOuts (models, payIn, { sats, id }, { me }) { + const item = await models.item.findUnique({ where: { id }, include: { sub: true } }) - let invoiceData = {} - if (invoiceId) { - invoiceData = { invoiceId, invoiceActionState: 'PENDING' } - // store a reference to the item in the invoice - await tx.invoice.update({ - where: { id: invoiceId }, - data: { actionId: itemId } - }) + const revenueMsats = satsToMsats(sats * item.sub.rewardsPct / 100) + const rewardMsats = satsToMsats(sats - revenueMsats) + + return { + payOutCustodialTokens: [ + { + payOutType: 'TERRITORY_REVENUE', + userId: item.sub.userId, + mtokens: revenueMsats, + custodialTokenType: 'SATS' + }, + { + payOutType: 'REWARD_POOL', + userId: null, + mtokens: rewardMsats, + custodialTokenType: 'SATS' + } + ] } +} - const act = await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'BOOST', ...invoiceData } }) +// TODO: continue with renaming perform to onPending? +// TODO: migrate ItemAct to this simple model of itemId and payInId? +export async function onPending (tx, payInId, { sats, id }, { me }) { + const itemId = parseInt(id) - const [{ path }] = await tx.$queryRaw` - SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` - return { id: itemId, sats, act: 'BOOST', path, actId: act.id } + return await tx.itemAct.create({ + data: { + itemId, + payInId + } + }) } -export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - const [{ id, path }] = await tx.$queryRaw` - SELECT "Item".id, ltree2text(path) as path - FROM "Item" - JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` - return { id, sats: msatsToSats(cost), act: 'BOOST', path } +export async function onRetry (tx, oldPayInId, newPayInId) { + await tx.itemAct.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } -export async function onPaid ({ invoice, actId }, { tx }) { - let itemAct - if (invoice) { - await tx.itemAct.updateMany({ - where: { invoiceId: invoice.id }, - data: { - invoiceActionState: 'PAID' - } - }) - itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } }) - } else if (actId) { - itemAct = await tx.itemAct.findFirst({ where: { id: actId } }) - } else { - throw new Error('No invoice or actId') - } +export async function onPaid (tx, payInId) { + const itemAct = await tx.itemAct.findUnique({ where: { payInId }, include: { item: true } }) // increment boost on item await tx.item.update({ @@ -67,16 +65,23 @@ export async function onPaid ({ invoice, actId }, { tx }) { } }) + // TODO: migrate SubAct to this simple model of subName and payInId? + // ??? is this the right place for this? + await tx.subAct.create({ + data: { + subName: itemAct.item.subName, + payInId + } + }) + + // 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', ${itemAct.itemId}::INTEGER), 21, true, now() + interval '30 days', now() + interval '40 days')` } -export async function onFail ({ invoice }, { tx }) { - await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) -} - -export async function describe ({ id: itemId, sats }, { actionId, cost }) { - return `SN: boost ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}` +export async function describe (models, payInId, { me }) { + const itemAct = await models.itemAct.findUnique({ where: { payInId }, include: { item: true } }) + return `SN: boost #${itemAct.itemId} by ${numWithUnits(msatsToSats(itemAct.msats), { abbreviate: false })}` } diff --git a/api/payIn/types/buyCredits.js b/api/payIn/types/buyCredits.js index b0851817c..4f39f7420 100644 --- a/api/payIn/types/buyCredits.js +++ b/api/payIn/types/buyCredits.js @@ -1,5 +1,5 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' -import { satsToMsats } from '@/lib/format' +import { numWithUnits, satsToMsats } from '@/lib/format' export const anonable = false @@ -8,25 +8,24 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost ({ credits }) { +export async function getCost (models, { credits }, { me }) { return satsToMsats(credits) } -export async function perform ({ credits }, { me, cost, tx }) { - await tx.user.update({ - where: { id: me.id }, - data: { - mcredits: { - increment: cost - } - } - }) - +export async function getPayOuts (models, { credits }, { me }) { return { - credits + payOutCustodialTokens: [ + { + payOutType: 'CREDITS', + userId: me.id, + mtokens: credits, + custodialTokenType: 'CREDITS' + } + ] } } -export async function describe () { - return 'SN: buy fee credits' +export async function describe (models, payInId, { me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId } }) + return `SN: buy ${numWithUnits(payIn.mcost, { abbreviate: false, unitSingular: 'credit', unitPlural: 'credits' })}` } diff --git a/api/payIn/types/donate.js b/api/payIn/types/donate.js index 20f4e7e63..020319f3c 100644 --- a/api/payIn/types/donate.js +++ b/api/payIn/types/donate.js @@ -1,5 +1,5 @@ -import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' -import { satsToMsats } from '@/lib/format' +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' export const anonable = true @@ -9,21 +9,21 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost ({ sats }) { +export async function getCost (models, { sats }, { me }) { return satsToMsats(sats) } -export async function perform ({ sats }, { me, tx }) { - await tx.donation.create({ - data: { - sats, - userId: me?.id ?? USER_ID.anon - } - }) - - return { sats } +export async function getPayOuts (models, { sats }, { me }) { + return { + payOutCustodialTokens: [ + { payOutType: 'REWARDS_POOL', userId: null, mtokens: satsToMsats(sats), custodialTokenType: 'SATS' } + ] + } } -export async function describe (args, context) { - return 'SN: donate to rewards pool' +// TODO: Donation table does not need to exist anymore + +export async function describe (models, payInId, { me }) { + 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 index 968f822e3..f64bcc821 100644 --- a/api/payIn/types/downZap.js +++ b/api/payIn/types/downZap.js @@ -1,5 +1,5 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' -import { msatsToSats, satsToMsats } from '@/lib/format' +import { msatsToSats, satsToMsats, numWithUnits } from '@/lib/format' import { Prisma } from '@prisma/client' export const anonable = false @@ -10,53 +10,40 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC ] -export async function getCost ({ sats }) { +export async function getCost (models, { sats }, { me }) { return satsToMsats(sats) } -export async function perform ({ invoiceId, sats, id: itemId }, { me, cost, tx }) { - itemId = parseInt(itemId) +export async function getPayOuts (models, payIn, { sats, id: itemId }, { me }) { + const item = await models.item.findUnique({ where: { id: parseInt(itemId) }, include: { sub: true } }) + + const revenueMsats = satsToMsats(sats * item.sub.rewardsPct / 100) + const rewardMsats = satsToMsats(sats - revenueMsats) - let invoiceData = {} - if (invoiceId) { - invoiceData = { invoiceId, invoiceActionState: 'PENDING' } - // store a reference to the item in the invoice - await tx.invoice.update({ - where: { id: invoiceId }, - data: { actionId: itemId } - }) + return { + payOutCustodialTokens: [ + { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' }, + { payOutType: 'TERRITORY_REVENUE', userId: item.sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' } + ] } +} - const itemAct = await tx.itemAct.create({ - data: { msats: cost, itemId, userId: me.id, act: 'DONT_LIKE_THIS', ...invoiceData } - }) +export async function onPending (tx, payInId, { sats, id: itemId }, { me }) { + itemId = parseInt(itemId) - const [{ path }] = await tx.$queryRaw`SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` - return { id: itemId, sats, act: 'DONT_LIKE_THIS', path, actId: itemAct.id } + await tx.itemAct.create({ + data: { itemId, payInId } + }) } -export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - const [{ id, path }] = await tx.$queryRaw` - SELECT "Item".id, ltree2text(path) as path - FROM "Item" - JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` - return { id, sats: msatsToSats(cost), act: 'DONT_LIKE_THIS', path } +export async function onRetry (tx, oldPayInId, newPayInId) { + await tx.itemAct.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } -export async function onPaid ({ invoice, actId }, { tx }) { - let itemAct - if (invoice) { - await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) - itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id }, include: { item: true } }) - } else if (actId) { - itemAct = await tx.itemAct.findUnique({ where: { id: actId }, include: { item: true } }) - } else { - throw new Error('No invoice or actId') - } +export async function onPaid (tx, payInId) { + const itemAct = await tx.itemAct.findUnique({ where: { payInId }, include: { payIn: true, item: true } }) - const msats = BigInt(itemAct.msats) + const msats = BigInt(itemAct.payIn.mcost) const sats = msatsToSats(msats) // denormalize downzaps @@ -76,10 +63,10 @@ export async function onPaid ({ invoice, actId }, { tx }) { : Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust" FROM territory LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName" - AND ust."userId" = ${itemAct.userId}::INTEGER + AND ust."userId" = ${itemAct.payIn.userId}::INTEGER ), zap AS ( INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats") - VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER) + VALUES (${itemAct.payIn.userId}::INTEGER, ${itemAct.itemId}::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 @@ -91,10 +78,7 @@ export async function onPaid ({ invoice, actId }, { tx }) { WHERE "Item".id = ${itemAct.itemId}::INTEGER` } -export async function onFail ({ invoice }, { tx }) { - await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) -} - -export async function describe ({ id: itemId, sats }, { cost, actionId }) { - return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}` +export async function describe (models, payInId, { me }) { + const itemAct = await models.itemAct.findUnique({ where: { payInId }, include: { payIn: true, item: true } }) + return `SN: downzap #${itemAct.itemId} for ${numWithUnits(msatsToSats(itemAct.payIn.mcost), { abbreviate: false })}` } diff --git a/api/payIn/types/index.js b/api/payIn/types/index.js index 0aecb8065..02e105370 100644 --- a/api/payIn/types/index.js +++ b/api/payIn/types/index.js @@ -12,6 +12,7 @@ import * as BOOST from './types/boost' import * as PROXY_PAYMENT from './types/receive' import * as BUY_CREDITS from './types/buyCredits' import * as INVITE_GIFT from './types/inviteGift' +import * as WITHDRAWAL from './types/withdrawal' export default { BUY_CREDITS, @@ -27,6 +28,6 @@ export default { TERRITORY_UPDATE, TERRITORY_BILLING, TERRITORY_UNARCHIVE, - PROXY_PAYMENT - // REWARDS + PROXY_PAYMENT, + WITHDRAWAL } diff --git a/api/payIn/types/inviteGift.js b/api/payIn/types/inviteGift.js index 2c24ac401..d0c26a227 100644 --- a/api/payIn/types/inviteGift.js +++ b/api/payIn/types/inviteGift.js @@ -9,7 +9,7 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.REWARD_SATS ] -export async function getCost ({ id }, { models, me }) { +export async function getCost (models, { id }, { me }) { const invite = await models.invite.findUnique({ where: { id, userId: me.id, revoked: false } }) if (!invite) { throw new Error('invite not found') @@ -17,7 +17,7 @@ export async function getCost ({ id }, { models, me }) { return satsToMsats(invite.gift) } -export async function perform ({ id, userId }, { me, cost, tx }) { +export async function onPending (tx, payInId, { id, userId }, { me }) { const invite = await tx.invite.findUnique({ where: { id, userId: me.id, revoked: false } }) @@ -38,14 +38,14 @@ export async function perform ({ id, userId }, { me, cost, tx }) { }, data: { mcredits: { - increment: cost + increment: satsToMsats(invite.gift) }, inviteId: id, referrerId: me.id } }) - return await tx.invite.update({ + await tx.invite.update({ where: { id, userId: me.id, revoked: false, ...(invite.limit ? { giftedCount: { lt: invite.limit } } : {}) }, data: { giftedCount: { @@ -55,6 +55,6 @@ export async function perform ({ id, userId }, { me, cost, tx }) { }) } -export async function nonCriticalSideEffects (_, { me }) { +export async function nonCriticalSideEffects (models, payInId, { me }) { notifyInvite(me.id) } diff --git a/api/payIn/types/itemCreate.js b/api/payIn/types/itemCreate.js index b3da730ec..b0fbcb3f6 100644 --- a/api/payIn/types/itemCreate.js +++ b/api/payIn/types/itemCreate.js @@ -1,7 +1,7 @@ import { 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 } from '../lib/item' -import { msatsToSats, satsToMsats } from '@/lib/format' +import { satsToMsats } from '@/lib/format' import { GqlInputError } from '@/lib/error' export const anonable = true @@ -15,7 +15,7 @@ export const paymentMethods = [ export const DEFAULT_ITEM_COST = 1000n -export async function getBaseCost ({ models, bio, parentId, subName }) { +export async function getBaseCost (models, { bio, parentId, subName }) { if (bio) return DEFAULT_ITEM_COST if (parentId) { @@ -35,8 +35,8 @@ export async function getBaseCost ({ models, bio, parentId, subName }) { return satsToMsats(sub.baseCost) } -export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) { - const baseCost = await getBaseCost({ models, bio, parentId, subName }) +export 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` @@ -56,10 +56,23 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, return freebie ? BigInt(0) : BigInt(cost) } -export async function perform (args, context) { +export async function getPayOuts (models, payIn, { subName, parentId, uploadIds, boost = 0, bio }, { me }) { + const sub = await models.sub.findUnique({ where: { name: subName } }) + const revenueMsats = payIn.mcost * BigInt(sub.rewardsPct) / 100n + const rewardMsats = payIn.mcost - revenueMsats + + return { + payOutCustodialTokens: [ + { payOutType: 'TERRITORY_REVENUE', userId: sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' }, + { payOutType: 'REWARD_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' } + ] + } +} + +// TODO: I've removed consideration of boost needing to be its own payIn because it complicates requirements +// TODO: uploads should just have an itemId +export async function onPending (tx, payInId, args, { me }) { const { invoiceId, parentId, uploadIds = [], forwardUsers = [], options: pollOptions = [], boost = 0, ...data } = args - const { tx, me, cost } = context - const boostMsats = satsToMsats(boost) const deletedUploads = [] for (const uploadId of uploadIds) { @@ -71,30 +84,8 @@ export async function perform (args, context) { throw new Error(`upload(s) ${deletedUploads.join(', ')} are expired, consider reuploading.`) } - let invoiceData = {} - if (invoiceId) { - invoiceData = { invoiceId, invoiceActionState: 'PENDING' } - await tx.upload.updateMany({ - where: { id: { in: uploadIds } }, - data: invoiceData - }) - } - - const itemActs = [] - if (boostMsats > 0) { - itemActs.push({ - msats: boostMsats, act: 'BOOST', userId: data.userId, ...invoiceData - }) - } - if (cost > 0) { - itemActs.push({ - msats: cost - boostMsats, act: 'FEE', userId: data.userId, ...invoiceData - }) - data.cost = msatsToSats(cost - boostMsats) - } - - const mentions = await getMentions(args, context) - const itemMentions = await getItemMentions(args, context) + const mentions = await getMentions(tx, args, { me }) + const itemMentions = await getItemMentions(tx, args, { me }) // start with median vote if (me) { @@ -110,7 +101,7 @@ export async function perform (args, context) { const itemData = { parentId: parentId ? parseInt(parentId) : null, ...data, - ...invoiceData, + payInId, boost, threadSubscriptions: { createMany: { @@ -133,11 +124,6 @@ export async function perform (args, context) { itemUploads: { create: uploadIds.map(id => ({ uploadId: id })) }, - itemActs: { - createMany: { - data: itemActs - } - }, mentions: { createMany: { data: mentions @@ -171,63 +157,17 @@ export async function perform (args, context) { } } - // store a reference to the item in the invoice - if (invoiceId) { - await tx.invoice.update({ - where: { id: invoiceId }, - data: { actionId: item.id } - }) - } - - await performBotBehavior(item, context) - - // ltree is unsupported in Prisma, so we have to query it manually (FUCK!) - return (await tx.$queryRaw` - SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" - FROM "Item" WHERE id = ${item.id}::INTEGER` - )[0] + await performBotBehavior(tx, item, { me }) } -export async function retry ({ invoiceId, newInvoiceId }, { tx }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - await tx.item.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - await tx.upload.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - return (await tx.$queryRaw` - SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" - FROM "Item" WHERE "invoiceId" = ${newInvoiceId}::INTEGER` - )[0] +export async function onRetry (tx, oldPayInId, newPayInId) { + await tx.item.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } -export async function onPaid ({ invoice, id }, context) { - const { tx } = context - let item - - if (invoice) { - item = await tx.item.findFirst({ - where: { invoiceId: invoice.id }, - include: { - user: true - } - }) - await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) - await tx.item.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID', invoicePaidAt: new Date() } }) - await tx.upload.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID', paid: true } }) - } else if (id) { - item = await tx.item.findUnique({ - where: { id }, - include: { - user: true, - itemUploads: { include: { upload: true } } - } - }) - await tx.upload.updateMany({ - where: { id: { in: item.itemUploads.map(({ uploadId }) => uploadId) } }, - data: { - paid: true - } - }) - } else { - throw new Error('No item found') +export async function onPaid (tx, payInId) { + const item = await tx.item.findUnique({ where: { payInId } }) + if (!item) { + throw new Error('Item not found') } await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) @@ -236,15 +176,8 @@ export async function onPaid ({ invoice, id }, context) { 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.boost > 0) { - await tx.$executeRaw` - INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) - VALUES ('expireBoost', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, - now() + interval '30 days', now() + interval '40 days')` - } - if (item.parentId) { - // denormalize ncomments, lastCommentAt, and "weightedComments" for ancestors, and insert into reply table + // denormalize ncomments, lastCommentAt for ancestors, and insert into reply table await tx.$executeRaw` WITH comment AS ( SELECT "Item".*, users.trust @@ -273,9 +206,9 @@ export async function onPaid ({ invoice, id }, context) { } } -export async function nonCriticalSideEffects ({ invoice, id }, { models }) { - const item = await models.item.findFirst({ - where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) }, +export async function nonCriticalSideEffects (models, payInId, { me }) { + const item = await models.item.findUnique({ + where: { payInId }, include: { mentions: true, itemReferrers: { include: { refereeItem: true } }, @@ -298,12 +231,7 @@ export async function nonCriticalSideEffects ({ invoice, id }, { models }) { notifyTerritorySubscribers({ models, item }).catch(console.error) } -export async function onFail ({ invoice }, { tx }) { - await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) - await tx.item.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) - await tx.upload.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) -} - -export async function describe ({ parentId }, context) { - return `SN: create ${parentId ? `reply to #${parentId}` : 'item'}` +export async function describe (models, payInId, { me }) { + const item = await models.item.findUnique({ where: { payInId } }) + return `SN: create ${item.parentId ? `reply to #${item.parentId}` : 'item'}` } diff --git a/api/payIn/types/itemUpdate.js b/api/payIn/types/itemUpdate.js index 6be16f3c9..da6a4b3b4 100644 --- a/api/payIn/types/itemUpdate.js +++ b/api/payIn/types/itemUpdate.js @@ -1,4 +1,4 @@ -import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { uploadFees } from '../../resolvers/upload' import { getItemMentions, getMentions, performBotBehavior } from '../lib/item' import { notifyItemMention, notifyMention } from '@/lib/webPush' @@ -12,23 +12,39 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }) { +export 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) } }) + const old = await models.item.findUnique({ where: { id: parseInt(id) }, include: { payIn: true } }) const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost) - if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') { - throw new Error('creation invoice not paid') + if (cost > 0 && old.payIn.payInState !== 'PAID') { + throw new Error('cannot update item with unpaid invoice') } return cost } -export async function perform (args, context) { +export async function getPayOuts (models, payIn, { id }, { me }) { + const item = await models.item.findUnique({ where: { id: parseInt(id) }, include: { sub: true } }) + + const revenueMsats = payIn.mcost * BigInt(item.sub.rewardsPct) / 100n + const rewardMsats = payIn.mcost - revenueMsats + + return { + payOutCustodialTokens: [ + { payOutType: 'TERRITORY_REVENUE', userId: item.sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' }, + { payOutType: 'REWARD_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' } + ] + } +} + +// TODO: this PayInId cannot be associated with the item, what to do? +export async function onPaid (tx, payInId, { me }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const args = payIn.pessimisticEnv.args const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args - const { tx, me } = context const old = await tx.item.findUnique({ where: { id: parseInt(id) }, include: { @@ -41,13 +57,6 @@ export async function perform (args, context) { }) const newBoost = boost - old.boost - const itemActs = [] - if (newBoost > 0) { - const boostMsats = satsToMsats(newBoost) - itemActs.push({ - msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon - }) - } // createMany is the set difference of the new - old // deleteMany is the set difference of the old - new @@ -56,15 +65,10 @@ export async function perform (args, context) { 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(args, context) - const itemMentions = await getItemMentions(args, context) + const mentions = await getMentions(tx, args, { me }) + const itemMentions = await getItemMentions(tx, args, { me }) const itemUploads = uploadIds.map(id => ({ uploadId: id })) - await tx.upload.updateMany({ - where: { id: { in: uploadIds } }, - data: { paid: true } - }) - // 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({ @@ -87,11 +91,6 @@ export async function perform (args, context) { } } }, - itemActs: { - createMany: { - data: itemActs - } - }, itemForwards: { deleteMany: { userId: { @@ -149,18 +148,12 @@ export async function perform (args, context) { now() + interval '30 days', now() + interval '40 days')` } - await performBotBehavior(args, context) - - // ltree is unsupported in Prisma, so we have to query it manually (FUCK!) - return (await tx.$queryRaw` - SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" - FROM "Item" WHERE id = ${parseInt(id)}::INTEGER` - )[0] + await performBotBehavior(tx, args, { me }) } -export async function nonCriticalSideEffects ({ invoice, id }, { models }) { - const item = await models.item.findFirst({ - where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) }, +export async function nonCriticalSideEffects (models, payInId, { me }) { + const item = await models.item.findUnique({ + where: { payInId }, include: { mentions: true, itemReferrers: { include: { refereeItem: true } }, @@ -178,6 +171,7 @@ export async function nonCriticalSideEffects ({ invoice, id }, { models }) { } } -export async function describe ({ id, parentId }, context) { - return `SN: update ${parentId ? `reply to #${parentId}` : 'post'}` +export async function describe (models, payInId, { me }) { + const item = await models.item.findUnique({ where: { payInId } }) + return `SN: update ${item.parentId ? `reply to #${item.parentId}` : 'post'}` } diff --git a/api/payIn/types/pollVote.js b/api/payIn/types/pollVote.js index d2eb41785..9d35892c5 100644 --- a/api/payIn/types/pollVote.js +++ b/api/payIn/types/pollVote.js @@ -9,7 +9,7 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC ] -export async function getCost ({ id }, { me, models }) { +export async function getCost (models, { id }, { me }) { const pollOption = await models.pollOption.findUnique({ where: { id: parseInt(id) }, include: { item: true } @@ -17,54 +17,31 @@ export async function getCost ({ id }, { me, models }) { return satsToMsats(pollOption.item.pollCost) } -export async function perform ({ invoiceId, id }, { me, cost, tx }) { +export async function onPending (tx, payInId, { id }, { me }) { const pollOption = await tx.pollOption.findUnique({ where: { id: parseInt(id) } }) const itemId = parseInt(pollOption.itemId) - let invoiceData = {} - if (invoiceId) { - invoiceData = { invoiceId, invoiceActionState: 'PENDING' } - // store a reference to the item in the invoice - await tx.invoice.update({ - where: { id: invoiceId }, - data: { actionId: itemId } - }) - } - // the unique index on userId, itemId will prevent double voting - await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'POLL', ...invoiceData } }) - await tx.pollBlindVote.create({ data: { userId: me.id, itemId, ...invoiceData } }) - await tx.pollVote.create({ data: { pollOptionId: pollOption.id, itemId, ...invoiceData } }) + await tx.pollBlindVote.create({ data: { userId: me.id, itemId, payInId } }) + await tx.pollVote.create({ data: { pollOptionId: pollOption.id, itemId, payInId } }) return { id } } -export async function retry ({ invoiceId, newInvoiceId }, { tx }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - await tx.pollBlindVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - await tx.pollVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - - const { pollOptionId } = await tx.pollVote.findFirst({ where: { invoiceId: newInvoiceId } }) - return { id: pollOptionId } +export async function onRetry (tx, oldPayInId, newPayInId) { + await tx.itemAct.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) + await tx.pollBlindVote.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) + await tx.pollVote.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } -export async function onPaid ({ invoice }, { tx }) { - if (!invoice) return - - await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) - await tx.pollBlindVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) +export async function onPaid (tx, payInId) { // anonymize the vote - await tx.pollVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceId: null, invoiceActionState: null } }) -} - -export async function onFail ({ invoice }, { tx }) { - await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) - await tx.pollBlindVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) - await tx.pollVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) + await tx.pollVote.updateMany({ where: { payInId }, data: { payInId: null } }) } -export async function describe ({ id }, { actionId }) { - return `SN: vote on poll #${id ?? actionId}` +export async function describe (models, payInId, { me }) { + const pollOption = await models.pollOption.findUnique({ where: { payInId } }) + return `SN: vote on poll #${pollOption.itemId}` } diff --git a/api/payIn/types/proxyPayment.js b/api/payIn/types/proxyPayment.js new file mode 100644 index 000000000..40c70f155 --- /dev/null +++ b/api/payIn/types/proxyPayment.js @@ -0,0 +1,81 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format' +import { notifyDeposit } from '@/lib/webPush' +import { createUserInvoice } from '@/wallets/server' +import { parsePaymentRequest } from 'ln-service' +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.P2P +] + +export async function getCost (models, { msats }, { me }) { + return toPositiveBigInt(msats) +} + +// 3% to routing fee, 7% to rewards pool, 90% to invoice +export async function getPayOuts (models, payIn, { msats }, { me }) { + const routingFeeMtokens = msats * 3n / 100n + const rewardsPoolMtokens = msats * 7n / 100n + const proxyPaymentMtokens = msats - routingFeeMtokens - rewardsPoolMtokens + + const { invoice: bolt11, wallet } = await createUserInvoice(me.id, { msats: proxyPaymentMtokens }, { models }) + const invoice = await parsePaymentRequest({ request: bolt11 }) + + return { + payOutCustodialTokens: [ + { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }, + { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardsPoolMtokens, custodialTokenType: 'SATS' } + ], + payOutBolt11: { + payOutType: 'PROXY_PAYMENT', + hash: invoice.id, + bolt11, + msats: proxyPaymentMtokens, + userId: me.id, + walletId: wallet.id + } + } +} + +// TODO: all of this needs to be updated elsewhere +export async function onPending (tx, payInId, { comment, lud18Data, noteStr }, { me }) { + await tx.payInBolt11.update({ + where: { payInId }, + data: { + lud18Data: { + create: lud18Data + }, + nostrNote: { + create: { + note: noteStr + } + }, + comment: { + create: { + comment + } + } + } + }) +} + +export async function onPaid (tx, payInId, { me }) { + 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 nonCriticalSideEffects ({ invoice }, { models }) { + await notifyDeposit(invoice.userId, invoice) +} + +export async function describe (models, payInId, { me }) { + const payInBolt11 = await models.payInBolt11.findUnique({ + where: { payInId }, + include: { lud18Data: true, nostrNote: true, comment: true, payIn: { include: { user: true } } } + }) + const { nostrNote, payIn: { user }, msatsRequested } = payInBolt11 + return `SN: ${nostrNote ? 'zap' : 'pay'} ${user?.name ?? ''} ${numWithUnits(msatsToSats(msatsRequested))}` +} diff --git a/api/payIn/types/receive.js b/api/payIn/types/receive.js deleted file mode 100644 index 51945a858..000000000 --- a/api/payIn/types/receive.js +++ /dev/null @@ -1,80 +0,0 @@ -import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' -import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format' -import { notifyDeposit } from '@/lib/webPush' -import { getInvoiceableWallets } from '@/wallets/server' - -export const anonable = false - -export const paymentMethods = [ - PAID_ACTION_PAYMENT_METHODS.P2P, - PAID_ACTION_PAYMENT_METHODS.DIRECT -] - -export async function getCost ({ msats }) { - return toPositiveBigInt(msats) -} - -export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) { - if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null - if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null - - const wallets = await getInvoiceableWallets(me.id, { models }) - if (wallets.length === 0) { - return null - } - - return me.id -} - -export async function getSybilFeePercent () { - return 10n -} - -export async function perform ({ - invoiceId, - comment, - lud18Data, - noteStr -}, { me, tx }) { - return await tx.invoice.update({ - where: { id: invoiceId }, - data: { - comment, - lud18Data, - ...(noteStr ? { desc: noteStr } : {}) - }, - include: { invoiceForward: true } - }) -} - -export async function describe ({ description }, { me, cost, paymentMethod, sybilFeePercent }) { - const fee = paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P - ? cost * BigInt(sybilFeePercent) / 100n - : 0n - return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}` -} - -export async function onPaid ({ invoice }, { tx }) { - if (!invoice) { - throw new Error('invoice is required') - } - - // P2P lnurlp does not need to update the user's balance - if (invoice?.invoiceForward) return - - await tx.user.update({ - where: { id: invoice.userId }, - data: { - mcredits: { - increment: invoice.msatsReceived - } - } - }) -} - -export async function nonCriticalSideEffects ({ invoice }, { models }) { - await notifyDeposit(invoice.userId, invoice) - await models.$executeRaw` - INSERT INTO pgboss.job (name, data) - VALUES ('nip57', jsonb_build_object('hash', ${invoice.hash}))` -} diff --git a/api/payIn/types/territoryBilling.js b/api/payIn/types/territoryBilling.js index 526816f7c..dd4f156c7 100644 --- a/api/payIn/types/territoryBilling.js +++ b/api/payIn/types/territoryBilling.js @@ -10,7 +10,7 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost ({ name }, { models }) { +export async function getCost (models, { name }) { const sub = await models.sub.findUnique({ where: { name @@ -20,7 +20,17 @@ export async function getCost ({ name }, { models }) { return satsToMsats(TERRITORY_PERIOD_COST(sub.billingType)) } -export async function perform ({ name }, { cost, tx }) { +export async function getPayOuts (models, payIn, { name }) { + return { + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: payIn.mcost, custodialTokenType: 'SATS' } + ] + } +} + +export async function onPaid (tx, payInId, { me }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const { args: { name } } = payIn.pessimisticEnv const sub = await tx.sub.findUnique({ where: { name @@ -59,15 +69,14 @@ export async function perform ({ name }, { cost, tx }) { status: 'ACTIVE', SubAct: { create: { - msats: cost, - type: 'BILLING', - userId: sub.userId + payInId } } } }) } -export async function describe ({ name }) { - return `SN: billing for territory ${name}` +export async function describe (models, payInId, { me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { subAct: true } }) + return `SN: billing for territory ${payIn.subAct.subName}` } diff --git a/api/payIn/types/territoryCreate.js b/api/payIn/types/territoryCreate.js index 683544aec..44c6ac32d 100644 --- a/api/payIn/types/territoryCreate.js +++ b/api/payIn/types/territoryCreate.js @@ -11,12 +11,21 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost ({ billingType }) { +export async function getCost (models, { billingType }, { me }) { return satsToMsats(TERRITORY_PERIOD_COST(billingType)) } -export async function perform ({ invoiceId, ...data }, { me, cost, tx }) { - const { billingType } = data +export async function getPayOuts (models, payIn, { name }) { + return { + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: payIn.mcost, custodialTokenType: 'SATS' } + ] + } +} + +export async function onPaid (tx, payInId, { me }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const { billingType, ...data } = payIn.pessimisticEnv.args const billingCost = TERRITORY_PERIOD_COST(billingType) const billedLastAt = new Date() const billPaidUntil = nextBilling(billedLastAt, billingType) @@ -31,9 +40,7 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) { userId: me.id, SubAct: { create: { - msats: cost, - type: 'BILLING', - userId: me.id + payInId } }, SubSubscription: { @@ -51,6 +58,8 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) { return sub } -export async function describe ({ name }) { +export async function describe (models, payInId, { me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const { args: { name } } = payIn.pessimisticEnv return `SN: create territory ${name}` } diff --git a/api/payIn/types/territoryUnarchive.js b/api/payIn/types/territoryUnarchive.js index 7d3c0c1b7..6c82b12b3 100644 --- a/api/payIn/types/territoryUnarchive.js +++ b/api/payIn/types/territoryUnarchive.js @@ -11,11 +11,21 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost ({ billingType }) { +export async function getCost (models, { billingType }, { me }) { return satsToMsats(TERRITORY_PERIOD_COST(billingType)) } -export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) { +export async function getPayOuts (models, payIn, { name }) { + return { + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: payIn.mcost, custodialTokenType: 'SATS' } + ] + } +} + +export async function onPaid (tx, payInId, { me }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const { args: { name, invoiceId, ...data } } = payIn.pessimisticEnv const sub = await tx.sub.findUnique({ where: { name @@ -42,10 +52,8 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) { await tx.subAct.create({ data: { - userId: me.id, - subName: name, - msats: cost, - type: 'BILLING' + payInId, + subName: name } }) @@ -85,6 +93,8 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) { return updatedSub } -export async function describe ({ name }, context) { +export async function describe (models, payInId, { me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const { args: { name } } = payIn.pessimisticEnv return `SN: unarchive territory ${name}` } diff --git a/api/payIn/types/territoryUpdate.js b/api/payIn/types/territoryUpdate.js index 30040a804..b76434a52 100644 --- a/api/payIn/types/territoryUpdate.js +++ b/api/payIn/types/territoryUpdate.js @@ -11,7 +11,7 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost ({ oldName, billingType }, { models }) { +export async function getCost (models, { oldName, billingType }, { me }) { const oldSub = await models.sub.findUnique({ where: { name: oldName @@ -26,7 +26,9 @@ export async function getCost ({ oldName, billingType }, { models }) { return satsToMsats(cost) } -export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx }) { +export async function onPaid (tx, payInId, { me }) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const { args: { oldName, invoiceId, ...data } } = payIn.pessimisticEnv const oldSub = await tx.sub.findUnique({ where: { name: oldName @@ -52,13 +54,11 @@ export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx } data.status = 'ACTIVE' } - if (cost > 0n) { + if (payIn.mcost > 0n) { await tx.subAct.create({ data: { - userId: me.id, - subName: oldName, - msats: cost, - type: 'BILLING' + payInId, + subName: oldName } }) } @@ -78,6 +78,8 @@ export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx } }) } -export async function describe ({ name }, context) { - return `SN: update territory billing ${name}` +export async function describe (models, payInId, { me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const { args: { oldName } } = payIn.pessimisticEnv + return `SN: update territory billing ${oldName}` } diff --git a/api/payIn/types/withdrawal.js b/api/payIn/types/withdrawal.js index 84d11dbe6..638515542 100644 --- a/api/payIn/types/withdrawal.js +++ b/api/payIn/types/withdrawal.js @@ -1,7 +1,40 @@ 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 getCost (models, { bolt11, maxFee, walletId }) { + const invoice = parsePaymentRequest({ request: bolt11 }) + return BigInt(invoice.mtokens) + satsToMsats(maxFee) +} + +export async function getPayOuts (models, payIn, { bolt11, maxFee, walletId }, { me }) { + const invoice = parsePaymentRequest({ request: bolt11 }) + return { + payOutBolt11: { + payOutType: 'WITHDRAWAL', + msats: BigInt(invoice.mtokens), + bolt11: invoice.bolt11, + hash: invoice.hash, + userId: me.id, + walletId + }, + payOutCustodialTokens: [ + { + payOutType: 'ROUTING_FEE', + userId: null, + mtokens: satsToMsats(maxFee) + } + ] + } +} + +export async function describe (models, payInId, { me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { payOutBolt11: true } }) + return `SN: withdraw ${numWithUnits(msatsToSats(payIn.payOutBolt11.msats))}` +} diff --git a/api/payingAction/index.js b/api/payingAction/index.js deleted file mode 100644 index 2ff7117a7..000000000 --- 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, walletId }, { me, models, lnd }) { - try { - console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId) - - 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, - walletId, - autoWithdraw: !!walletId - } - }) - }, { 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/prisma/schema.prisma b/prisma/schema.prisma index 0ee7708c3..6b177e8ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1325,6 +1325,7 @@ enum PayInType { TERRITORY_UNARCHIVE PROXY_PAYMENT REWARDS + WITHDRAWAL } enum PayInState { @@ -1370,7 +1371,7 @@ model PayIn { payInFailureReason PayInFailureReason? // TODO: add check constraint payInStateChangedAt DateTime? // TODO: set with a trigger predecessorId Int? @unique - predecessor PayIn? @relation("PayInPredecessor", fields: [predecessorId], references: [id], onDelete: Cascade) + benefactorId Int? userId Int? user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -1380,10 +1381,14 @@ model PayIn { pessimisticEnv PessimisticEnv? payInCustodialTokens PayInCustodialToken[] - successor PayIn? @relation("PayInPredecessor") payOutCustodialTokens PayOutCustodialToken[] payOutBolt11 PayOutBolt11? + successor PayIn? @relation("PayInPredecessor") + predecessor PayIn? @relation("PayInPredecessor", fields: [predecessorId], references: [id], onDelete: Cascade) + benefactor PayIn? @relation("PayInBenefactor", fields: [benefactorId], references: [id], onDelete: Cascade) + beneficiaries PayIn[] @relation("PayInBenefactor") + @@index([userId]) @@index([payInType]) @@index([predecessorId]) @@ -1423,7 +1428,7 @@ enum PayOutType { REWARD INVITE_GIFT WITHDRAWAL - AUTO_WITHDRAWAL + SYSTEM_REVENUE } model PayOutCustodialToken { @@ -1463,6 +1468,10 @@ model PayInBolt11 { User User? @relation(fields: [userId], references: [id]) userId Int? + lud18Data PayInBolt11Lud18? + nostrNote PayInBolt11NostrNote? + comment PayInBolt11Comment? + @@index([createdAt]) @@index([confirmedIndex]) @@index([confirmedAt]) @@ -1493,3 +1502,28 @@ model PayOutBolt11 { @@index([walletId]) @@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 +} From c1c83802b52aae65f62fb763ae75111d435d13fc Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 24 Apr 2025 15:50:01 -0500 Subject: [PATCH 06/10] more declaritive payIn modules --- api/payIn/index.js | 308 ++++++++------------------ api/payIn/lib/is.js | 28 +++ api/payIn/lib/payInBolt11.js | 38 ++++ api/payIn/lib/payInCustodialTokens.js | 48 ++++ api/payIn/lib/payInPrismaCreate.js | 34 +++ api/payIn/transitions.js | 22 +- api/payIn/types/boost.js | 64 +++--- api/payIn/types/buyCredits.js | 9 +- api/payIn/types/donate.js | 11 +- api/payIn/types/downZap.js | 30 ++- api/payIn/types/inviteGift.js | 26 ++- api/payIn/types/itemCreate.js | 84 +++++-- api/payIn/types/itemUpdate.js | 73 +++--- api/payIn/types/pollVote.js | 31 ++- api/payIn/types/proxyPayment.js | 25 ++- api/payIn/types/territoryBilling.js | 21 +- api/payIn/types/territoryCreate.js | 23 +- api/payIn/types/territoryUnarchive.js | 33 ++- api/payIn/types/territoryUpdate.js | 35 ++- api/payIn/types/withdrawal.js | 10 +- api/payIn/types/zap.js | 92 ++++---- prisma/schema.prisma | 78 +++++-- 22 files changed, 652 insertions(+), 471 deletions(-) create mode 100644 api/payIn/lib/is.js create mode 100644 api/payIn/lib/payInBolt11.js create mode 100644 api/payIn/lib/payInCustodialTokens.js create mode 100644 api/payIn/lib/payInPrismaCreate.js diff --git a/api/payIn/index.js b/api/payIn/index.js index 32bc85c58..5c819e924 100644 --- a/api/payIn/index.js +++ b/api/payIn/index.js @@ -1,11 +1,20 @@ -import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, USER_ID } from '@/lib/constants' import { Prisma } from '@prisma/client' -import { wrapBolt11 } from '@/wallets/server' -import { createHodlInvoice, createInvoice, parsePaymentRequest, payViaPaymentRequest } from 'ln-service' -import { datePivot } from '@/lib/time' +import { payViaPaymentRequest } from 'ln-service' import lnd from '../lnd' import payInTypeModules from './types' import { msatsToSats } from '@/lib/format' +import { getPayInCustodialTokens } from './lib/payInCustodialTokens' +import { getPayInBolt11, getPayInBolt11Wrap } from './lib/payInBolt11' +import { isInvoiceable, isP2P, isPessimistic, isWithdrawal } from './lib/is' +import { payInPrismaCreate } from './lib/payInPrismaCreate' +const PAY_IN_INCLUDE = { + payInCustodialTokens: true, + payOutBolt11: true, + pessimisticEnv: true, + user: true, + payOutCustodialTokens: true +} export default async function payIn (payInType, payInArgs, { models, me }) { try { @@ -21,17 +30,10 @@ export default async function payIn (payInType, payInArgs, { models, me }) { throw new Error('You must be logged in to perform this action') } - // payIn = getInitialPayIn(models, payInArgs, { me }) - // onInitialize(models, payIn, payInArgs, { me }) - // then depending on payIn.payInState, we do what's required - - const payIn = { - payInType, - userId: me?.id ?? USER_ID.anon, - mcost: await payInModule.getCost(models, payInArgs, { me }) - } + me ??= { id: USER_ID.anon } - return await payInPerform(models, payIn, payInArgs, { me }) + const payIn = await payInModule.getInitial(models, payInArgs, { me }) + return await begin(models, payIn, payInArgs, { me }) } catch (e) { console.error('performPaidAction failed', e) throw e @@ -40,250 +42,129 @@ export default async function payIn (payInType, payInArgs, { models, me }) { } } -async function payInPerform (models, payIn, payInArgs, { me }) { - const payInModule = payInTypeModules[payIn.payInType] +async function begin (models, payInInitial, payInArgs, { me }) { + const payInModule = payInTypeModules[payInInitial.payInType] - const { payOutCustodialTokens, payOutBolt11 } = await payInModule.getPayOuts(models, payIn, payInArgs, { me }) - const mP2PCost = isP2P(payIn) ? (payOutBolt11?.msats ?? 0n) : 0n - const mCustodialCost = payIn.mcost - mP2PCost + const { payOutBolt11, beneficiaries } = payInInitial + const mP2PCost = isP2P(payInInitial) ? (payOutBolt11?.msats ?? 0n) : 0n + const mCustodialCost = payInInitial.mcost + beneficiaries.reduce((acc, b) => acc + b.mcost, 0n) - mP2PCost - const result = await models.$transaction(async tx => { - const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, payIn, { me, models }) + const { payIn, mCostRemaining } = await models.$transaction(async tx => { + const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, payInInitial, { me }) const mCustodialPaid = payInCustodialTokens.reduce((acc, token) => acc + token.mtokens, 0n) + // TODO: how to deal with < 1000msats? const mCostRemaining = mCustodialCost - mCustodialPaid + mP2PCost let payInState = null if (mCostRemaining > 0n) { - if (!isInvoiceable(payIn)) { + if (!isInvoiceable(payInInitial)) { throw new Error('Insufficient funds') } - payInState = 'PENDING_INVOICE_CREATION' - } else if (isWithdrawal(payIn)) { + if (mP2PCost > 0n) { + payInState = 'PENDING_INVOICE_WRAP' + } else { + payInState = 'PENDING_INVOICE_CREATION' + } + } else if (isWithdrawal(payInInitial)) { payInState = 'PENDING_WITHDRAWAL' } else { payInState = 'PAID' } - const payInResult = await tx.payIn.create({ + const payIn = await tx.payIn.create({ data: { - payInType: payIn.payInType, - mcost: payIn.mcost, - payInState, - payInStateChangedAt: new Date(), - userId: payIn.userId, + ...payInPrismaCreate({ + ...payInInitial, + payInState, + payInStateChangedAt: new Date() + }), pessimisticEnv: { - create: mCostRemaining > 0n && isPessimistic(payIn, { me }) ? { args: payInArgs } : null - }, - payInCustodialTokens: { - createMany: { - data: payInCustodialTokens - } - }, - payOutCustodialTokens: { - createMany: { - data: payOutCustodialTokens - } - }, - payOutBolt11: { - create: payOutBolt11 + create: isPessimistic(payInInitial, { me }) ? { args: payInArgs } : undefined } }, - include: { - payInCustodialTokens: true, - user: true, - payOutBolt11: true, - payOutCustodialTokens: true - } + include: PAY_IN_INCLUDE }) // if it's pessimistic, we don't perform the action until the invoice is held - if (payInResult.pessimisticEnv) { + if (payIn.pessimisticEnv) { return { - payIn: payInResult, + payIn, mCostRemaining } } // if it's optimistic or already paid, we perform the action - const result = await payInModule.onPending?.(tx, payInResult.id, payInArgs, { models, me }) + await payInModule.onBegin?.(tx, payIn.id, payInArgs, { models, me }) // if it's already paid, we run onPaid and do payOuts in the same transaction - if (payInResult.payInState === 'PAID') { - await onPaid(tx, payInResult.id, { models, me }) - // run non critical side effects in the background - // now that everything is paid - payInModule.nonCriticalSideEffects?.(payInResult.id, { models }).catch(console.error) + if (payIn.payInState === 'PAID') { + await onPaid(tx, payIn.id, { me }) + // run non critical side effects + payInModule.onPaidSideEffects?.(models, payIn.id).catch(console.error) return { - payIn: payInResult, - result, + payIn, mCostRemaining: 0n } } - // transactionally insert a job to check if the required invoice/withdrawal is added - // we can't do it before because we don't know the amount of the invoice - // and we want to refund the custodial tokens if the invoice creation fails - // TODO: consider timeouts of wrapped invoice creation ... ie 30 seconds might not be enough - await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) - VALUES ('checkPayIn', jsonb_build_object('id', ${payInResult.id}::INTEGER), now() + interval '10 seconds', 1000)` + // TODO: create a periodic job that checks if the invoice/withdrawal creation failed + // It will need to consider timeouts of wrapped invoice creation very carefully return { - payIn: payInResult, - result, + payIn, mCostRemaining } }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) - if (result.payIn.payInState === 'PENDING_INVOICE_CREATION') { - try { - return { - payIn: await payInAddInvoice(result.mCostRemaining, result.payIn, { models, me }), - result: result.result - } - } catch (e) { - models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) - VALUES ('payInFailed', jsonb_build_object('id', ${result.payIn.id}::INTEGER), now(), 1000)`.catch(console.error) - console.error('payInAddInvoice failed', e) - throw e - } - } else if (result.payIn.payInState === 'PENDING_WITHDRAWAL') { - const { mtokens } = result.payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + try { + return await afterBegin(models, { payIn, mCostRemaining }, payInArgs, { me }) + } catch (e) { + models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ('payInFailed', jsonb_build_object('id', ${payIn.id}::INTEGER), now(), 1000)`.catch(console.error) + throw e + } +} + +async function afterBegin (models, { payIn, mCostRemaining }, payInArgs, { me }) { + if (payIn.payInState === 'PENDING_INVOICE_CREATION') { + const payInBolt11 = await getPayInBolt11(models, { mCostRemaining, payIn }, { me }) + return await models.payIn.update({ + where: { id: payIn.id }, + data: { + payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING', + payInStateChangedAt: new Date(), + payInBolt11: { create: payInBolt11 } + }, + include: PAY_IN_INCLUDE + }) + } else if (payIn.payInState === 'PENDING_INVOICE_WRAP') { + const payInBolt11 = await getPayInBolt11Wrap(models, { mCostRemaining, payIn }, { me }) + return await models.payIn.update({ + where: { id: payIn.id }, + data: { + payInState: 'PENDING_HELD', + payInStateChangedAt: new Date(), + payInBolt11: { create: payInBolt11 } + }, + include: PAY_IN_INCLUDE + }) + } else if (payIn.payInState === 'PENDING_WITHDRAWAL') { + const { mtokens } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') payViaPaymentRequest({ lnd, - request: result.payIn.payOutBolt11.bolt11, + request: payIn.payOutBolt11.bolt11, max_fee: msatsToSats(mtokens), pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, confidence: LND_PATHFINDING_TIME_PREF_PPM }).catch(console.error) - } - - return result.result -} - -async function getPayInCustodialTokens (tx, mCustodialCost, payIn, { me, models }) { - if (!me || mCustodialCost <= 0n) { - return [] - } - - const payInAssets = [] - if (isPayableWithCredits(payIn)) { - const { mcreditsSpent, mcreditsBefore } = await tx.$queryRaw` - UPDATE users - SET mcredits = CASE - WHEN mcredits >= ${mCustodialCost} THEN mcredits - ${mCustodialCost} - ELSE mcredits - ((mcredits / 1000) * 1000) - END - FROM (SELECT id, mcredits FROM users WHERE id = ${me.id} FOR UPDATE) before - WHERE users.id = before.id - RETURNING mcredits - before.mcredits as mcreditsSpent, before.mcredits as mcreditsBefore` - if (mcreditsSpent > 0n) { - payInAssets.push({ - payInAssetType: 'CREDITS', - masset: mcreditsSpent, - massetBefore: mcreditsBefore - }) - } - mCustodialCost -= mcreditsSpent - } - - const { msatsSpent, msatsBefore } = await tx.$queryRaw` - UPDATE users - SET msats = CASE - WHEN msats >= ${mCustodialCost} THEN msats - ${mCustodialCost} - ELSE msats - ((msats / 1000) * 1000) - END - FROM (SELECT id, msats FROM users WHERE id = ${me.id} FOR UPDATE) before - WHERE users.id = before.id - RETURNING msats - before.msats as msatsSpent, before.msats as msatsBefore` - if (msatsSpent > 0n) { - payInAssets.push({ - payInAssetType: 'SATS', - masset: msatsSpent, - massetBefore: msatsBefore - }) - } - - return payInAssets -} - -async function isPessimistic (payIn, { me }) { - const payInModule = payInTypeModules[payIn.payInType] - return !me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) -} - -async function isPayableWithCredits (payIn) { - const payInModule = payInTypeModules[payIn.payInType] - return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) -} - -async 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) -} - -async function isP2P (payIn) { - const payInModule = payInTypeModules[payIn.payInType] - return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) -} - -async function isWithdrawal (payIn) { - return payIn.payInType === 'WITHDRAWAL' -} - -const INVOICE_EXPIRE_SECS = 600 - -async function createBolt11 (mCostRemaining, payIn, { models, me }) { - const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice - const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) - const invoice = await createLNDinvoice({ - description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(models, payIn.id, { me }), - mtokens: String(mCostRemaining), - expires_at: expiresAt, - lnd - }) - return invoice.request -} - -// TODO: throw errors that give us PayInFailureReason -async function payInAddInvoice (mCostRemaining, payIn, { models, me }) { - let bolt11 = null - let payInState = null - if (payIn.payOutBolt11) { - bolt11 = await wrapBolt11({ msats: mCostRemaining, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models, me }) - payInState = 'PENDING_HELD' + return payIn } else { - bolt11 = await createBolt11(mCostRemaining, payIn, { models, me }) - payInState = payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING' + throw new Error('Invalid payIn begin state') } - - const decodedBolt11 = parsePaymentRequest({ request: bolt11 }) - const expiresAt = new Date(decodedBolt11.expires_at) - const msatsRequested = BigInt(decodedBolt11.mtokens) - - return await models.payIn.update({ - where: { id: payIn.id, payInState: 'PENDING_INVOICE_CREATION' }, - data: { - payInState, - payInStateChangedAt: new Date(), - payInBolt11: { - create: { - hash: decodedBolt11.id, - bolt11, - msatsRequested, - expiresAt - } - } - }, - include: { - payInBolt11: true - } - }) } -export async function onFail (tx, payInId, { me }) { - const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payInCustodialTokens: true } }) +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') } @@ -296,11 +177,15 @@ export async function onFail (tx, payInId, { me }) { mcredits = mcredits + ${payInCustodialToken.custodialTokenType === 'CREDITS' ? payInCustodialToken.mtokens : 0} WHERE id = ${payIn.userId}` } - await payInTypeModules[payIn.payInType].onFail?.(tx, payInId, { me }) + + 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: true, payOutBolt11: true } }) + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payOutCustodialTokens: true, payOutBolt11: true, beneficiaries: true } }) if (!payIn) { throw new Error('PayIn not found') } @@ -344,4 +229,7 @@ export async function onPaid (tx, payInId) { const payInModule = payInTypeModules[payIn.payInType] await payInModule.onPaid?.(tx, payInId) + for (const beneficiary of payIn.beneficiaries) { + await onPaid(tx, beneficiary.id) + } } diff --git a/api/payIn/lib/is.js b/api/payIn/lib/is.js new file mode 100644 index 000000000..c3bd67a20 --- /dev/null +++ b/api/payIn/lib/is.js @@ -0,0 +1,28 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { payInTypeModules } from '../types' + +export async function isPessimistic (payIn, { me }) { + const payInModule = payInTypeModules[payIn.payInType] + return !me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) +} + +export async function isPayableWithCredits (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) +} + +export async 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 async function isP2P (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) +} + +export async function isWithdrawal (payIn) { + return payIn.payInType === 'WITHDRAWAL' +} diff --git a/api/payIn/lib/payInBolt11.js b/api/payIn/lib/payInBolt11.js new file mode 100644 index 000000000..328df983c --- /dev/null +++ b/api/payIn/lib/payInBolt11.js @@ -0,0 +1,38 @@ +import { datePivot } from '@/lib/time' +import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' +import lnd from '@/api/lnd' +import { wrapBolt11 } from '@/wallets/server' +import { payInTypeModules } from '../types' + +const INVOICE_EXPIRE_SECS = 600 + +function payInBolt11FromBolt11 (bolt11) { + const decodedBolt11 = parsePaymentRequest({ request: bolt11 }) + const expiresAt = new Date(decodedBolt11.expires_at) + const msatsRequested = BigInt(decodedBolt11.mtokens) + return { + hash: decodedBolt11.id, + bolt11, + msatsRequested, + expiresAt + } +} + +// TODO: throw errors that give us PayInFailureReason +export async function getPayInBolt11 (models, { mCostRemaining, payIn }, { me }) { + const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice + const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) + const invoice = await createLNDinvoice({ + description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(models, payIn.id, { me }), + mtokens: String(mCostRemaining), + expires_at: expiresAt, + lnd + }) + + return payInBolt11FromBolt11(invoice.request) +} + +export async function getPayInBolt11Wrap (models, { mCostRemaining, payIn }, { me }) { + const bolt11 = await wrapBolt11({ msats: mCostRemaining, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models, me }) + return payInBolt11FromBolt11(bolt11) +} diff --git a/api/payIn/lib/payInCustodialTokens.js b/api/payIn/lib/payInCustodialTokens.js new file mode 100644 index 000000000..2b696853b --- /dev/null +++ b/api/payIn/lib/payInCustodialTokens.js @@ -0,0 +1,48 @@ +import { isPayableWithCredits } from './is' +import { USER_ID } from '@/lib/constants' + +export async function getPayInCustodialTokens (tx, mCustodialCost, payIn, { me }) { + if (!me || me.id === USER_ID.anon || mCustodialCost <= 0n) { + return [] + } + + const payInAssets = [] + if (isPayableWithCredits(payIn)) { + const { mcreditsSpent, mcreditsBefore } = await tx.$queryRaw` + UPDATE users + SET mcredits = CASE + WHEN mcredits >= ${mCustodialCost} THEN mcredits - ${mCustodialCost} + ELSE mcredits - ((mcredits / 1000) * 1000) + END + FROM (SELECT id, mcredits FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING mcredits - before.mcredits as mcreditsSpent, before.mcredits as mcreditsBefore` + if (mcreditsSpent > 0n) { + payInAssets.push({ + payInAssetType: 'CREDITS', + masset: mcreditsSpent, + massetBefore: mcreditsBefore + }) + } + mCustodialCost -= mcreditsSpent + } + + const { msatsSpent, msatsBefore } = await tx.$queryRaw` + UPDATE users + SET msats = CASE + WHEN msats >= ${mCustodialCost} THEN msats - ${mCustodialCost} + ELSE msats - ((msats / 1000) * 1000) + END + FROM (SELECT id, msats FROM users WHERE id = ${me.id} FOR UPDATE) before + WHERE users.id = before.id + RETURNING msats - before.msats as msatsSpent, before.msats as msatsBefore` + if (msatsSpent > 0n) { + payInAssets.push({ + payInAssetType: 'SATS', + masset: msatsSpent, + massetBefore: msatsBefore + }) + } + + return payInAssets +} diff --git a/api/payIn/lib/payInPrismaCreate.js b/api/payIn/lib/payInPrismaCreate.js new file mode 100644 index 000000000..c67acc3a4 --- /dev/null +++ b/api/payIn/lib/payInPrismaCreate.js @@ -0,0 +1,34 @@ +export function payInPrismaCreate (payIn) { + const result = {} + + if (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) { + if (typeof payIn[key] === 'object') { + result[key] = { create: payInPrismaCreate(payIn[key]) } + } else if (Array.isArray(payIn[key])) { + result[key] = { create: payIn[key].map(item => payInPrismaCreate(item)) } + } else if (payIn[key] !== undefined) { + result[key] = payIn[key] + } + } + + return result +} diff --git a/api/payIn/transitions.js b/api/payIn/transitions.js index be017e2e3..384a81537 100644 --- a/api/payIn/transitions.js +++ b/api/payIn/transitions.js @@ -15,7 +15,7 @@ const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDe async function transitionPayIn (jobName, { payInId, fromStates, toState, transitionFunc, errorFunc, invoice, withdrawal }, { models, boss, lnd }) { let payIn try { - const include = { payInBolt11: true, payOutBolt11: true, pessimisticEnv: true, payOutCustodialTokens: true } + const include = { payInBolt11: true, payOutBolt11: true, pessimisticEnv: true, payOutCustodialTokens: true, beneficiaries: true } const currentPayIn = await models.payIn.findUnique({ where: { id: payInId }, include }) if (PAY_IN_TERMINAL_STATES.includes(currentPayIn.payInState)) { @@ -39,8 +39,22 @@ async function transitionPayIn (jobName, { payInId, fromStates, toState, transit const transitionedPayIn = await models.$transaction(async tx => { payIn = await tx.payIn.update({ - where: { id: payInId, payInState: { in: fromStates } }, - data: { payInState: toState, payInStateChangedAt: new Date() }, + where: { + id: payInId, + payInState: { in: fromStates } + }, + data: { + payInState: toState, + payInStateChangedAt: new Date(), + beneficiaries: { + updateMany: { + data: { + payInState: toState, + payInStateChangedAt: new Date() + } + } + } + }, include }) @@ -505,7 +519,7 @@ export async function payInFailed ({ data: { payInId, ...args }, models, lnd, bo return await transitionPayIn('payInFailed', { payInId, // any of these states can transition to FAILED - fromState: ['PENDING', 'PENDING_HELD', 'HELD', 'FAILED_FORWARD', 'CANCELLED', 'PENDING_INVOICE_CREATION'], + fromState: ['PENDING', 'PENDING_HELD', 'HELD', 'FAILED_FORWARD', 'CANCELLED', 'PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP'], toState: 'FAILED', transition: async ({ tx, payIn, lndPayInBolt11 }) => { let payInBolt11 diff --git a/api/payIn/types/boost.js b/api/payIn/types/boost.js index 7f0cf9666..48f947018 100644 --- a/api/payIn/types/boost.js +++ b/api/payIn/types/boost.js @@ -9,23 +9,29 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC ] -export async function getCost (models, { sats }, { me }) { - return satsToMsats(sats) -} +async function getPayOuts (models, { sats, id, sub }, { me }) { + if (id) { + sub = (await models.item.findUnique({ where: { id }, include: { sub: true } })).sub + } -export async function getPayOuts (models, payIn, { sats, id }, { me }) { - const item = await models.item.findUnique({ where: { id }, include: { sub: true } }) + if (!sub) { + throw new Error('Sub not found') + } - const revenueMsats = satsToMsats(sats * item.sub.rewardsPct / 100) + const revenueMsats = satsToMsats(sats * sub.rewardsPct / 100) const rewardMsats = satsToMsats(sats - revenueMsats) + // TODO: this won't work for beneficiaries on itemCreate return { payOutCustodialTokens: [ { payOutType: 'TERRITORY_REVENUE', - userId: item.sub.userId, + userId: sub.userId, mtokens: revenueMsats, - custodialTokenType: 'SATS' + custodialTokenType: 'SATS', + subPayOutCustodialToken: { + subName: sub.name + } }, { payOutType: 'REWARD_POOL', @@ -37,51 +43,49 @@ export async function getPayOuts (models, payIn, { sats, id }, { me }) { } } -// TODO: continue with renaming perform to onPending? -// TODO: migrate ItemAct to this simple model of itemId and payInId? -export async function onPending (tx, payInId, { sats, id }, { me }) { - const itemId = parseInt(id) +export async function getInitial (models, payInArgs, { me }) { + const { sats } = payInArgs + const payIn = { + payInType: 'BOOST', + userId: me?.id, + mcost: satsToMsats(sats) + } + + return { ...payIn, ...(await getPayOuts(models, payInArgs, { me })) } +} - return await tx.itemAct.create({ +export async function onBegin (tx, payInId, { sats, id }, { me }) { + await tx.itemPayIn.create({ data: { - itemId, + itemId: parseInt(id), payInId } }) } export async function onRetry (tx, oldPayInId, newPayInId) { - await tx.itemAct.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) + await tx.itemPayIn.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } export async function onPaid (tx, payInId) { - const itemAct = await tx.itemAct.findUnique({ where: { payInId }, include: { item: true } }) + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { itemPayIn: true } }) // increment boost on item await tx.item.update({ - where: { id: itemAct.itemId }, - data: { - boost: { increment: msatsToSats(itemAct.msats) } - } - }) - - // TODO: migrate SubAct to this simple model of subName and payInId? - // ??? is this the right place for this? - await tx.subAct.create({ + where: { id: payIn.itemPayIn.itemId }, data: { - subName: itemAct.item.subName, - payInId + 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', ${itemAct.itemId}::INTEGER), 21, true, + 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, { me }) { - const itemAct = await models.itemAct.findUnique({ where: { payInId }, include: { item: true } }) - return `SN: boost #${itemAct.itemId} by ${numWithUnits(msatsToSats(itemAct.msats), { abbreviate: false })}` + 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 index 4f39f7420..46138929a 100644 --- a/api/payIn/types/buyCredits.js +++ b/api/payIn/types/buyCredits.js @@ -8,12 +8,11 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost (models, { credits }, { me }) { - return satsToMsats(credits) -} - -export async function getPayOuts (models, { credits }, { me }) { +export async function getInitial (models, { credits }, { me }) { return { + payInType: 'BUY_CREDITS', + userId: me?.id, + mcost: satsToMsats(credits), payOutCustodialTokens: [ { payOutType: 'CREDITS', diff --git a/api/payIn/types/donate.js b/api/payIn/types/donate.js index 020319f3c..457947922 100644 --- a/api/payIn/types/donate.js +++ b/api/payIn/types/donate.js @@ -9,20 +9,17 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost (models, { sats }, { me }) { - return satsToMsats(sats) -} - -export async function getPayOuts (models, { sats }, { me }) { +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' } ] } } -// TODO: Donation table does not need to exist anymore - export async function describe (models, payInId, { me }) { 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 index f64bcc821..57ab48704 100644 --- a/api/payIn/types/downZap.js +++ b/api/payIn/types/downZap.js @@ -10,11 +10,7 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC ] -export async function getCost (models, { sats }, { me }) { - return satsToMsats(sats) -} - -export async function getPayOuts (models, payIn, { sats, id: itemId }, { me }) { +async function getPayOuts (models, payIn, { sats, id: itemId }, { me }) { const item = await models.item.findUnique({ where: { id: parseInt(itemId) }, include: { sub: true } }) const revenueMsats = satsToMsats(sats * item.sub.rewardsPct / 100) @@ -23,21 +19,37 @@ export async function getPayOuts (models, payIn, { sats, id: itemId }, { me }) { return { payOutCustodialTokens: [ { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' }, - { payOutType: 'TERRITORY_REVENUE', userId: item.sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' } + { + payOutType: 'TERRITORY_REVENUE', + userId: item.sub.userId, + mtokens: revenueMsats, + custodialTokenType: 'SATS', + subPayOutCustodialToken: { + subName: item.sub.name + } + } ] } } -export async function onPending (tx, payInId, { sats, id: itemId }, { me }) { +export async function getInitial (models, { sats, id: itemId }, { me }) { + return { + payInType: 'DOWNZAP', + userId: me?.id, + mcost: satsToMsats(sats), + ...(await getPayOuts(models, { sats, id: itemId }, { me })) + } +} +export async function onBegin (tx, payInId, { sats, id: itemId }, { me }) { itemId = parseInt(itemId) - await tx.itemAct.create({ + await tx.itemPayIn.create({ data: { itemId, payInId } }) } export async function onRetry (tx, oldPayInId, newPayInId) { - await tx.itemAct.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) + await tx.itemPayIn.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } export async function onPaid (tx, payInId) { diff --git a/api/payIn/types/inviteGift.js b/api/payIn/types/inviteGift.js index d0c26a227..683a55d23 100644 --- a/api/payIn/types/inviteGift.js +++ b/api/payIn/types/inviteGift.js @@ -9,7 +9,7 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.REWARD_SATS ] -export async function getCost (models, { id }, { me }) { +async function getCost (models, { id }, { me }) { const invite = await models.invite.findUnique({ where: { id, userId: me.id, revoked: false } }) if (!invite) { throw new Error('invite not found') @@ -17,7 +17,24 @@ export async function getCost (models, { id }, { me }) { return satsToMsats(invite.gift) } -export async function onPending (tx, payInId, { id, userId }, { me }) { +export async function getInitial (models, { id, userId }, { me }) { + const mcost = await getCost(models, { id }, { me }) + return { + payInType: 'INVITE_GIFT', + userId: me?.id, + mcost, + payOutCustodialTokens: [ + { + payOutType: 'INVITE_GIFT', + userId, + mtokens: mcost, + custodialTokenType: 'CREDITS' + } + ] + } +} + +export async function onBegin (tx, payInId, { id, userId }, { me }) { const invite = await tx.invite.findUnique({ where: { id, userId: me.id, revoked: false } }) @@ -37,9 +54,6 @@ export async function onPending (tx, payInId, { id, userId }, { me }) { } }, data: { - mcredits: { - increment: satsToMsats(invite.gift) - }, inviteId: id, referrerId: me.id } @@ -55,6 +69,6 @@ export async function onPending (tx, payInId, { id, userId }, { me }) { }) } -export async function nonCriticalSideEffects (models, payInId, { me }) { +export async function onPaidSideEffects (models, payInId, { me }) { notifyInvite(me.id) } diff --git a/api/payIn/types/itemCreate.js b/api/payIn/types/itemCreate.js index b0fbcb3f6..fa299dcd7 100644 --- a/api/payIn/types/itemCreate.js +++ b/api/payIn/types/itemCreate.js @@ -3,6 +3,7 @@ import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySub import { getItemMentions, getMentions, performBotBehavior } from '../lib/item' import { satsToMsats } from '@/lib/format' import { GqlInputError } from '@/lib/error' +import * as BOOST from './boost' export const anonable = true @@ -13,9 +14,9 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export const DEFAULT_ITEM_COST = 1000n +const DEFAULT_ITEM_COST = 1000n -export async function getBaseCost (models, { bio, parentId, subName }) { +async function getBaseCost (models, { bio, parentId, subName }) { if (bio) return DEFAULT_ITEM_COST if (parentId) { @@ -35,7 +36,7 @@ export async function getBaseCost (models, { bio, parentId, subName }) { return satsToMsats(sub.baseCost) } -export async function getCost (models, { subName, parentId, uploadIds, boost = 0, bio }, { me }) { +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 @@ -45,33 +46,50 @@ export async function getCost (models, { subName, parentId, uploadIds, boost = 0 ${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL)) * ${me ? 1 : 100}::INTEGER + (SELECT "nUnpaid" * "uploadFeesMsats" - FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[])) - + ${satsToMsats(boost)}::INTEGER as cost` + FROM upload_fees(${me?.id || USER_ID.anon}::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 && - me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost + me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost && boost <= 0 return freebie ? BigInt(0) : BigInt(cost) } -export async function getPayOuts (models, payIn, { subName, parentId, uploadIds, boost = 0, bio }, { me }) { - const sub = await models.sub.findUnique({ where: { name: subName } }) - const revenueMsats = payIn.mcost * BigInt(sub.rewardsPct) / 100n - const rewardMsats = payIn.mcost - revenueMsats +export async function getInitial (models, args, { me }) { + const mcost = await getCost(models, args, { me }) + const sub = await models.sub.findUnique({ where: { name: args.subName } }) + const revenueMsats = mcost * BigInt(sub.rewardsPct) / 100n + const rewardMsats = mcost - revenueMsats + let beneficiaries + if (args.boost > 0) { + beneficiaries = [ + await BOOST.getInitial(models, { sats: args.boost, sub }, { me }) + ] + } return { + payInType: 'ITEM_CREATE', + userId: me?.id, + mcost, payOutCustodialTokens: [ - { payOutType: 'TERRITORY_REVENUE', userId: sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' }, + { + payOutType: 'TERRITORY_REVENUE', + userId: sub.userId, + mtokens: revenueMsats, + custodialTokenType: 'SATS', + subPayOutCustodialToken: { + subName: sub.name + } + }, { payOutType: 'REWARD_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' } - ] + ], + beneficiaries } } -// TODO: I've removed consideration of boost needing to be its own payIn because it complicates requirements // TODO: uploads should just have an itemId -export async function onPending (tx, payInId, args, { me }) { +export async function onBegin (tx, payInId, args, { me }) { const { invoiceId, parentId, uploadIds = [], forwardUsers = [], options: pollOptions = [], boost = 0, ...data } = args const deletedUploads = [] @@ -88,7 +106,7 @@ export async function onPending (tx, payInId, args, { me }) { const itemMentions = await getItemMentions(tx, args, { me }) // start with median vote - if (me) { + if (me !== USER_ID.anon) { const [row] = await tx.$queryRaw`SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP( ORDER BY "weightedVotes" - "weightedDownVotes"), 0) @@ -101,8 +119,11 @@ export async function onPending (tx, payInId, args, { me }) { const itemData = { parentId: parentId ? parseInt(parentId) : null, ...data, - payInId, - boost, + itemPayIn: { + create: { + payInId + } + }, threadSubscriptions: { createMany: { data: [ @@ -158,14 +179,20 @@ export async function onPending (tx, payInId, args, { me }) { } await performBotBehavior(tx, item, { me }) + + const { beneficiaries } = await tx.payIn.findUnique({ where: { id: payInId }, include: { beneficiaries: true } }) + for (const beneficiary of beneficiaries) { + await BOOST.onBegin(tx, beneficiary.id, { sats: boost, id: item.id }, { me }) + } } +// TODO: caller of onRetry needs to update beneficiaries export async function onRetry (tx, oldPayInId, newPayInId) { - await tx.item.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) + await tx.itemPayIn.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } export async function onPaid (tx, payInId) { - const item = await tx.item.findUnique({ where: { payInId } }) + const { item } = await tx.itemPayIn.findUnique({ where: { payInId }, include: { item: true } }) if (!item) { throw new Error('Item not found') } @@ -206,13 +233,17 @@ export async function onPaid (tx, payInId) { } } -export async function nonCriticalSideEffects (models, payInId, { me }) { - const item = await models.item.findUnique({ +export async function onPaidSideEffects (models, payInId, { me }) { + const { item } = await models.itemPayIn.findUnique({ where: { payInId }, include: { - mentions: true, - itemReferrers: { include: { refereeItem: true } }, - user: true + item: { + include: { + mentions: true, + itemReferrers: { include: { refereeItem: true } }, + user: true + } + } } }) @@ -229,6 +260,11 @@ export async function nonCriticalSideEffects (models, payInId, { me }) { notifyUserSubscribers({ models, item }).catch(console.error) notifyTerritorySubscribers({ models, item }).catch(console.error) + + const { beneficiaries } = await models.payIn.findUnique({ where: { id: payInId }, include: { beneficiaries: true } }) + for (const beneficiary of beneficiaries) { + await BOOST.onPaidSideEffects(models, beneficiary.id) + } } export async function describe (models, payInId, { me }) { diff --git a/api/payIn/types/itemUpdate.js b/api/payIn/types/itemUpdate.js index da6a4b3b4..d2bc346f4 100644 --- a/api/payIn/types/itemUpdate.js +++ b/api/payIn/types/itemUpdate.js @@ -2,8 +2,7 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { uploadFees } from '../../resolvers/upload' import { getItemMentions, getMentions, performBotBehavior } from '../lib/item' import { notifyItemMention, notifyMention } from '@/lib/webPush' -import { satsToMsats } from '@/lib/format' - +import * as BOOST from './boost' export const anonable = true export const paymentMethods = [ @@ -12,39 +11,50 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost (models, { id, boost = 0, uploadIds, bio }, { me }) { +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: { payIn: true } }) const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) - const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost) + const cost = BigInt(totalFeesMsats) - if (cost > 0 && old.payIn.payInState !== 'PAID') { + if ((cost > 0 || (boost - old.boost) > 0) && old.payIn.payInState !== 'PAID') { throw new Error('cannot update item with unpaid invoice') } return cost } -export async function getPayOuts (models, payIn, { id }, { me }) { - const item = await models.item.findUnique({ where: { id: parseInt(id) }, include: { sub: true } }) - - const revenueMsats = payIn.mcost * BigInt(item.sub.rewardsPct) / 100n - const rewardMsats = payIn.mcost - revenueMsats +export async function getInitial (models, { id, boost = 0, uploadIds, bio }, { me }) { + const old = await models.item.findUnique({ where: { id: parseInt(id) }, include: { sub: true } }) + const mcost = await getCost(models, { id, boost, uploadIds, bio }, { me }) + const revenueMsats = mcost * BigInt(old.sub.rewardsPct) / 100n + const rewardMsats = mcost - revenueMsats + 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: [ - { payOutType: 'TERRITORY_REVENUE', userId: item.sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' }, + { payOutType: 'TERRITORY_REVENUE', userId: old.sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' }, { payOutType: 'REWARD_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' } - ] + ], + beneficiaries } } -// TODO: this PayInId cannot be associated with the item, what to do? export async function onPaid (tx, payInId, { me }) { const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) + const args = payIn.pessimisticEnv.args - const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args + const { id, boost: _, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args + const old = await tx.item.findUnique({ where: { id: parseInt(id) }, include: { @@ -56,8 +66,6 @@ export async function onPaid (tx, payInId, { me }) { } }) - const newBoost = boost - old.boost - // 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 @@ -72,11 +80,13 @@ export async function onPaid (tx, payInId, { me }) { // 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), boost: old.boost }, + where: { id: parseInt(id) }, data: { ...data, - boost: { - increment: newBoost + itemPayIn: { + create: { + payInId + } }, pollOptions: { createMany: { @@ -141,23 +151,20 @@ export async function onPaid (tx, payInId, { me }) { VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, now() + interval '5 seconds', now() + interval '1 day')` - if (newBoost > 0) { - await tx.$executeRaw` - INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) - VALUES ('expireBoost', jsonb_build_object('id', ${id}::INTEGER), 21, true, - now() + interval '30 days', now() + interval '40 days')` - } - await performBotBehavior(tx, args, { me }) } -export async function nonCriticalSideEffects (models, payInId, { me }) { - const item = await models.item.findUnique({ +export async function onPaidSideEffects (models, payInId) { + const { item } = await models.itemPayIn.findUnique({ where: { payInId }, include: { - mentions: true, - itemReferrers: { include: { refereeItem: true } }, - user: true + 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 @@ -171,7 +178,7 @@ export async function nonCriticalSideEffects (models, payInId, { me }) { } } -export async function describe (models, payInId, { me }) { - const item = await models.item.findUnique({ where: { payInId } }) +export async function describe (models, payInId) { + const { item } = await models.itemPayIn.findUnique({ where: { payInId }, include: { item: true } }) return `SN: update ${item.parentId ? `reply to #${item.parentId}` : 'post'}` } diff --git a/api/payIn/types/pollVote.js b/api/payIn/types/pollVote.js index 9d35892c5..fc134b2b2 100644 --- a/api/payIn/types/pollVote.js +++ b/api/payIn/types/pollVote.js @@ -9,15 +9,38 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC ] -export async function getCost (models, { id }, { me }) { +export async function getInitial (models, { id }, { me }) { const pollOption = await models.pollOption.findUnique({ where: { id: parseInt(id) }, - include: { item: true } + include: { item: { include: { sub: true } } } }) - return satsToMsats(pollOption.item.pollCost) + + const mcost = satsToMsats(pollOption.item.pollCost) + const revenueMsats = mcost * BigInt(pollOption.item.sub.rewardsPct) / 100n + const rewardMsats = mcost - revenueMsats + + return { + payInType: 'POLL_VOTE', + userId: me?.id, + mcost, + payOutCustodialTokens: [{ + payOutType: 'TERRITORY_REVENUE', + userId: pollOption.item.sub.userId, + mtokens: revenueMsats, + custodialTokenType: 'SATS', + subPayOutCustodialToken: { + subName: pollOption.item.sub.name + } + }, { + payOutType: 'REWARD_POOL', + userId: null, + mtokens: rewardMsats, + custodialTokenType: 'SATS' + }] + } } -export async function onPending (tx, payInId, { id }, { me }) { +export async function onBegin (tx, payInId, { id }, { me }) { const pollOption = await tx.pollOption.findUnique({ where: { id: parseInt(id) } }) diff --git a/api/payIn/types/proxyPayment.js b/api/payIn/types/proxyPayment.js index 40c70f155..541029d36 100644 --- a/api/payIn/types/proxyPayment.js +++ b/api/payIn/types/proxyPayment.js @@ -9,20 +9,20 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.P2P ] -export async function getCost (models, { msats }, { me }) { - return toPositiveBigInt(msats) -} - // 3% to routing fee, 7% to rewards pool, 90% to invoice -export async function getPayOuts (models, payIn, { msats }, { me }) { - const routingFeeMtokens = msats * 3n / 100n - const rewardsPoolMtokens = msats * 7n / 100n - const proxyPaymentMtokens = msats - routingFeeMtokens - rewardsPoolMtokens +export async function getInitial (models, { msats }, { me }) { + const mcost = toPositiveBigInt(msats) + const routingFeeMtokens = mcost * 3n / 100n + const rewardsPoolMtokens = mcost * 7n / 100n + const proxyPaymentMtokens = mcost - routingFeeMtokens - rewardsPoolMtokens const { invoice: bolt11, wallet } = await createUserInvoice(me.id, { msats: proxyPaymentMtokens }, { models }) const invoice = await parsePaymentRequest({ request: bolt11 }) return { + payInType: 'PROXY_PAYMENT', + userId: me.id, + mcost, payOutCustodialTokens: [ { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }, { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardsPoolMtokens, custodialTokenType: 'SATS' } @@ -39,7 +39,7 @@ export async function getPayOuts (models, payIn, { msats }, { me }) { } // TODO: all of this needs to be updated elsewhere -export async function onPending (tx, payInId, { comment, lud18Data, noteStr }, { me }) { +export async function onBegin (tx, payInId, { comment, lud18Data, noteStr }, { me }) { await tx.payInBolt11.update({ where: { payInId }, data: { @@ -60,15 +60,16 @@ export async function onPending (tx, payInId, { comment, lud18Data, noteStr }, { }) } -export async function onPaid (tx, payInId, { me }) { +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 nonCriticalSideEffects ({ invoice }, { models }) { - await notifyDeposit(invoice.userId, invoice) +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, { me }) { diff --git a/api/payIn/types/territoryBilling.js b/api/payIn/types/territoryBilling.js index dd4f156c7..2648faaaf 100644 --- a/api/payIn/types/territoryBilling.js +++ b/api/payIn/types/territoryBilling.js @@ -10,20 +10,21 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost (models, { name }) { +export async function getInitial (models, { name }, { me }) { const sub = await models.sub.findUnique({ where: { name } }) - return satsToMsats(TERRITORY_PERIOD_COST(sub.billingType)) -} + const mcost = satsToMsats(TERRITORY_PERIOD_COST(sub.billingType)) -export async function getPayOuts (models, payIn, { name }) { return { + payInType: 'TERRITORY_BILLING', + userId: me?.id, + mcost, payOutCustodialTokens: [ - { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: payIn.mcost, custodialTokenType: 'SATS' } + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' } ] } } @@ -53,7 +54,7 @@ export async function onPaid (tx, payInId, { me }) { const billPaidUntil = nextBilling(billedLastAt, sub.billingType) - return await tx.sub.update({ + await tx.sub.update({ // optimistic concurrency control // make sure the sub hasn't changed since we fetched it where: { @@ -67,7 +68,7 @@ export async function onPaid (tx, payInId, { me }) { billPaidUntil, billingCost, status: 'ACTIVE', - SubAct: { + subPayIn: { create: { payInId } @@ -76,7 +77,7 @@ export async function onPaid (tx, payInId, { me }) { }) } -export async function describe (models, payInId, { me }) { - const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { subAct: true } }) - return `SN: billing for territory ${payIn.subAct.subName}` +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 index 44c6ac32d..baf33c6bb 100644 --- a/api/payIn/types/territoryCreate.js +++ b/api/payIn/types/territoryCreate.js @@ -11,14 +11,14 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost (models, { billingType }, { me }) { - return satsToMsats(TERRITORY_PERIOD_COST(billingType)) -} - -export async function getPayOuts (models, payIn, { name }) { +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: payIn.mcost, custodialTokenType: 'SATS' } + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' } ] } } @@ -38,7 +38,7 @@ export async function onPaid (tx, payInId, { me }) { billingCost, rankingType: 'WOT', userId: me.id, - SubAct: { + subPayIn: { create: { payInId } @@ -54,12 +54,9 @@ export async function onPaid (tx, payInId, { me }) { await tx.userSubTrust.createMany({ data: initialTrust({ name: sub.name, userId: sub.userId }) }) - - return sub } -export async function describe (models, payInId, { me }) { - const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) - const { args: { name } } = payIn.pessimisticEnv - return `SN: create territory ${name}` +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 index 6c82b12b3..b3b4dc27b 100644 --- a/api/payIn/types/territoryUnarchive.js +++ b/api/payIn/types/territoryUnarchive.js @@ -11,14 +11,14 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost (models, { billingType }, { me }) { - return satsToMsats(TERRITORY_PERIOD_COST(billingType)) -} - -export async function getPayOuts (models, payIn, { name }) { +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: payIn.mcost, custodialTokenType: 'SATS' } + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' } ] } } @@ -44,19 +44,17 @@ export async function onPaid (tx, payInId, { me }) { data.billPaidUntil = nextBilling(data.billedLastAt, data.billingType) data.status = 'ACTIVE' data.userId = me.id + data.subPayIn = { + create: { + payInId + } + } if (sub.userId !== me.id) { await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } }) await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } }) } - await tx.subAct.create({ - data: { - payInId, - subName: name - } - }) - await tx.subSubscription.upsert({ where: { userId_subName: { @@ -89,12 +87,9 @@ export async function onPaid (tx, payInId, { me }) { await tx.userSubTrust.createMany({ data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId }) }) - - return updatedSub } -export async function describe (models, payInId, { me }) { - const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) - const { args: { name } } = payIn.pessimisticEnv - return `SN: unarchive territory ${name}` +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 index b76434a52..d1555e2ce 100644 --- a/api/payIn/types/territoryUpdate.js +++ b/api/payIn/types/territoryUpdate.js @@ -11,19 +11,21 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost (models, { oldName, billingType }, { me }) { +export async function getInitial (models, { oldName, billingType }, { me }) { const oldSub = await models.sub.findUnique({ where: { name: oldName } }) - const cost = proratedBillingCost(oldSub, billingType) - if (!cost) { - return 0n - } + const mcost = satsToMsats(proratedBillingCost(oldSub, billingType) ?? 0) - return satsToMsats(cost) + return { + payInType: 'TERRITORY_UPDATE', + userId: me?.id, + mcost, + payOutCustodialTokens: [{ payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' }] + } } export async function onPaid (tx, payInId, { me }) { @@ -36,6 +38,11 @@ export async function onPaid (tx, payInId, { me }) { }) data.billingCost = TERRITORY_PERIOD_COST(data.billingType) + data.subPayIn = { + create: { + payInId + } + } // we never want to bill them again if they are changing to ONCE if (data.billingType === 'ONCE') { @@ -54,15 +61,6 @@ export async function onPaid (tx, payInId, { me }) { data.status = 'ACTIVE' } - if (payIn.mcost > 0n) { - await tx.subAct.create({ - data: { - payInId, - subName: oldName - } - }) - } - return await tx.sub.update({ data, where: { @@ -78,8 +76,7 @@ export async function onPaid (tx, payInId, { me }) { }) } -export async function describe (models, payInId, { me }) { - const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { pessimisticEnv: true } }) - const { args: { oldName } } = payIn.pessimisticEnv - return `SN: update territory billing ${oldName}` +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 index 638515542..6b01b346c 100644 --- a/api/payIn/types/withdrawal.js +++ b/api/payIn/types/withdrawal.js @@ -8,14 +8,12 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.REWARD_SATS ] -export async function getCost (models, { bolt11, maxFee, walletId }) { - const invoice = parsePaymentRequest({ request: bolt11 }) - return BigInt(invoice.mtokens) + satsToMsats(maxFee) -} - -export async function getPayOuts (models, payIn, { bolt11, maxFee, walletId }, { me }) { +export async function getInitial (models, { bolt11, maxFee, walletId }, { me }) { const invoice = parsePaymentRequest({ request: bolt11 }) return { + payInType: 'WITHDRAWAL', + userId: me?.id, + mcost: BigInt(invoice.mtokens) + satsToMsats(maxFee), payOutBolt11: { payOutType: 'WITHDRAWAL', msats: BigInt(invoice.mtokens), diff --git a/api/payIn/types/zap.js b/api/payIn/types/zap.js index 66b4d6a0c..68a24e5cd 100644 --- a/api/payIn/types/zap.js +++ b/api/payIn/types/zap.js @@ -1,8 +1,9 @@ -import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { msatsToSats, satsToMsats } from '@/lib/format' import { notifyZapped } from '@/lib/webPush' -import { getInvoiceableWallets } from '@/wallets/server' +import { createUserInvoice, getInvoiceableWallets } from '@/wallets/server' import { Prisma } from '@prisma/client' +import { parsePaymentRequest } from 'ln-service' export const anonable = true @@ -14,15 +15,12 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getCost ({ sats }) { - return satsToMsats(sats) -} - -export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, me, cost }) { +export async function getInvoiceablePeer (models, { id, sats, hasSendWallet }, { me }) { + 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 < me?.sendCreditsBelowSats || - (me && !hasSendWallet && (me.mcredits >= cost || me.msats >= cost))) { + if (sats < zapper?.sendCreditsBelowSats || + (me && !hasSendWallet && (zapper.mcredits + zapper.msats >= satsToMsats(sats)))) { return null } @@ -52,45 +50,55 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, return null } -export async function getSybilFeePercent () { - return 30n -} +// 70% to the receiver, 21% to the territory founder, 6% to rewards pool, 3% to routing fee +export async function getInitial (models, payInArgs, { me }) { + const mcost = satsToMsats(payInArgs.sats) + const routingFeeMtokens = mcost * 3n / 100n + const rewardsPoolMtokens = mcost * 6n / 100n + const zapMtokens = mcost - routingFeeMtokens - rewardsPoolMtokens + const payOutCustodialTokens = [ + { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }, + { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardsPoolMtokens, custodialTokenType: 'SATS' } + ] + + let payOutBolt11 + const invoiceablePeer = await getInvoiceablePeer(models, payInArgs, { me }) + if (invoiceablePeer) { + const { invoice: bolt11, wallet } = await createUserInvoice(me.id, { msats: zapMtokens }, { models }) + const invoice = await parsePaymentRequest({ request: bolt11 }) + payOutBolt11 = { + payOutType: 'ZAP', + msats: BigInt(invoice.mtokens), + bolt11: invoice.bolt11, + hash: invoice.hash, + userId: invoiceablePeer, + walletId: wallet.id + } + } else { + const item = await models.item.findUnique({ where: { id: parseInt(payInArgs.id) } }) + payOutCustodialTokens.push({ payOutType: 'ZAP', userId: item.userId, mtokens: zapMtokens, custodialTokenType: 'SATS' }) + } -export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) { - const feeMsats = cost * sybilFeePercent / 100n - const zapMsats = cost - feeMsats - itemId = parseInt(itemId) - - let invoiceData = {} - if (invoiceId) { - invoiceData = { invoiceId, invoiceActionState: 'PENDING' } - // store a reference to the item in the invoice - await tx.invoice.update({ - where: { id: invoiceId }, - data: { actionId: itemId } - }) + return { + payInType: 'ZAP', + userId: me.id, + mcost, + payOutCustodialTokens, + payOutBolt11 } +} - const acts = await tx.itemAct.createManyAndReturn({ - data: [ - { msats: feeMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'FEE', ...invoiceData }, - { msats: zapMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'TIP', ...invoiceData } - ] +export async function onBegin (tx, payInId, { sats, id }, { me }) { + await tx.itemPayIn.create({ + data: { + itemId: parseInt(id), + payInId + } }) - - const [{ path }] = await tx.$queryRaw` - SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` - return { id: itemId, sats, act: 'TIP', path, actIds: acts.map(act => act.id) } } -export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - const [{ id, path }] = await tx.$queryRaw` - SELECT "Item".id, ltree2text(path) as path - FROM "Item" - JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` - return { id, sats: msatsToSats(cost), act: 'TIP', path } +export async function onRetry (tx, oldPayInId, newPayInId) { + await tx.itemAct.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } export async function onPaid ({ invoice, actIds }, { tx }) { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b177e8ad..100246a7c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -612,6 +612,7 @@ model Item { ItemUserAgg ItemUserAgg[] AutoSocialPost AutoSocialPost[] randPollOptions Boolean @default(false) + ItemPayIn ItemPayIn[] @@index([uploadId]) @@index([lastZapAt]) @@ -799,15 +800,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]) @@ -1330,6 +1333,7 @@ enum PayInType { enum PayInState { PENDING_INVOICE_CREATION + PENDING_INVOICE_WRAP PENDING_WITHDRAWAL WITHDRAWAL_PAID WITHDRAWAL_FAILED @@ -1360,6 +1364,30 @@ enum PayInFailureReason { EXECUTION_FAILED } +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") @@ -1384,10 +1412,12 @@ model PayIn { payOutCustodialTokens PayOutCustodialToken[] payOutBolt11 PayOutBolt11? - successor PayIn? @relation("PayInPredecessor") - predecessor PayIn? @relation("PayInPredecessor", fields: [predecessorId], references: [id], onDelete: Cascade) - benefactor PayIn? @relation("PayInBenefactor", fields: [benefactorId], references: [id], onDelete: Cascade) - beneficiaries PayIn[] @relation("PayInBenefactor") + successor PayIn? @relation("PayInPredecessor") + predecessor PayIn? @relation("PayInPredecessor", fields: [predecessorId], references: [id], onDelete: Cascade) + benefactor PayIn? @relation("PayInBenefactor", fields: [benefactorId], references: [id], onDelete: Cascade) + beneficiaries PayIn[] @relation("PayInBenefactor") + ItemPayIn ItemPayIn[] + SubPayIn SubPayIn[] @@index([userId]) @@index([payInType]) @@ -1413,9 +1443,8 @@ model PessimisticEnv { payInId Int @unique payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) - args Json? @db.JsonB - result Json? @db.JsonB - error String? + args Json? @db.JsonB + error String? } enum PayOutType { @@ -1431,6 +1460,18 @@ enum PayOutType { SYSTEM_REVENUE } +model SubPayOutCustodialToken { + id Int @id @default(autoincrement()) + subName String @db.Citext + sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade) + payOutCustodialTokenId Int + payOutCustodialToken PayOutCustodialToken @relation(fields: [payOutCustodialTokenId], references: [id], onDelete: Cascade) + + @@unique([subName, payOutCustodialTokenId]) + @@index([subName]) + @@index([payOutCustodialTokenId]) +} + model PayOutCustodialToken { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") @@ -1445,8 +1486,9 @@ model PayOutCustodialToken { mtokens BigInt custodialTokenType CustodialTokenType - msatsBefore BigInt? - mcreditsBefore BigInt? + msatsBefore BigInt? + mcreditsBefore BigInt? + SubPayOutCustodialToken SubPayOutCustodialToken[] } model PayInBolt11 { From 1f076e0c1501bdba0b882ff50554ba04fc5a93d8 Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 25 Apr 2025 11:57:09 -0500 Subject: [PATCH 07/10] refactor zap --- api/payIn/index.js | 9 +- api/payIn/lib/payInCustodialTokens.js | 13 +- api/payIn/types/boost.js | 19 +-- api/payIn/types/downZap.js | 10 +- api/payIn/types/itemUpdate.js | 8 +- api/payIn/types/pollVote.js | 24 ++-- api/payIn/types/zap.js | 166 +++++++++++--------------- 7 files changed, 103 insertions(+), 146 deletions(-) diff --git a/api/payIn/index.js b/api/payIn/index.js index 5c819e924..ddd0c20f2 100644 --- a/api/payIn/index.js +++ b/api/payIn/index.js @@ -4,9 +4,9 @@ import { payViaPaymentRequest } from 'ln-service' import lnd from '../lnd' import payInTypeModules from './types' import { msatsToSats } from '@/lib/format' -import { getPayInCustodialTokens } from './lib/payInCustodialTokens' +import { getCostBreakdown, getPayInCustodialTokens } from './lib/payInCustodialTokens' import { getPayInBolt11, getPayInBolt11Wrap } from './lib/payInBolt11' -import { isInvoiceable, isP2P, isPessimistic, isWithdrawal } from './lib/is' +import { isInvoiceable, isPessimistic, isWithdrawal } from './lib/is' import { payInPrismaCreate } from './lib/payInPrismaCreate' const PAY_IN_INCLUDE = { payInCustodialTokens: true, @@ -44,10 +44,7 @@ export default async function payIn (payInType, payInArgs, { models, me }) { async function begin (models, payInInitial, payInArgs, { me }) { const payInModule = payInTypeModules[payInInitial.payInType] - - const { payOutBolt11, beneficiaries } = payInInitial - const mP2PCost = isP2P(payInInitial) ? (payOutBolt11?.msats ?? 0n) : 0n - const mCustodialCost = payInInitial.mcost + beneficiaries.reduce((acc, b) => acc + b.mcost, 0n) - mP2PCost + const { mP2PCost, mCustodialCost } = getCostBreakdown(payInInitial) const { payIn, mCostRemaining } = await models.$transaction(async tx => { const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, payInInitial, { me }) diff --git a/api/payIn/lib/payInCustodialTokens.js b/api/payIn/lib/payInCustodialTokens.js index 2b696853b..0ad6d25b6 100644 --- a/api/payIn/lib/payInCustodialTokens.js +++ b/api/payIn/lib/payInCustodialTokens.js @@ -1,4 +1,4 @@ -import { isPayableWithCredits } from './is' +import { isP2P, isPayableWithCredits } from './is' import { USER_ID } from '@/lib/constants' export async function getPayInCustodialTokens (tx, mCustodialCost, payIn, { me }) { @@ -46,3 +46,14 @@ export async function getPayInCustodialTokens (tx, mCustodialCost, payIn, { me } return payInAssets } + +export async function getCostBreakdown (payIn) { + const { payOutBolt11, beneficiaries } = payIn + const mP2PCost = isP2P(payIn) ? (payOutBolt11?.msats ?? 0n) : 0n + const mCustodialCost = payIn.mcost + beneficiaries.reduce((acc, b) => acc + b.mcost, 0n) - mP2PCost + + return { + mP2PCost, + mCustodialCost + } +} diff --git a/api/payIn/types/boost.js b/api/payIn/types/boost.js index 48f947018..ef3cfa8df 100644 --- a/api/payIn/types/boost.js +++ b/api/payIn/types/boost.js @@ -45,22 +45,15 @@ async function getPayOuts (models, { sats, id, sub }, { me }) { export async function getInitial (models, payInArgs, { me }) { const { sats } = payInArgs - const payIn = { + return { payInType: 'BOOST', userId: me?.id, - mcost: satsToMsats(sats) + mcost: satsToMsats(sats), + itemPayIn: { + itemId: parseInt(payInArgs.id) + }, + ...(await getPayOuts(models, payInArgs, { me })) } - - return { ...payIn, ...(await getPayOuts(models, payInArgs, { me })) } -} - -export async function onBegin (tx, payInId, { sats, id }, { me }) { - await tx.itemPayIn.create({ - data: { - itemId: parseInt(id), - payInId - } - }) } export async function onRetry (tx, oldPayInId, newPayInId) { diff --git a/api/payIn/types/downZap.js b/api/payIn/types/downZap.js index 57ab48704..9e0562a14 100644 --- a/api/payIn/types/downZap.js +++ b/api/payIn/types/downZap.js @@ -37,16 +37,12 @@ export async function getInitial (models, { sats, id: itemId }, { me }) { payInType: 'DOWNZAP', userId: me?.id, mcost: satsToMsats(sats), + itemPayIn: { + itemId: parseInt(itemId) + }, ...(await getPayOuts(models, { sats, id: itemId }, { me })) } } -export async function onBegin (tx, payInId, { sats, id: itemId }, { me }) { - itemId = parseInt(itemId) - - await tx.itemPayIn.create({ - data: { itemId, payInId } - }) -} export async function onRetry (tx, oldPayInId, newPayInId) { await tx.itemPayIn.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) diff --git a/api/payIn/types/itemUpdate.js b/api/payIn/types/itemUpdate.js index d2bc346f4..8cc1734be 100644 --- a/api/payIn/types/itemUpdate.js +++ b/api/payIn/types/itemUpdate.js @@ -45,6 +45,9 @@ export async function getInitial (models, { id, boost = 0, uploadIds, bio }, { m { payOutType: 'TERRITORY_REVENUE', userId: old.sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' }, { payOutType: 'REWARD_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' } ], + itemPayIn: { + itemId: parseInt(id) + }, beneficiaries } } @@ -83,11 +86,6 @@ export async function onPaid (tx, payInId, { me }) { where: { id: parseInt(id) }, data: { ...data, - itemPayIn: { - create: { - payInId - } - }, pollOptions: { createMany: { data: pollOptions?.map(option => ({ option })) diff --git a/api/payIn/types/pollVote.js b/api/payIn/types/pollVote.js index fc134b2b2..2c7d189ca 100644 --- a/api/payIn/types/pollVote.js +++ b/api/payIn/types/pollVote.js @@ -36,25 +36,19 @@ export async function getInitial (models, { id }, { me }) { userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' - }] + }], + pollBlindVote: { + itemId: pollOption.itemId, + userId: me.id + }, + pollVote: { + pollOptionId: pollOption.id, + itemId: pollOption.itemId + } } } -export async function onBegin (tx, payInId, { id }, { me }) { - const pollOption = await tx.pollOption.findUnique({ - where: { id: parseInt(id) } - }) - const itemId = parseInt(pollOption.itemId) - - // the unique index on userId, itemId will prevent double voting - await tx.pollBlindVote.create({ data: { userId: me.id, itemId, payInId } }) - await tx.pollVote.create({ data: { pollOptionId: pollOption.id, itemId, payInId } }) - - return { id } -} - export async function onRetry (tx, oldPayInId, newPayInId) { - await tx.itemAct.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) await tx.pollBlindVote.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) await tx.pollVote.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } diff --git a/api/payIn/types/zap.js b/api/payIn/types/zap.js index 68a24e5cd..710bed7c0 100644 --- a/api/payIn/types/zap.js +++ b/api/payIn/types/zap.js @@ -1,5 +1,5 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' -import { msatsToSats, satsToMsats } from '@/lib/format' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' import { notifyZapped } from '@/lib/webPush' import { createUserInvoice, getInvoiceableWallets } from '@/wallets/server' import { Prisma } from '@prisma/client' @@ -15,7 +15,7 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -export async function getInvoiceablePeer (models, { id, sats, hasSendWallet }, { me }) { +async function getInvoiceablePeer (models, { id, sats, hasSendWallet }, { me }) { 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 @@ -50,33 +50,50 @@ export async function getInvoiceablePeer (models, { id, sats, hasSendWallet }, { return null } -// 70% to the receiver, 21% to the territory founder, 6% to rewards pool, 3% to routing fee +// 70% to the receiver(s), 21% to the territory founder, the rest depends on if it's P2P or not export async function getInitial (models, payInArgs, { me }) { + const { sub, itemForwards, userId } = await models.item.findUnique({ where: { id: parseInt(payInArgs.id) }, include: { sub: true, itemForwards: true, user: true } }) const mcost = satsToMsats(payInArgs.sats) - const routingFeeMtokens = mcost * 3n / 100n - const rewardsPoolMtokens = mcost * 6n / 100n - const zapMtokens = mcost - routingFeeMtokens - rewardsPoolMtokens - const payOutCustodialTokens = [ - { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }, - { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardsPoolMtokens, custodialTokenType: 'SATS' } - ] + const founderMtokens = mcost * 21n / 100n + const payOutCustodialTokens = [{ + payOutType: 'TERRITORY_REVENUE', + userId: sub.userId, + mtokens: founderMtokens, + custodialTokenType: 'SATS' + }] let payOutBolt11 const invoiceablePeer = await getInvoiceablePeer(models, payInArgs, { me }) if (invoiceablePeer) { + const routingFeeMtokens = mcost * 3n / 100n + const rewardsPoolMtokens = mcost * 6n / 100n + const zapMtokens = mcost - routingFeeMtokens - rewardsPoolMtokens const { invoice: bolt11, wallet } = await createUserInvoice(me.id, { msats: zapMtokens }, { models }) const invoice = await parsePaymentRequest({ request: bolt11 }) + + // 6% to rewards pool, 3% to routing fee + payOutCustodialTokens.push( + { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }, + { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardsPoolMtokens, custodialTokenType: 'SATS' }) payOutBolt11 = { payOutType: 'ZAP', msats: BigInt(invoice.mtokens), - bolt11: invoice.bolt11, + bolt11, hash: invoice.hash, userId: invoiceablePeer, walletId: wallet.id } } else { - const item = await models.item.findUnique({ where: { id: parseInt(payInArgs.id) } }) - payOutCustodialTokens.push({ payOutType: 'ZAP', userId: item.userId, mtokens: zapMtokens, custodialTokenType: 'SATS' }) + // 9% to rewards pool + const rewardsPoolMtokens = mcost * 9n / 100n + const zapMtokens = mcost - rewardsPoolMtokens - founderMtokens + if (itemForwards.length > 0) { + for (const f of itemForwards) { + payOutCustodialTokens.push({ payOutType: 'ZAP', userId: f.userId, mtokens: zapMtokens * BigInt(f.pct) / 100n, custodialTokenType: 'CREDITS' }) + } + } + const remainingZapMtokens = zapMtokens - payOutCustodialTokens.filter(t => t.payOutType === 'ZAP').reduce((acc, t) => acc + t.mtokens, 0n) + payOutCustodialTokens.push({ payOutType: 'ZAP', userId, mtokens: remainingZapMtokens, custodialTokenType: 'CREDITS' }) } return { @@ -84,76 +101,30 @@ export async function getInitial (models, payInArgs, { me }) { userId: me.id, mcost, payOutCustodialTokens, - payOutBolt11 - } -} - -export async function onBegin (tx, payInId, { sats, id }, { me }) { - await tx.itemPayIn.create({ - data: { - itemId: parseInt(id), - payInId + payOutBolt11, + itemPayIn: { + itemId: parseInt(payInArgs.id) } - }) + } } export async function onRetry (tx, oldPayInId, newPayInId) { - await tx.itemAct.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) + await tx.itemPayIn.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) } -export async function onPaid ({ invoice, actIds }, { tx }) { - let acts - if (invoice) { - await tx.itemAct.updateMany({ - where: { invoiceId: invoice.id }, - data: { - invoiceActionState: 'PAID' - } - }) - acts = await tx.itemAct.findMany({ where: { invoiceId: invoice.id }, include: { item: true } }) - actIds = acts.map(act => act.id) - } else if (actIds) { - acts = await tx.itemAct.findMany({ where: { id: { in: actIds } }, include: { item: true } }) - } else { - throw new Error('No invoice or actIds') - } +export async function onPaid (tx, payInId) { + const payIn = await tx.payIn.findUnique({ + where: { id: payInId }, + include: { + itemPayIn: { include: { item: true } }, + payOutBolt11: true + } + }) - const msats = acts.reduce((a, b) => a + BigInt(b.msats), BigInt(0)) + const msats = payIn.mcost const sats = msatsToSats(msats) - const itemAct = acts.find(act => act.act === 'TIP') - - if (invoice?.invoiceForward) { - // only the op got sats and we need to add it to their stackedMsats - // because the sats were p2p - await tx.user.update({ - where: { id: itemAct.item.userId }, - data: { stackedMsats: { increment: itemAct.msats } } - }) - } else { - // splits only use mcredits - await tx.$executeRaw` - WITH forwardees AS ( - SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits - FROM "ItemForward" - WHERE "itemId" = ${itemAct.itemId}::INTEGER - ), total_forwarded AS ( - SELECT COALESCE(SUM(mcredits), 0) as mcredits - FROM forwardees - ), recipients AS ( - SELECT "userId", mcredits FROM forwardees - UNION - SELECT ${itemAct.item.userId}::INTEGER as "userId", - ${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits - ORDER BY "userId" ASC -- order to prevent deadlocks - ) - UPDATE users - SET - mcredits = users.mcredits + recipients.mcredits, - "stackedMsats" = users."stackedMsats" + recipients.mcredits, - "stackedMcredits" = users."stackedMcredits" + recipients.mcredits - FROM recipients - WHERE users.id = recipients."userId"` - } + 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 @@ -162,21 +133,21 @@ export async function onPaid ({ invoice, actIds }, { tx }) { 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 = ${itemAct.itemId}::INTEGER + WHERE i.id = ${item.id}::INTEGER ), zapper AS ( SELECT - COALESCE(${itemAct.item.parentId + COALESCE(${item.parentId ? Prisma.sql`"zapCommentTrust"` : Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust", - COALESCE(${itemAct.item.parentId + 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" = ${itemAct.userId}::INTEGER + AND ust."userId" = ${userId}::INTEGER ), zap AS ( INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats") - VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER) + 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, @@ -188,10 +159,10 @@ export async function onPaid ({ invoice, actIds }, { tx }) { "subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats, upvotes = upvotes + zap.first_vote, msats = "Item".msats + ${msats}::BIGINT, - mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT, + mcredits = "Item".mcredits + ${payIn.payOutBolt11 ? 0n : msats}::BIGINT, "lastZapAt" = now() FROM zap, zapper - WHERE "Item".id = ${itemAct.itemId}::INTEGER + WHERE "Item".id = ${item.id}::INTEGER RETURNING "Item".*, zapper."zapTrust" * zap.log_sats as "weightedVote" ), ancestors AS ( SELECT "Item".* @@ -202,7 +173,7 @@ export async function onPaid ({ invoice, actIds }, { tx }) { UPDATE "Item" SET "weightedComments" = "Item"."weightedComments" + item_zapped."weightedVote", "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT, - "commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT + "commentMcredits" = "Item"."commentMcredits" + ${payIn.payOutBolt11 ? 0n : msats}::BIGINT FROM item_zapped, ancestors WHERE "Item".id = ancestors.id` @@ -215,9 +186,9 @@ export async function onPaid ({ invoice, actIds }, { tx }) { FROM "ItemUserAgg" JOIN "Item" ON "Item".id = "ItemUserAgg"."itemId" LEFT JOIN "Item" root ON root.id = "Item"."rootId" - WHERE "ItemUserAgg"."userId" = ${itemAct.userId}::INTEGER - AND "ItemUserAgg"."itemId" = ${itemAct.itemId}::INTEGER - AND root."userId" = ${itemAct.userId}::INTEGER + WHERE "ItemUserAgg"."userId" = ${userId}::INTEGER + AND "ItemUserAgg"."itemId" = ${item.id}::INTEGER + AND root."userId" = ${userId}::INTEGER AND root.bounty IS NOT NULL ) UPDATE "Item" @@ -226,28 +197,25 @@ export async function onPaid ({ invoice, actIds }, { tx }) { WHERE "Item".id = bounty.id AND bounty.paid` } -export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) { - const itemAct = await models.itemAct.findFirst({ - where: invoice ? { invoiceId: invoice.id } : { id: { in: actIds } }, - include: { item: true } +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 pendingActs = await models.itemAct.count({ where: { - itemId: itemAct.itemId, + itemId: payIn.itemPayIn.itemId, createdAt: { - gt: itemAct.createdAt + gt: payIn.createdAt } } }) - if (pendingActs === 0) notifyZapped({ models, item: itemAct.item }).catch(console.error) -} - -export async function onFail ({ invoice }, { tx }) { - await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } }) + if (pendingActs === 0) notifyZapped({ models, item: payIn.itemPayIn.item }).catch(console.error) } -export async function describe ({ id: itemId, sats }, { actionId, cost }) { - return `SN: zap ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}` +export async function describe (models, payInId, { me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { itemPayIn: true } }) + return `SN: zap ${numWithUnits(payIn.mcost, { abbreviate: false })} #${payIn.itemPayIn.itemId}` } From 8c69ef331291e2c3e334437d7d32e4aa8abebbfe Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 25 Apr 2025 20:24:41 -0500 Subject: [PATCH 08/10] wip approach to retries --- api/payIn/index.js | 192 ++++++++++++++++------------- api/payIn/lib/is.js | 20 ++- api/payIn/lib/payInBolt11.js | 27 +++- api/payIn/lib/payInCreate.js | 70 +++++++++++ api/payIn/lib/payInPrisma.js | 73 +++++++++++ api/payIn/lib/payInPrismaCreate.js | 34 ----- prisma/schema.prisma | 22 ++-- 7 files changed, 298 insertions(+), 140 deletions(-) create mode 100644 api/payIn/lib/payInCreate.js create mode 100644 api/payIn/lib/payInPrisma.js delete mode 100644 api/payIn/lib/payInPrismaCreate.js diff --git a/api/payIn/index.js b/api/payIn/index.js index ddd0c20f2..64df4ea0d 100644 --- a/api/payIn/index.js +++ b/api/payIn/index.js @@ -4,19 +4,11 @@ import { payViaPaymentRequest } from 'ln-service' import lnd from '../lnd' import payInTypeModules from './types' import { msatsToSats } from '@/lib/format' -import { getCostBreakdown, getPayInCustodialTokens } from './lib/payInCustodialTokens' -import { getPayInBolt11, getPayInBolt11Wrap } from './lib/payInBolt11' -import { isInvoiceable, isPessimistic, isWithdrawal } from './lib/is' -import { payInPrismaCreate } from './lib/payInPrismaCreate' -const PAY_IN_INCLUDE = { - payInCustodialTokens: true, - payOutBolt11: true, - pessimisticEnv: true, - user: true, - payOutCustodialTokens: true -} +import { payInCreatePayInBolt11, payInCreatePayInBolt11Wrap } from './lib/payInBolt11' +import { isPessimistic, isWithdrawal } from './lib/is' +import { payInCloneAndCreate, payInCreate } from './lib/payInCreate' -export default async function payIn (payInType, payInArgs, { models, me }) { +export default async function pay (payInType, payInArgs, { models, me }) { try { const payInModule = payInTypeModules[payInType] @@ -30,6 +22,7 @@ export default async function payIn (payInType, payInArgs, { models, me }) { throw new Error('You must be logged in to perform this action') } + // 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 }) @@ -44,44 +37,9 @@ export default async function payIn (payInType, payInArgs, { models, me }) { async function begin (models, payInInitial, payInArgs, { me }) { const payInModule = payInTypeModules[payInInitial.payInType] - const { mP2PCost, mCustodialCost } = getCostBreakdown(payInInitial) const { payIn, mCostRemaining } = await models.$transaction(async tx => { - const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, payInInitial, { me }) - const mCustodialPaid = payInCustodialTokens.reduce((acc, token) => acc + token.mtokens, 0n) - - // TODO: how to deal with < 1000msats? - const mCostRemaining = mCustodialCost - mCustodialPaid + mP2PCost - - let payInState = null - if (mCostRemaining > 0n) { - if (!isInvoiceable(payInInitial)) { - throw new Error('Insufficient funds') - } - if (mP2PCost > 0n) { - payInState = 'PENDING_INVOICE_WRAP' - } else { - payInState = 'PENDING_INVOICE_CREATION' - } - } else if (isWithdrawal(payInInitial)) { - payInState = 'PENDING_WITHDRAWAL' - } else { - payInState = 'PAID' - } - - const payIn = await tx.payIn.create({ - data: { - ...payInPrismaCreate({ - ...payInInitial, - payInState, - payInStateChangedAt: new Date() - }), - pessimisticEnv: { - create: isPessimistic(payInInitial, { me }) ? { args: payInArgs } : undefined - } - }, - include: PAY_IN_INCLUDE - }) + const { payIn, mCostRemaining } = await payInCreate(tx, payInInitial, { me }) // if it's pessimistic, we don't perform the action until the invoice is held if (payIn.pessimisticEnv) { @@ -97,8 +55,6 @@ async function begin (models, payInInitial, payInArgs, { me }) { // 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 }) - // run non critical side effects - payInModule.onPaidSideEffects?.(models, payIn.id).catch(console.error) return { payIn, mCostRemaining: 0n @@ -113,8 +69,31 @@ async function begin (models, payInInitial, payInArgs, { me }) { } }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + return await afterBegin(models, { payIn, mCostRemaining }, { me }) +} + +async function afterBegin (models, { payIn, mCostRemaining }, { me }) { try { - return await afterBegin(models, { payIn, mCostRemaining }, payInArgs, { me }) + if (payIn.payInState === 'PAID') { + // run non critical side effects + payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payIn.id).catch(console.error) + } else if (payIn.payInState === 'PENDING_INVOICE_CREATION') { + return await payInCreatePayInBolt11(models, { mCostRemaining, payIn }, { me }) + } else if (payIn.payInState === 'PENDING_INVOICE_WRAP') { + return await payInCreatePayInBolt11Wrap(models, { mCostRemaining, payIn }, { me }) + } 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) + return payIn + } 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('id', ${payIn.id}::INTEGER), now(), 1000)`.catch(console.error) @@ -122,44 +101,6 @@ async function begin (models, payInInitial, payInArgs, { me }) { } } -async function afterBegin (models, { payIn, mCostRemaining }, payInArgs, { me }) { - if (payIn.payInState === 'PENDING_INVOICE_CREATION') { - const payInBolt11 = await getPayInBolt11(models, { mCostRemaining, payIn }, { me }) - return await models.payIn.update({ - where: { id: payIn.id }, - data: { - payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING', - payInStateChangedAt: new Date(), - payInBolt11: { create: payInBolt11 } - }, - include: PAY_IN_INCLUDE - }) - } else if (payIn.payInState === 'PENDING_INVOICE_WRAP') { - const payInBolt11 = await getPayInBolt11Wrap(models, { mCostRemaining, payIn }, { me }) - return await models.payIn.update({ - where: { id: payIn.id }, - data: { - payInState: 'PENDING_HELD', - payInStateChangedAt: new Date(), - payInBolt11: { create: payInBolt11 } - }, - include: PAY_IN_INCLUDE - }) - } 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) - return payIn - } else { - throw new Error('Invalid payIn begin state') - } -} - export async function onFail (tx, payInId) { const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payInCustodialTokens: true, beneficiaries: true } }) if (!payIn) { @@ -230,3 +171,76 @@ export async function onPaid (tx, payInId) { await onPaid(tx, beneficiary.id) } } + +export async function retry (payInId, { models, me }) { + const include = { payOutCustodialTokens: true, payOutBolt11: true } + const where = { id: payInId, userId: me.id, payInState: 'FAILED', successorId: { is: null } } + + const payInFailed = await models.payIn.findUnique({ + where, + include: { ...include, beneficiaries: { include } } + }) + if (!payInFailed) { + throw new Error('PayIn 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') + } + + const { payIn, mCostRemaining } = await models.$transaction(async tx => { + const { payIn, mCostRemaining } = await payInCloneAndCreate(tx, payInFailed, { me }) + + // use an optimistic lock on successorId on the payIn + await tx.payIn.update({ + where, + data: { + successorId: payIn.id + } + }) + + // run the onRetry hook for the payIn and its beneficiaries + await payInTypeModules[payInFailed.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, + mCostRemaining: 0n + } + } + + return { + payIn, + mCostRemaining + } + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + + return await afterRetry(models, { payIn, mCostRemaining }, { me }) +} + +async function afterRetry (models, { payIn, mCostRemaining, payInFailureReason }, { me }) { + try { + if (payIn.payInState === 'PAID') { + // run non critical side effects + payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payIn.id).catch(console.error) + } else if (payIn.payInState === 'PENDING_INVOICE_CREATION_RETRY') { + return await payInCreatePayInBolt11(models, { mCostRemaining, payIn }, { me }) + } else if (payIn.payInState === 'PENDING_INVOICE_WRAP_RETRY') { + // TODO: replace payOutBolt11 with a new one, depending on the failure reason + return await payInCreatePayInBolt11Wrap(models, { mCostRemaining, payIn }, { me }) + } 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('id', ${payIn.id}::INTEGER), now(), 1000)`.catch(console.error) + throw e + } +} diff --git a/api/payIn/lib/is.js b/api/payIn/lib/is.js index c3bd67a20..b9b594d65 100644 --- a/api/payIn/lib/is.js +++ b/api/payIn/lib/is.js @@ -1,28 +1,38 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { payInTypeModules } from '../types' -export async function isPessimistic (payIn, { me }) { +export function isPessimistic (payIn, { me }) { const payInModule = payInTypeModules[payIn.payInType] return !me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) } -export async function isPayableWithCredits (payIn) { +export function isPayableWithCredits (payIn) { const payInModule = payInTypeModules[payIn.payInType] return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) } -export async function isInvoiceable (payIn) { +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 async function isP2P (payIn) { +export function isP2P (payIn) { const payInModule = payInTypeModules[payIn.payInType] return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) } -export async function isWithdrawal (payIn) { +export function isWithdrawal (payIn) { return payIn.payInType === 'WITHDRAWAL' } + +export function isReceiverFailure (payInFailureReason) { + return [ + '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' + ].includes(payInFailureReason) +} diff --git a/api/payIn/lib/payInBolt11.js b/api/payIn/lib/payInBolt11.js index 328df983c..de6fac0cb 100644 --- a/api/payIn/lib/payInBolt11.js +++ b/api/payIn/lib/payInBolt11.js @@ -3,6 +3,7 @@ import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-servic import lnd from '@/api/lnd' import { wrapBolt11 } from '@/wallets/server' import { payInTypeModules } from '../types' +import { PAY_IN_INCLUDE } from './payInCreate' const INVOICE_EXPIRE_SECS = 600 @@ -19,7 +20,7 @@ function payInBolt11FromBolt11 (bolt11) { } // TODO: throw errors that give us PayInFailureReason -export async function getPayInBolt11 (models, { mCostRemaining, payIn }, { me }) { +export async function payInCreatePayInBolt11 (models, { mCostRemaining, payIn }, { me }) { const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) const invoice = await createLNDinvoice({ @@ -29,10 +30,28 @@ export async function getPayInBolt11 (models, { mCostRemaining, payIn }, { me }) lnd }) - return payInBolt11FromBolt11(invoice.request) + const payInBolt11 = payInBolt11FromBolt11(invoice.request) + return await models.payIn.update({ + where: { id: payIn.id }, + data: { + payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING', + payInStateChangedAt: new Date(), + payInBolt11: { create: payInBolt11 } + }, + include: PAY_IN_INCLUDE + }) } -export async function getPayInBolt11Wrap (models, { mCostRemaining, payIn }, { me }) { +export async function payInCreatePayInBolt11Wrap (models, { mCostRemaining, payIn }, { me }) { const bolt11 = await wrapBolt11({ msats: mCostRemaining, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models, me }) - return payInBolt11FromBolt11(bolt11) + const payInBolt11 = payInBolt11FromBolt11(bolt11) + return models.payIn.update({ + where: { id: payIn.id }, + data: { + payInState: 'PENDING_HELD', + payInStateChangedAt: new Date(), + payInBolt11: { create: payInBolt11 }, + include: PAY_IN_INCLUDE + } + }) } diff --git a/api/payIn/lib/payInCreate.js b/api/payIn/lib/payInCreate.js new file mode 100644 index 000000000..7be5bd576 --- /dev/null +++ b/api/payIn/lib/payInCreate.js @@ -0,0 +1,70 @@ +import { isInvoiceable, isPessimistic } from './is' +import { getCostBreakdown, getPayInCustodialTokens } from './payInCustodialTokens' +import { payInClone, payInPrismaCreate } from './payInPrisma' + +export const PAY_IN_INCLUDE = { + payInCustodialTokens: true, + payOutBolt11: true, + pessimisticEnv: true, + user: true, + payOutCustodialTokens: true +} + +export async function payInCloneAndCreate (tx, payIn, { me }) { + return await _payInCreate(tx, payInClone(payIn), { me }, + { pendingWrapState: 'PENDING_INVOICE_WRAP_RETRY', pendingCreationState: 'PENDING_INVOICE_CREATION_RETRY' }) +} + +export async function payInCreate (tx, payIn, { me }) { + return await _payInCreate(tx, payIn, { me }, + { pendingWrapState: 'PENDING_INVOICE_WRAP', pendingCreationState: 'PENDING_INVOICE_CREATION' }) +} + +async function _payInCreate (tx, payInProspect, { me }, { pendingWrapState, pendingCreationState }) { + const { mCostRemaining, mP2PCost, payInCustodialTokens } = await getPayInCosts(tx, payInProspect, { me }) + const payInState = await getPayInState(payInProspect, { mCostRemaining, mP2PCost }, { pendingWrapState, pendingCreationState }) + const payIn = await tx.payIn.create({ + data: { + ...payInPrismaCreate({ + ...payInProspect, + payInState, + payInStateChangedAt: new Date(), + payInCustodialTokens + }), + pessimisticEnv: { + create: isPessimistic(payInProspect, { me }) ? { args: payInProspect } : undefined + } + }, + include: PAY_IN_INCLUDE + }) + return { payIn, mCostRemaining } +} + +async function getPayInCosts (tx, payIn, { me }) { + const { mP2PCost, mCustodialCost } = getCostBreakdown(payIn) + const payInCustodialTokens = await getPayInCustodialTokens(tx, mCustodialCost, payIn, { me }) + 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 }, { pendingWrapState, pendingCreationState }) { + if (mCostRemaining > 0n) { + if (!isInvoiceable(payIn)) { + throw new Error('Insufficient funds') + } + if (mP2PCost > 0n) { + return pendingWrapState + } else { + return pendingCreationState + } + } + return 'PAID' +} diff --git a/api/payIn/lib/payInPrisma.js b/api/payIn/lib/payInPrisma.js new file mode 100644 index 000000000..f0545b797 --- /dev/null +++ b/api/payIn/lib/payInPrisma.js @@ -0,0 +1,73 @@ +export function payInPrismaCreate (payIn) { + const result = {} + + if (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) { + if (typeof payIn[key] === 'object') { + result[key] = { create: payInPrismaCreate(payIn[key]) } + } else if (Array.isArray(payIn[key])) { + result[key] = { create: payIn[key].map(item => payInPrismaCreate(item)) } + } 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) { + if (typeof payIn[key] === 'object') { + result[key] = payInCloneNested(payIn[key]) + } else 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)) + } + } + } + return payInPrismaCreate(result) +} + +function payInCloneNested (payInNested) { + const result = {} + for (const key in payInNested) { + if (typeof payInNested[key] === 'object') { + result[key] = payInCloneNested(payInNested[key]) + } else if (Array.isArray(payInNested[key])) { + result[key] = payInNested[key].map(item => payInCloneNested(item)) + } else if (key !== 'payInId') { + result[key] = payInNested[key] + } + } + return result +} diff --git a/api/payIn/lib/payInPrismaCreate.js b/api/payIn/lib/payInPrismaCreate.js deleted file mode 100644 index c67acc3a4..000000000 --- a/api/payIn/lib/payInPrismaCreate.js +++ /dev/null @@ -1,34 +0,0 @@ -export function payInPrismaCreate (payIn) { - const result = {} - - if (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) { - if (typeof payIn[key] === 'object') { - result[key] = { create: payInPrismaCreate(payIn[key]) } - } else if (Array.isArray(payIn[key])) { - result[key] = { create: payIn[key].map(item => payInPrismaCreate(item)) } - } else if (payIn[key] !== undefined) { - result[key] = payIn[key] - } - } - - return result -} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 100246a7c..aadc8a240 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1311,6 +1311,7 @@ enum LogLevel { } // payIn playground +// TODO: add constraints on all sat value fields enum PayInType { BUY_CREDITS @@ -1333,7 +1334,9 @@ enum PayInType { enum PayInState { PENDING_INVOICE_CREATION + PENDING_INVOICE_CREATION_RETRY PENDING_INVOICE_WRAP + PENDING_INVOICE_WRAP_RETRY PENDING_WITHDRAWAL WITHDRAWAL_PAID WITHDRAWAL_FAILED @@ -1346,13 +1349,13 @@ enum PayInState { FORWARDED FAILED_FORWARD CANCELLED - RETRYING } 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 @@ -1397,8 +1400,9 @@ model PayIn { payInType PayInType payInState PayInState payInFailureReason PayInFailureReason? // TODO: add check constraint - payInStateChangedAt DateTime? // TODO: set with a trigger - predecessorId Int? @unique + payInStateChangedAt DateTime? // TODO: set with a trigger? + genesisId Int? + successorId Int? @unique benefactorId Int? userId Int? @@ -1412,16 +1416,18 @@ model PayIn { payOutCustodialTokens PayOutCustodialToken[] payOutBolt11 PayOutBolt11? - successor PayIn? @relation("PayInPredecessor") - predecessor PayIn? @relation("PayInPredecessor", fields: [predecessorId], references: [id], onDelete: Cascade) + 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[] + itemPayIns ItemPayIn[] + subPayIns SubPayIn[] + progeny PayIn[] @relation("PayInGenesis") @@index([userId]) @@index([payInType]) - @@index([predecessorId]) + @@index([successorId]) @@index([payInStateChangedAt]) } From 27c3615ead46986c8a9f00b3661a7b0bcf726468 Mon Sep 17 00:00:00 2001 From: k00b Date: Mon, 28 Apr 2025 15:47:15 -0500 Subject: [PATCH 09/10] decent retry logic --- api/payIn/index.js | 65 ++++++++++++++++++++--------------- api/payIn/lib/assert.js | 55 ++++------------------------- api/payIn/lib/is.js | 16 +++++---- api/payIn/lib/payInBolt11.js | 33 ++++-------------- api/payIn/lib/payInCreate.js | 26 ++++++-------- api/payIn/lib/payOutBolt11.js | 45 ++++++++++++++++++++++++ api/payIn/types/zap.js | 7 ++-- prisma/schema.prisma | 2 -- 8 files changed, 120 insertions(+), 129 deletions(-) create mode 100644 api/payIn/lib/payOutBolt11.js diff --git a/api/payIn/index.js b/api/payIn/index.js index 64df4ea0d..e5e20a30c 100644 --- a/api/payIn/index.js +++ b/api/payIn/index.js @@ -6,7 +6,9 @@ import payInTypeModules from './types' import { msatsToSats } from '@/lib/format' import { payInCreatePayInBolt11, payInCreatePayInBolt11Wrap } from './lib/payInBolt11' import { isPessimistic, isWithdrawal } from './lib/is' -import { payInCloneAndCreate, payInCreate } from './lib/payInCreate' +import { PAY_IN_INCLUDE, payInCreate } from './lib/payInCreate' +import { payOutBolt11Replacement } from './lib/payOutBolt11' +import { payInClone } from './lib/payInPrisma' export default async function pay (payInType, payInArgs, { models, me }) { try { @@ -73,14 +75,35 @@ async function begin (models, payInInitial, payInArgs, { me }) { } async function afterBegin (models, { payIn, mCostRemaining }, { me }) { + async function afterInvoiceCreation ({ payInState, payInBolt11 }) { + return await models.payIn.update({ + where: { id: payIn.id }, + data: { + payInState, + payInStateChangedAt: new Date(), + payInBolt11: { + create: payInBolt11 + } + }, + include: PAY_IN_INCLUDE + }) + } + try { if (payIn.payInState === 'PAID') { - // run non critical side effects payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payIn.id).catch(console.error) } else if (payIn.payInState === 'PENDING_INVOICE_CREATION') { - return await payInCreatePayInBolt11(models, { mCostRemaining, payIn }, { me }) + const payInBolt11 = await payInCreatePayInBolt11(models, payIn, { msats: mCostRemaining }) + return await afterInvoiceCreation({ + payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING', + payInBolt11 + }) } else if (payIn.payInState === 'PENDING_INVOICE_WRAP') { - return await payInCreatePayInBolt11Wrap(models, { mCostRemaining, payIn }, { me }) + const payInBolt11 = await payInCreatePayInBolt11Wrap(models, payIn, { msats: mCostRemaining }) + return await afterInvoiceCreation({ + payInState: 'PENDING_HELD', + payInBolt11 + }) } else if (payIn.payInState === 'PENDING_WITHDRAWAL') { const { mtokens } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') payViaPaymentRequest({ @@ -90,7 +113,6 @@ async function afterBegin (models, { payIn, mCostRemaining }, { me }) { pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, confidence: LND_PATHFINDING_TIME_PREF_PPM }).catch(console.error) - return payIn } else { throw new Error('Invalid payIn begin state') } @@ -99,6 +121,8 @@ async function afterBegin (models, { payIn, mCostRemaining }, { me }) { VALUES ('payInFailed', jsonb_build_object('id', ${payIn.id}::INTEGER), now(), 1000)`.catch(console.error) throw e } + + return payIn } export async function onFail (tx, payInId) { @@ -190,8 +214,13 @@ export async function retry (payInId, { models, me }) { throw new Error('Pessimistic payIns cannot be retried') } + let payOutBolt11 + if (payInFailed.payOutBolt11) { + payOutBolt11 = await payOutBolt11Replacement(models, payInFailed) + } + const { payIn, mCostRemaining } = await models.$transaction(async tx => { - const { payIn, mCostRemaining } = await payInCloneAndCreate(tx, payInFailed, { me }) + const { payIn, mCostRemaining } = await payInCreate(tx, payInClone({ ...payInFailed, payOutBolt11 }), { me }) // use an optimistic lock on successorId on the payIn await tx.payIn.update({ @@ -202,7 +231,7 @@ export async function retry (payInId, { models, me }) { }) // run the onRetry hook for the payIn and its beneficiaries - await payInTypeModules[payInFailed.payInType].onRetry?.(tx, payInFailed.id, payIn.id) + 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) } @@ -222,25 +251,5 @@ export async function retry (payInId, { models, me }) { } }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) - return await afterRetry(models, { payIn, mCostRemaining }, { me }) -} - -async function afterRetry (models, { payIn, mCostRemaining, payInFailureReason }, { me }) { - try { - if (payIn.payInState === 'PAID') { - // run non critical side effects - payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payIn.id).catch(console.error) - } else if (payIn.payInState === 'PENDING_INVOICE_CREATION_RETRY') { - return await payInCreatePayInBolt11(models, { mCostRemaining, payIn }, { me }) - } else if (payIn.payInState === 'PENDING_INVOICE_WRAP_RETRY') { - // TODO: replace payOutBolt11 with a new one, depending on the failure reason - return await payInCreatePayInBolt11Wrap(models, { mCostRemaining, payIn }, { me }) - } 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('id', ${payIn.id}::INTEGER), now(), 1000)`.catch(console.error) - throw e - } + return await afterBegin(models, { payIn, mCostRemaining }, { me }) } diff --git a/api/payIn/lib/assert.js b/api/payIn/lib/assert.js index a4d599c52..72900f20b 100644 --- a/api/payIn/lib/assert.js +++ b/api/payIn/lib/assert.js @@ -1,56 +1,15 @@ -import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants' -import { datePivot } from '@/lib/time' +const MAX_PENDING_PAY_IN_BOLT_11_PER_USER = 100 -const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 -const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10 -const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100 - -export async function assertBelowMaxPendingInvoices (context) { - const { models, me } = context - const pendingInvoices = await models.invoice.count({ +export async function assertBelowMaxPendingPayInBolt11s (models, userId) { + const pendingBolt11s = await models.payInBolt11.count({ where: { - userId: me?.id ?? USER_ID.anon, - actionState: { - notIn: PAID_ACTION_TERMINAL_STATES - } + userId, + confirmedAt: null, + cancelledAt: null } }) - if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) { + 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') } } - -export async function assertBelowMaxPendingDirectPayments (userId, context) { - const { models, me } = context - - if (me?.id !== userId) { - const pendingSenderInvoices = await models.directPayment.count({ - where: { - senderId: me?.id ?? USER_ID.anon, - createdAt: { - gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES }) - } - } - }) - - if (pendingSenderInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) { - throw new Error('You\'ve sent too many direct payments') - } - } - - if (!userId) return - - const pendingReceiverInvoices = await models.directPayment.count({ - where: { - receiverId: userId, - createdAt: { - gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES }) - } - } - }) - - if (pendingReceiverInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) { - throw new Error('Receiver has too many direct payments') - } -} diff --git a/api/payIn/lib/is.js b/api/payIn/lib/is.js index b9b594d65..192b48b83 100644 --- a/api/payIn/lib/is.js +++ b/api/payIn/lib/is.js @@ -1,6 +1,14 @@ import { PAID_ACTION_PAYMENT_METHODS } 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 || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) @@ -28,11 +36,5 @@ export function isWithdrawal (payIn) { } export function isReceiverFailure (payInFailureReason) { - return [ - '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' - ].includes(payInFailureReason) + return PAY_IN_RECEIVER_FAILURE_REASONS.includes(payInFailureReason) } diff --git a/api/payIn/lib/payInBolt11.js b/api/payIn/lib/payInBolt11.js index de6fac0cb..f6249b369 100644 --- a/api/payIn/lib/payInBolt11.js +++ b/api/payIn/lib/payInBolt11.js @@ -3,7 +3,6 @@ import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-servic import lnd from '@/api/lnd' import { wrapBolt11 } from '@/wallets/server' import { payInTypeModules } from '../types' -import { PAY_IN_INCLUDE } from './payInCreate' const INVOICE_EXPIRE_SECS = 600 @@ -20,38 +19,20 @@ function payInBolt11FromBolt11 (bolt11) { } // TODO: throw errors that give us PayInFailureReason -export async function payInCreatePayInBolt11 (models, { mCostRemaining, payIn }, { me }) { +export async function payInCreatePayInBolt11 (models, payIn, { msats }) { const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) const invoice = await createLNDinvoice({ - description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(models, payIn.id, { me }), - mtokens: String(mCostRemaining), + description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(models, payIn.id), + mtokens: String(msats), expires_at: expiresAt, lnd }) - const payInBolt11 = payInBolt11FromBolt11(invoice.request) - return await models.payIn.update({ - where: { id: payIn.id }, - data: { - payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING', - payInStateChangedAt: new Date(), - payInBolt11: { create: payInBolt11 } - }, - include: PAY_IN_INCLUDE - }) + return payInBolt11FromBolt11(invoice.request) } -export async function payInCreatePayInBolt11Wrap (models, { mCostRemaining, payIn }, { me }) { - const bolt11 = await wrapBolt11({ msats: mCostRemaining, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models, me }) - const payInBolt11 = payInBolt11FromBolt11(bolt11) - return models.payIn.update({ - where: { id: payIn.id }, - data: { - payInState: 'PENDING_HELD', - payInStateChangedAt: new Date(), - payInBolt11: { create: payInBolt11 }, - include: PAY_IN_INCLUDE - } - }) +export async function payInCreatePayInBolt11Wrap (models, payIn, { msats }) { + const bolt11 = await wrapBolt11({ msats, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models }) + return payInBolt11FromBolt11(bolt11) } diff --git a/api/payIn/lib/payInCreate.js b/api/payIn/lib/payInCreate.js index 7be5bd576..8795b481e 100644 --- a/api/payIn/lib/payInCreate.js +++ b/api/payIn/lib/payInCreate.js @@ -1,6 +1,7 @@ +import { assertBelowMaxPendingPayInBolt11s } from './assert' import { isInvoiceable, isPessimistic } from './is' import { getCostBreakdown, getPayInCustodialTokens } from './payInCustodialTokens' -import { payInClone, payInPrismaCreate } from './payInPrisma' +import { payInPrismaCreate } from './payInPrisma' export const PAY_IN_INCLUDE = { payInCustodialTokens: true, @@ -10,19 +11,12 @@ export const PAY_IN_INCLUDE = { payOutCustodialTokens: true } -export async function payInCloneAndCreate (tx, payIn, { me }) { - return await _payInCreate(tx, payInClone(payIn), { me }, - { pendingWrapState: 'PENDING_INVOICE_WRAP_RETRY', pendingCreationState: 'PENDING_INVOICE_CREATION_RETRY' }) -} - -export async function payInCreate (tx, payIn, { me }) { - return await _payInCreate(tx, payIn, { me }, - { pendingWrapState: 'PENDING_INVOICE_WRAP', pendingCreationState: 'PENDING_INVOICE_CREATION' }) -} - -async function _payInCreate (tx, payInProspect, { me }, { pendingWrapState, pendingCreationState }) { +export async function payInCreate (tx, payInProspect, { me }) { const { mCostRemaining, mP2PCost, payInCustodialTokens } = await getPayInCosts(tx, payInProspect, { me }) - const payInState = await getPayInState(payInProspect, { mCostRemaining, mP2PCost }, { pendingWrapState, pendingCreationState }) + const payInState = await getPayInState(payInProspect, { mCostRemaining, mP2PCost }) + if (payInState !== 'PAID') { + await assertBelowMaxPendingPayInBolt11s(tx, payInProspect.userId) + } const payIn = await tx.payIn.create({ data: { ...payInPrismaCreate({ @@ -55,15 +49,15 @@ async function getPayInCosts (tx, payIn, { me }) { } } -async function getPayInState (payIn, { mCostRemaining, mP2PCost }, { pendingWrapState, pendingCreationState }) { +async function getPayInState (payIn, { mCostRemaining, mP2PCost }) { if (mCostRemaining > 0n) { if (!isInvoiceable(payIn)) { throw new Error('Insufficient funds') } if (mP2PCost > 0n) { - return pendingWrapState + return 'PENDING_INVOICE_WRAP' } else { - return pendingCreationState + return 'PENDING_INVOICE_CREATION' } } return 'PAID' diff --git a/api/payIn/lib/payOutBolt11.js b/api/payIn/lib/payOutBolt11.js new file mode 100644 index 000000000..7c90ec198 --- /dev/null +++ b/api/payIn/lib/payOutBolt11.js @@ -0,0 +1,45 @@ +import { parsePaymentRequest } from 'ln-service' +import { PAY_IN_RECEIVER_FAILURE_REASONS } from './is' +import walletDefs, { createUserInvoice } from '@/wallets/server' +import { Prisma } from '@prisma/client' +import { canReceive } from '@/wallets/common' + +async function getLeastFailedWallet (models, { genesisId, userId }) { + const leastFailedWallets = await models.$queryRaw` + WITH "failedWallets" AS ( + SELECT count(*) as "failedCount", "PayOutBolt11"."walletId" + FROM "PayIn" + JOIN "PayOutBolt11" ON "PayOutBolt11"."payInId" = "PayIn"."id" + WHERE "PayIn"."payInFailureReason" IS NOT NULL AND "PayIn"."genesisId" = ${genesisId} + AND "PayOutBolt11"."payInFailureReason" IN (${Prisma.join(PAY_IN_RECEIVER_FAILURE_REASONS)}) + GROUP BY "PayOutBolt11"."walletId" + ) + SELECT * + FROM "Wallet" + LEFT JOIN "failedWallets" ON "failedWallets"."walletId" = "Wallet"."id" + ORDER BY "failedWallets"."failedCount" ASC, "Wallet"."priority" ASC` + + const walletsWithDefs = leastFailedWallets.map(wallet => { + const w = walletDefs.find(w => w.walletType === wallet.type) + return { wallet, def: w } + }).filter(({ def, wallet }) => canReceive({ def, config: wallet.wallet })) + + return walletsWithDefs.map(({ wallet }) => wallet) +} + +export async function payOutBolt11Replacement (models, payIn) { + const { payOutBolt11: { userId, msats }, genesisId } = payIn + const wallets = await getLeastFailedWallet(models, { genesisId, userId }) + + const { invoice: bolt11, wallet } = await createUserInvoice(userId, { msats, wallets }, { models }) + const invoice = await parsePaymentRequest({ request: bolt11 }) + + return { + payOutType: 'ZAP', + msats, + bolt11, + hash: invoice.hash, + userId, + walletId: wallet.id + } +} diff --git a/api/payIn/types/zap.js b/api/payIn/types/zap.js index 710bed7c0..eb6638171 100644 --- a/api/payIn/types/zap.js +++ b/api/payIn/types/zap.js @@ -204,15 +204,18 @@ export async function onPaidSideEffects (models, payInId) { }) // avoid duplicate notifications with the same zap amount // by checking if there are any other pending acts on the item - const pendingActs = await models.itemAct.count({ + const pendingZaps = await models.itemPayIn.count({ where: { itemId: payIn.itemPayIn.itemId, createdAt: { gt: payIn.createdAt + }, + payIn: { + payInType: 'ZAP' } } }) - if (pendingActs === 0) notifyZapped({ models, item: payIn.itemPayIn.item }).catch(console.error) + if (pendingZaps === 0) notifyZapped({ models, item: payIn.itemPayIn.item }).catch(console.error) } export async function describe (models, payInId, { me }) { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aadc8a240..1a4290484 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1334,9 +1334,7 @@ enum PayInType { enum PayInState { PENDING_INVOICE_CREATION - PENDING_INVOICE_CREATION_RETRY PENDING_INVOICE_WRAP - PENDING_INVOICE_WRAP_RETRY PENDING_WITHDRAWAL WITHDRAWAL_PAID WITHDRAWAL_FAILED From b5f8aa8b022ce50445cecaebeaffc120b7366328 Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 30 Apr 2025 18:23:53 -0500 Subject: [PATCH 10/10] conceptually coherent modules and retry logic --- api/payIn/errors.js | 7 ++ api/payIn/index.js | 24 +++-- api/payIn/lib/is.js | 2 +- api/payIn/lib/payInBolt11.js | 39 +++++--- api/payIn/lib/payInCreate.js | 10 +- api/payIn/lib/payOutBolt11.js | 48 ++++++--- api/payIn/transitions.js | 158 +++++++++++++++--------------- api/payIn/types/autoWithdrawal.js | 31 ++++++ api/payIn/types/proxyPayment.js | 15 +-- api/payIn/types/zap.js | 118 +++++++++++----------- prisma/schema.prisma | 2 + wallets/server.js | 145 +++++---------------------- 12 files changed, 288 insertions(+), 311 deletions(-) create mode 100644 api/payIn/errors.js create mode 100644 api/payIn/types/autoWithdrawal.js diff --git a/api/payIn/errors.js b/api/payIn/errors.js new file mode 100644 index 000000000..4b116e3fa --- /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 index e5e20a30c..4f77caeba 100644 --- a/api/payIn/index.js +++ b/api/payIn/index.js @@ -4,11 +4,12 @@ import { payViaPaymentRequest } from 'ln-service' import lnd from '../lnd' import payInTypeModules from './types' import { msatsToSats } from '@/lib/format' -import { payInCreatePayInBolt11, payInCreatePayInBolt11Wrap } from './lib/payInBolt11' +import { payInBolt11Prospect, payInBolt11WrapProspect } from './lib/payInBolt11' import { isPessimistic, isWithdrawal } from './lib/is' import { PAY_IN_INCLUDE, payInCreate } from './lib/payInCreate' import { payOutBolt11Replacement } from './lib/payOutBolt11' import { payInClone } from './lib/payInPrisma' +import { PayInFailureReasonError } from './errors' export default async function pay (payInType, payInArgs, { models, me }) { try { @@ -63,8 +64,8 @@ async function begin (models, payInInitial, payInArgs, { me }) { } } - // TODO: create a periodic job that checks if the invoice/withdrawal creation failed - // It will need to consider timeouts of wrapped invoice creation very carefully + 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, mCostRemaining @@ -77,7 +78,10 @@ async function begin (models, payInInitial, payInArgs, { me }) { async function afterBegin (models, { payIn, mCostRemaining }, { me }) { async function afterInvoiceCreation ({ payInState, payInBolt11 }) { return await models.payIn.update({ - where: { id: payIn.id }, + where: { + id: payIn.id, + payInState: { in: ['PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP'] } + }, data: { payInState, payInStateChangedAt: new Date(), @@ -93,13 +97,13 @@ async function afterBegin (models, { payIn, mCostRemaining }, { me }) { if (payIn.payInState === 'PAID') { payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payIn.id).catch(console.error) } else if (payIn.payInState === 'PENDING_INVOICE_CREATION') { - const payInBolt11 = await payInCreatePayInBolt11(models, payIn, { msats: mCostRemaining }) + const payInBolt11 = await payInBolt11Prospect(models, payIn, { msats: mCostRemaining }) return await afterInvoiceCreation({ payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING', payInBolt11 }) } else if (payIn.payInState === 'PENDING_INVOICE_WRAP') { - const payInBolt11 = await payInCreatePayInBolt11Wrap(models, payIn, { msats: mCostRemaining }) + const payInBolt11 = await payInBolt11WrapProspect(models, payIn, { msats: mCostRemaining }) return await afterInvoiceCreation({ payInState: 'PENDING_HELD', payInBolt11 @@ -117,8 +121,12 @@ async function afterBegin (models, { payIn, mCostRemaining }, { me }) { throw new Error('Invalid payIn begin state') } } catch (e) { + let payInFailureReason = 'EXECUTION_FAILED' + if (e instanceof PayInFailureReasonError) { + payInFailureReason = e.payInFailureReason + } models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) - VALUES ('payInFailed', jsonb_build_object('id', ${payIn.id}::INTEGER), now(), 1000)`.catch(console.error) + VALUES ('payInFailed', jsonb_build_object('payInId', ${payIn.id}::INTEGER, 'payInFailureReason', ${payInFailureReason}), now(), 1000)`.catch(console.error) throw e } @@ -216,7 +224,7 @@ export async function retry (payInId, { models, me }) { let payOutBolt11 if (payInFailed.payOutBolt11) { - payOutBolt11 = await payOutBolt11Replacement(models, payInFailed) + payOutBolt11 = await payOutBolt11Replacement(models, payInFailed.genesisId ?? payInFailed.id, payInFailed.payOutBolt11) } const { payIn, mCostRemaining } = await models.$transaction(async tx => { diff --git a/api/payIn/lib/is.js b/api/payIn/lib/is.js index 192b48b83..eec14222b 100644 --- a/api/payIn/lib/is.js +++ b/api/payIn/lib/is.js @@ -32,7 +32,7 @@ export function isP2P (payIn) { } export function isWithdrawal (payIn) { - return payIn.payInType === 'WITHDRAWAL' + return payIn.payInType === 'WITHDRAWAL' || payIn.payInType === 'AUTO_WITHDRAWAL' } export function isReceiverFailure (payInFailureReason) { diff --git a/api/payIn/lib/payInBolt11.js b/api/payIn/lib/payInBolt11.js index f6249b369..1b80c0f0e 100644 --- a/api/payIn/lib/payInBolt11.js +++ b/api/payIn/lib/payInBolt11.js @@ -3,6 +3,7 @@ import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-servic import lnd from '@/api/lnd' import { wrapBolt11 } from '@/wallets/server' import { payInTypeModules } from '../types' +import { PayInFailureReasonError } from '../errors' const INVOICE_EXPIRE_SECS = 600 @@ -18,21 +19,31 @@ function payInBolt11FromBolt11 (bolt11) { } } -// TODO: throw errors that give us PayInFailureReason -export async function payInCreatePayInBolt11 (models, payIn, { msats }) { - const createLNDinvoice = payIn.pessimisticEnv ? createHodlInvoice : createInvoice - const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) - const invoice = await createLNDinvoice({ - description: payIn.user?.hideInvoiceDesc ? undefined : await payInTypeModules[payIn.payInType].describe(models, payIn.id), - mtokens: String(msats), - expires_at: expiresAt, - lnd - }) +export async function payInBolt11Prospect (models, payIn, { msats }) { + 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 : await payInTypeModules[payIn.payInType].describe(models, payIn.id), + mtokens: String(msats), + expires_at: expiresAt, + lnd + }) - return payInBolt11FromBolt11(invoice.request) + return payInBolt11FromBolt11(invoice.request) + } catch (e) { + throw new PayInFailureReasonError('Invoice creation failed', 'INVOICE_CREATION_FAILED') + } } -export async function payInCreatePayInBolt11Wrap (models, payIn, { msats }) { - const bolt11 = await wrapBolt11({ msats, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models }) - return payInBolt11FromBolt11(bolt11) +export async function payInBolt11WrapProspect (models, payIn, { msats }) { + try { + const bolt11 = await wrapBolt11({ msats, bolt11: payIn.payOutBolt11.bolt11, expiry: INVOICE_EXPIRE_SECS }, { models }) + return payInBolt11FromBolt11(bolt11) + } catch (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 index 8795b481e..ef946bf68 100644 --- a/api/payIn/lib/payInCreate.js +++ b/api/payIn/lib/payInCreate.js @@ -1,5 +1,5 @@ import { assertBelowMaxPendingPayInBolt11s } from './assert' -import { isInvoiceable, isPessimistic } from './is' +import { isInvoiceable, isPessimistic, isWithdrawal } from './is' import { getCostBreakdown, getPayInCustodialTokens } from './payInCustodialTokens' import { payInPrismaCreate } from './payInPrisma' @@ -14,7 +14,7 @@ export const PAY_IN_INCLUDE = { export async function payInCreate (tx, payInProspect, { me }) { const { mCostRemaining, mP2PCost, payInCustodialTokens } = await getPayInCosts(tx, payInProspect, { me }) const payInState = await getPayInState(payInProspect, { mCostRemaining, mP2PCost }) - if (payInState !== 'PAID') { + if (!isWithdrawal(payInProspect) && payInState !== 'PAID') { await assertBelowMaxPendingPayInBolt11s(tx, payInProspect.userId) } const payIn = await tx.payIn.create({ @@ -54,11 +54,17 @@ async function getPayInState (payIn, { mCostRemaining, mP2PCost }) { 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/payOutBolt11.js b/api/payIn/lib/payOutBolt11.js index 7c90ec198..00d72e528 100644 --- a/api/payIn/lib/payOutBolt11.js +++ b/api/payIn/lib/payOutBolt11.js @@ -1,11 +1,10 @@ import { parsePaymentRequest } from 'ln-service' import { PAY_IN_RECEIVER_FAILURE_REASONS } from './is' -import walletDefs, { createUserInvoice } from '@/wallets/server' +import { createBolt11FromWallets } from '@/wallets/server' import { Prisma } from '@prisma/client' -import { canReceive } from '@/wallets/common' -async function getLeastFailedWallet (models, { genesisId, userId }) { - const leastFailedWallets = await models.$queryRaw` +async function getLeastFailedWallets (models, { genesisId, userId }) { + return await models.$queryRaw` WITH "failedWallets" AS ( SELECT count(*) as "failedCount", "PayOutBolt11"."walletId" FROM "PayIn" @@ -18,25 +17,42 @@ async function getLeastFailedWallet (models, { genesisId, userId }) { FROM "Wallet" LEFT JOIN "failedWallets" ON "failedWallets"."walletId" = "Wallet"."id" ORDER BY "failedWallets"."failedCount" ASC, "Wallet"."priority" ASC` +} - const walletsWithDefs = leastFailedWallets.map(wallet => { - const w = walletDefs.find(w => w.walletType === wallet.type) - return { wallet, def: w } - }).filter(({ def, wallet }) => canReceive({ def, config: wallet.wallet })) - - return walletsWithDefs.map(({ wallet }) => wallet) +async function getWallets (models, { userId }) { + return await models.wallet.findMany({ + where: { + userId, + enabled: true + }, + include: { + user: true + } + }) } -export async function payOutBolt11Replacement (models, payIn) { - const { payOutBolt11: { userId, msats }, genesisId } = payIn - const wallets = await getLeastFailedWallet(models, { genesisId, userId }) +export async function payOutBolt11Replacement (models, genesisId, { userId, msats, payOutType }) { + const wallets = await getLeastFailedWallets(models, { genesisId, userId }) + const { bolt11, wallet } = await createBolt11FromWallets(wallets, { msats }, { models }) - const { invoice: bolt11, wallet } = await createUserInvoice(userId, { msats, wallets }, { models }) const invoice = await parsePaymentRequest({ request: bolt11 }) + return { + payOutType, + msats: BigInt(invoice.mtokens), + bolt11, + hash: invoice.hash, + userId, + walletId: wallet.id + } +} +export async function payOutBolt11Prospect (models, { userId, payOutType, msats }) { + const wallets = await getWallets(models, { userId }) + const { bolt11, wallet } = await createBolt11FromWallets(wallets, { msats }, { models }) + const invoice = await parsePaymentRequest({ request: bolt11 }) return { - payOutType: 'ZAP', - msats, + payOutType, + msats: BigInt(invoice.mtokens), bolt11, hash: invoice.hash, userId, diff --git a/api/payIn/transitions.js b/api/payIn/transitions.js index 384a81537..9ecf7e716 100644 --- a/api/payIn/transitions.js +++ b/api/payIn/transitions.js @@ -9,11 +9,15 @@ import { toPositiveNumber, formatSats, msatsToSats, toPositiveBigInt, formatMsat import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' import { notifyWithdrawal } from '@/lib/webPush' +import { PayInFailureReasonError } from './errors' const PAY_IN_TERMINAL_STATES = ['PAID', 'FAILED'] const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 } -async function transitionPayIn (jobName, { payInId, fromStates, toState, transitionFunc, errorFunc, invoice, withdrawal }, { models, boss, lnd }) { +async function transitionPayIn (jobName, data, + { payInId, fromStates, toState, transitionFunc, cancelOnError }, + { invoice, withdrawal, models, boss, lnd }) { let payIn + try { const include = { payInBolt11: true, payOutBolt11: true, pessimisticEnv: true, payOutCustodialTokens: true, beneficiaries: true } const currentPayIn = await models.payIn.findUnique({ where: { id: payInId }, include }) @@ -96,31 +100,36 @@ async function transitionPayIn (jobName, { payInId, fromStates, toState, transit } console.error('unexpected error', error) - errorFunc?.(error, payIn.id, { models, boss }) - await boss.send( - jobName, - { payInId }, - { startAfter: datePivot(new Date(), { seconds: 30 }), priority: 1000 }) + 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 } } -// if we experience an unexpected error when holding an invoice, we need aggressively attempt to cancel it -// store the error in the invoice, nonblocking and outside of this tx, finalizing immediately -function errorFunc (error, payInId, { models, boss }) { - models.pessimisticEnv.update({ - where: { payInId }, - data: { - error: error.message - } - }).catch(e => console.error('failed to store payIn error', e)) - boss.send('payInCancel', { payInId, payInFailureReason: 'HELD_INVOICE_UNEXPECTED_ERROR' }, FINALIZE_OPTIONS) - .catch(e => console.error('failed to cancel payIn', e)) -} +export async function payInWithdrawalPaid ({ data, models, ...args }) { + const { payInId } = data -export async function payInWithdrawalPaid ({ data: { payInId, ...args }, models, lnd, boss }) { - const transitionedPayIn = await transitionPayIn('payInWithdrawalPaid', { + const transitionedPayIn = await transitionPayIn('payInWithdrawalPaid', data, { payInId, fromState: 'PENDING_WITHDRAWAL', toState: 'WITHDRAWAL_PAID', @@ -155,9 +164,8 @@ export async function payInWithdrawalPaid ({ data: { payInId, ...args }, models, } } } - }, - ...args - }, { models, lnd, boss }) + } + }, { models, ...args }) if (transitionedPayIn) { await notifyWithdrawal(transitionedPayIn) @@ -169,9 +177,10 @@ export async function payInWithdrawalPaid ({ data: { payInId, ...args }, models, } } -export async function payInWithdrawalFailed ({ data: { payInId, ...args }, models, lnd, boss }) { +export async function payInWithdrawalFailed ({ data, models, ...args }) { + const { payInId } = data let message - const transitionedPayIn = await transitionPayIn('payInWithdrawalFailed', { + const transitionedPayIn = await transitionPayIn('payInWithdrawalFailed', data, { payInId, fromState: 'PENDING_WITHDRAWAL', toState: 'WITHDRAWAL_FAILED', @@ -192,7 +201,7 @@ export async function payInWithdrawalFailed ({ data: { payInId, ...args }, model } } } - }) + }, { models, ...args }) if (transitionedPayIn) { const { mtokens } = transitionedPayIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') @@ -204,8 +213,9 @@ export async function payInWithdrawalFailed ({ data: { payInId, ...args }, model } } -export async function payInPaid ({ data: { payInId, ...args }, models, lnd, boss }) { - const transitionedPayIn = await transitionPayIn('payInPaid', { +export async function payInPaid ({ data, models, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInPaid', data, { payInId, fromState: ['HELD', 'PENDING', 'FORWARDED'], toState: 'PAID', @@ -225,9 +235,8 @@ export async function payInPaid ({ data: { payInId, ...args }, models, lnd, boss } } } - }, - ...args - }, { models, lnd, boss }) + } + }, { models, ...args }) if (transitionedPayIn) { // run non critical side effects in the background @@ -239,8 +248,9 @@ export async function payInPaid ({ data: { payInId, ...args }, models, lnd, boss } // this performs forward creating the outgoing payment -export async function payInForwarding ({ data: { payInId, ...args }, models, lnd, boss }) { - const transitionedPayIn = await transitionPayIn('payInForwarding', { +export async function payInForwarding ({ data, models, boss, lnd, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInForwarding', data, { payInId, fromState: 'PENDING_HELD', toState: 'FORWARDING', @@ -260,9 +270,7 @@ export async function payInForwarding ({ data: { payInId, ...args }, models, lnd if (maxTimeoutDelta - toPositiveNumber(invoice.cltv_delta) < 0) { // the payment will certainly fail, so we can // cancel and allow transition from PENDING[_HELD] -> FAILED - boss.send('payInCancel', { payInId, payInFailureReason: 'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW' }, FINALIZE_OPTIONS) - .catch(e => console.error('failed to cancel payIn', e)) - throw new Error('invoice has insufficient cltv delta for forward') + 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 @@ -271,7 +279,7 @@ export async function payInForwarding ({ data: { payInId, ...args }, models, lnd if (payIn.pessimisticEnv) { pessimisticEnv = { update: { - result: await payInTypeModules[payIn.payInType].perform(tx, payIn.id, payIn.pessimisticEnv.args) + result: await payInTypeModules[payIn.payInType].onBegin?.(tx, payIn.id, payIn.pessimisticEnv.args) } } } @@ -287,9 +295,8 @@ export async function payInForwarding ({ data: { payInId, ...args }, models, lnd pessimisticEnv } }, - errorFunc, - ...args - }, { models, lnd, boss }) + 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 @@ -315,23 +322,23 @@ export async function payInForwarding ({ data: { payInId, ...args }, models, lnd } // this finalizes the forward by settling the incoming invoice after the outgoing payment is confirmed -export async function payInForwarded ({ data: { payInId, withdrawal, ...args }, models, lnd, boss }) { - const transitionedPayIn = await transitionPayIn('payInForwarded', { +export async function payInForwarded ({ data, models, lnd, boss, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInForwarded', data, { payInId, fromState: 'FORWARDING', toState: 'FORWARDED', - transition: async ({ tx, payIn, lndPayInBolt11 }) => { + transition: async ({ tx, payIn, lndPayInBolt11, lndPayOutBolt11 }) => { if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_confirmed)) { throw new Error('invoice is not held') } - const { hash, createdAt } = payIn.payOutBolt11 - const { payment, is_confirmed: isConfirmed } = withdrawal ?? - await getPaymentOrNotSent({ id: hash, lnd, createdAt }) - if (!isConfirmed) { + 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 }) @@ -365,9 +372,8 @@ export async function payInForwarded ({ data: { payInId, withdrawal, ...args }, ] } } - }, - ...args - }, { models, lnd, boss }) + } + }, { models, lnd, boss, ...args }) if (transitionedPayIn) { const withdrawal = transitionedPayIn.payOutBolt11 @@ -383,29 +389,27 @@ export async function payInForwarded ({ data: { payInId, withdrawal, ...args }, } // when the pending forward fails, we need to cancel the incoming invoice -export async function payInFailedForward ({ data: { payInId, withdrawal: pWithdrawal, ...args }, models, lnd, boss }) { +export async function payInFailedForward ({ data, models, lnd, boss, ...args }) { + const { payInId } = data let message - const transitionedPayIn = await transitionPayIn('payInFailedForward', { + const transitionedPayIn = await transitionPayIn('payInFailedForward', data, { payInId, fromState: 'FORWARDING', toState: 'FAILED_FORWARD', - transition: async ({ tx, payIn, lndPayInBolt11 }) => { + transition: async ({ tx, payIn, lndPayInBolt11, lndPayOutBolt11 }) => { if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_cancelled)) { throw new Error('invoice is not held') } - const { hash, createdAt } = payIn.payOutBolt11 - const withdrawal = pWithdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt }) - - if (!(withdrawal?.is_failed || withdrawal?.notSent)) { - throw new Error('payment has not failed') + 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(withdrawal) + const { status, message: failureMessage } = getPaymentFailureStatus(lndPayOutBolt11) message = failureMessage return { @@ -415,9 +419,8 @@ export async function payInFailedForward ({ data: { payInId, withdrawal: pWithdr } } } - }, - ...args - }, { models, lnd, boss }) + } + }, { models, lnd, boss, ...args }) if (transitionedPayIn) { const fwd = transitionedPayIn.payOutBolt11 @@ -458,7 +461,7 @@ export async function payInHeld ({ data: { payInId, ...args }, models, lnd, boss if (payIn.pessimisticEnv) { pessimisticEnv = { update: { - result: await payInTypeModules[payIn.payInType].perform(tx, payIn.id, payIn.pessimisticEnv.args) + result: await payInTypeModules[payIn.payInType].onBegin?.(tx, payIn.id, payIn.pessimisticEnv.args) } } } @@ -475,13 +478,13 @@ export async function payInHeld ({ data: { payInId, ...args }, models, lnd, boss pessimisticEnv } }, - errorFunc, - ...args + cancelOnError: true }, { models, lnd, boss }) } -export async function payInCancel ({ data: { payInId, payInFailureReason, ...args }, models, lnd, boss }) { - const transitionedPayIn = await transitionPayIn('payInCancel', { +export async function payInCancel ({ data, models, lnd, boss, ...args }) { + const { payInId, payInFailureReason } = data + const transitionedPayIn = await transitionPayIn('payInCancel', data, { payInId, fromState: ['HELD', 'PENDING', 'PENDING_HELD', 'FAILED_FORWARD'], toState: 'CANCELLED', @@ -495,9 +498,8 @@ export async function payInCancel ({ data: { payInId, payInFailureReason, ...arg return { payInFailureReason: payInFailureReason ?? 'SYSTEM_CANCELLED' } - }, - ...args - }, { models, lnd, boss }) + } + }, { models, lnd, boss, ...args }) if (transitionedPayIn) { if (transitionedPayIn.payOutBolt11) { @@ -515,8 +517,9 @@ export async function payInCancel ({ data: { payInId, payInFailureReason, ...arg return transitionedPayIn } -export async function payInFailed ({ data: { payInId, ...args }, models, lnd, boss }) { - return await transitionPayIn('payInFailed', { +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 fromState: ['PENDING', 'PENDING_HELD', 'HELD', 'FAILED_FORWARD', 'CANCELLED', 'PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP'], @@ -536,15 +539,12 @@ export async function payInFailed ({ data: { payInId, ...args }, models, lnd, bo await onFail(tx, payIn.id) - const payInFailureReason = !lndPayInBolt11 - ? 'INVOICE_CREATION_FAILED' - : (payIn.payInFailureReason ?? 'INVOICE_EXPIRED') + const reason = payInFailureReason ?? payIn.payInFailureReason ?? 'UNKNOWN_FAILURE' return { - payInFailureReason, + payInFailureReason: reason, payInBolt11 } - }, - ...args - }, { models, lnd, boss }) + } + }, { models, lnd, boss, ...args }) } diff --git a/api/payIn/types/autoWithdrawal.js b/api/payIn/types/autoWithdrawal.js new file mode 100644 index 000000000..8e5eeec25 --- /dev/null +++ b/api/payIn/types/autoWithdrawal.js @@ -0,0 +1,31 @@ +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 }) { + const payOutBolt11 = await payOutBolt11Prospect(models, { userId: me.id, payOutType: 'WITHDRAWAL', msats }) + return { + payInType: 'AUTO_WITHDRAWAL', + userId: me?.id, + mcost: msats + maxFeeMsats, + payOutBolt11, + payOutCustodialTokens: [ + { + payOutType: 'ROUTING_FEE', + userId: null, + mtokens: maxFeeMsats + } + ] + } +} + +export async function describe (models, payInId, { me }) { + 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/proxyPayment.js b/api/payIn/types/proxyPayment.js index 541029d36..3a58c76cf 100644 --- a/api/payIn/types/proxyPayment.js +++ b/api/payIn/types/proxyPayment.js @@ -1,8 +1,7 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format' import { notifyDeposit } from '@/lib/webPush' -import { createUserInvoice } from '@/wallets/server' -import { parsePaymentRequest } from 'ln-service' +import { payOutBolt11Prospect } from '../lib/payOutBolt11' export const anonable = false export const paymentMethods = [ @@ -16,8 +15,7 @@ export async function getInitial (models, { msats }, { me }) { const rewardsPoolMtokens = mcost * 7n / 100n const proxyPaymentMtokens = mcost - routingFeeMtokens - rewardsPoolMtokens - const { invoice: bolt11, wallet } = await createUserInvoice(me.id, { msats: proxyPaymentMtokens }, { models }) - const invoice = await parsePaymentRequest({ request: bolt11 }) + const payOutBolt11 = await payOutBolt11Prospect(models, { userId: me.id, payOutType: 'PROXY_PAYMENT', msats: proxyPaymentMtokens }) return { payInType: 'PROXY_PAYMENT', @@ -27,14 +25,7 @@ export async function getInitial (models, { msats }, { me }) { { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }, { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardsPoolMtokens, custodialTokenType: 'SATS' } ], - payOutBolt11: { - payOutType: 'PROXY_PAYMENT', - hash: invoice.id, - bolt11, - msats: proxyPaymentMtokens, - userId: me.id, - walletId: wallet.id - } + payOutBolt11 } } diff --git a/api/payIn/types/zap.js b/api/payIn/types/zap.js index eb6638171..6b52a4bbe 100644 --- a/api/payIn/types/zap.js +++ b/api/payIn/types/zap.js @@ -1,9 +1,8 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' import { notifyZapped } from '@/lib/webPush' -import { createUserInvoice, getInvoiceableWallets } from '@/wallets/server' import { Prisma } from '@prisma/client' -import { parsePaymentRequest } from 'ln-service' +import { payOutBolt11Prospect } from '../lib/payOutBolt11' export const anonable = true @@ -15,13 +14,13 @@ export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC ] -async function getInvoiceablePeer (models, { id, sats, hasSendWallet }, { me }) { +async function tryP2P (models, { id, sats, hasSendWallet }, { me }) { 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 || (me && !hasSendWallet && (zapper.mcredits + zapper.msats >= satsToMsats(sats)))) { - return null + return false } const item = await models.item.findUnique({ @@ -32,22 +31,12 @@ async function getInvoiceablePeer (models, { id, sats, hasSendWallet }, { me }) } }) - // bios don't get sats - if (item.bio) { - return null + // bios, forwards, or dust don't get sats + if (item.bio || item.itemForwards.length > 0 || sats < item.user.receiveCreditsBelowSats) { + return false } - const wallets = await getInvoiceableWallets(item.userId, { models }) - - // request peer invoice if they have an attached wallet and have not forwarded the item - // and the receiver doesn't want to receive credits - if (wallets.length > 0 && - item.itemForwards.length === 0 && - sats >= item.user.receiveCreditsBelowSats) { - return item.userId - } - - return null + return true } // 70% to the receiver(s), 21% to the territory founder, the rest depends on if it's P2P or not @@ -55,57 +44,64 @@ export async function getInitial (models, payInArgs, { me }) { const { sub, itemForwards, userId } = await models.item.findUnique({ where: { id: parseInt(payInArgs.id) }, include: { sub: true, itemForwards: true, user: true } }) const mcost = satsToMsats(payInArgs.sats) const founderMtokens = mcost * 21n / 100n - const payOutCustodialTokens = [{ - payOutType: 'TERRITORY_REVENUE', - userId: sub.userId, - mtokens: founderMtokens, - custodialTokenType: 'SATS' - }] - - let payOutBolt11 - const invoiceablePeer = await getInvoiceablePeer(models, payInArgs, { me }) - if (invoiceablePeer) { - const routingFeeMtokens = mcost * 3n / 100n - const rewardsPoolMtokens = mcost * 6n / 100n - const zapMtokens = mcost - routingFeeMtokens - rewardsPoolMtokens - const { invoice: bolt11, wallet } = await createUserInvoice(me.id, { msats: zapMtokens }, { models }) - const invoice = await parsePaymentRequest({ request: bolt11 }) - - // 6% to rewards pool, 3% to routing fee - payOutCustodialTokens.push( - { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }, - { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardsPoolMtokens, custodialTokenType: 'SATS' }) - payOutBolt11 = { - payOutType: 'ZAP', - msats: BigInt(invoice.mtokens), - bolt11, - hash: invoice.hash, - userId: invoiceablePeer, - walletId: wallet.id - } - } else { - // 9% to rewards pool - const rewardsPoolMtokens = mcost * 9n / 100n - const zapMtokens = mcost - rewardsPoolMtokens - founderMtokens - if (itemForwards.length > 0) { - for (const f of itemForwards) { - payOutCustodialTokens.push({ payOutType: 'ZAP', userId: f.userId, mtokens: zapMtokens * BigInt(f.pct) / 100n, custodialTokenType: 'CREDITS' }) - } - } - const remainingZapMtokens = zapMtokens - payOutCustodialTokens.filter(t => t.payOutType === 'ZAP').reduce((acc, t) => acc + t.mtokens, 0n) - payOutCustodialTokens.push({ payOutType: 'ZAP', userId, mtokens: remainingZapMtokens, custodialTokenType: 'CREDITS' }) - } - return { + const result = { payInType: 'ZAP', userId: me.id, mcost, - payOutCustodialTokens, - payOutBolt11, + payOutCustodialTokens: [{ + payOutType: 'TERRITORY_REVENUE', + userId: sub.userId, + mtokens: founderMtokens, + custodialTokenType: 'SATS' + }], + payOutBolt11: null, itemPayIn: { itemId: parseInt(payInArgs.id) } } + + const p2p = await tryP2P(models, payInArgs, { me }) + if (p2p) { + try { + // 6% to rewards pool, 3% to routing fee + const routingFeeMtokens = mcost * 3n / 100n + const rewardsPoolMtokens = mcost * 6n / 100n + const zapMtokens = mcost - routingFeeMtokens - rewardsPoolMtokens + + return { + ...result, + payOutCustodialTokens: [ + ...result.payOutCustodialTokens, + { payOutType: 'ROUTING_FEE', userId: null, mtokens: routingFeeMtokens, custodialTokenType: 'SATS' }, + { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardsPoolMtokens, custodialTokenType: 'SATS' } + ], + payOutBolt11: await payOutBolt11Prospect(models, { userId, payOutType: 'ZAP', msats: zapMtokens }) + } + } catch (err) { + console.error('failed to create user invoice:', err) + } + } + + // 9% to rewards pool + const rewardsPoolMtokens = mcost * 9n / 100n + const zapMtokens = mcost - rewardsPoolMtokens - founderMtokens + const payOutCustodialTokens = [] + if (itemForwards.length > 0) { + for (const f of itemForwards) { + payOutCustodialTokens.push({ payOutType: 'ZAP', userId: f.userId, mtokens: zapMtokens * BigInt(f.pct) / 100n, custodialTokenType: 'CREDITS' }) + } + } + const remainingZapMtokens = zapMtokens - payOutCustodialTokens.filter(t => t.payOutType === 'ZAP').reduce((acc, t) => acc + t.mtokens, 0n) + payOutCustodialTokens.push({ payOutType: 'ZAP', userId, mtokens: remainingZapMtokens, custodialTokenType: 'CREDITS' }) + + return { + ...result, + payOutCustodialTokens: [ + ...result.payOutCustodialTokens, + ...payOutCustodialTokens + ] + } } export async function onRetry (tx, oldPayInId, newPayInId) { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1a4290484..abdf5b2b8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1330,6 +1330,7 @@ enum PayInType { PROXY_PAYMENT REWARDS WITHDRAWAL + AUTO_WITHDRAWAL } enum PayInState { @@ -1363,6 +1364,7 @@ enum PayInFailureReason { SYSTEM_CANCELLED INVOICE_EXPIRED EXECUTION_FAILED + UNKNOWN_FAILURE } model ItemPayIn { diff --git a/wallets/server.js b/wallets/server.js index 6c0c59273..229290372 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -14,27 +14,19 @@ import * as webln from '@/wallets/webln' import { walletLogger } from '@/api/resolvers/wallet' import walletDefs from '@/wallets/server' import { parsePaymentRequest } from 'ln-service' -import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format' -import { PAID_ACTION_TERMINAL_STATES, WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format' +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { timeoutSignal, withTimeout } from '@/lib/time' import { canReceive } from './common' -import wrapInvoice from './wrap' export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] const MAX_PENDING_INVOICES_PER_WALLET = 25 -export async function * createUserInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { paymentAttempt, predecessorId, models }) { - // get the wallets in order of priority - const wallets = await getInvoiceableWallets(userId, { - paymentAttempt, - predecessorId, - models - }) - +export async function createBolt11FromWallets (wallets, { msats, description, descriptionHash, expiry = 360 }, { models }) { msats = toPositiveNumber(msats) - for (const { def, wallet } of wallets) { + for (const { def, wallet } of walletsWithDefsReceiveable(wallets)) { const logger = walletLogger({ wallet, models }) try { @@ -43,9 +35,9 @@ export async function * createUserInvoice (userId, { msats, description, descrip amount: formatMsats(msats) }) - let invoice + let bolt11 try { - invoice = await walletCreateInvoice( + bolt11 = await createBolt11FromWallet( { wallet, def }, { msats, description, descriptionHash, expiry }, { logger, models }) @@ -53,25 +45,27 @@ 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, wallet, logger } + // TODO: add option to check if wrap will succeed + + return { bolt11, wallet, logger } } catch (err) { console.error('failed to create user invoice:', err) logger.error(err.message, { status: true }) @@ -79,117 +73,32 @@ 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, wallet } 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, - wallet, - maxFee - } - } catch (e) { - console.error('failed to wrap invoice:', e) - logger?.error('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. - const wallets = await models.$queryRaw` - SELECT - "Wallet".*, - jsonb_build_object( - 'id', "users"."id", - 'hideInvoiceDesc', "users"."hideInvoiceDesc" - ) AS "user" - FROM "Wallet" - JOIN "users" ON "users"."id" = "Wallet"."userId" - WHERE - "Wallet"."userId" = ${userId} - AND "Wallet"."enabled" = true - AND "Wallet"."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"."walletId" - 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` - - const walletsWithDefs = wallets.map(wallet => { +export async function walletsWithDefsReceiveable (wallets) { + return wallets.map(wallet => { const w = walletDefs.find(w => w.walletType === wallet.type) return { wallet, def: w } - }) - - return walletsWithDefs.filter(({ def, wallet }) => canReceive({ def, config: wallet.wallet })) + }).filter(({ def, wallet }) => canReceive({ def, config: wallet.wallet })) } -async function walletCreateInvoice ({ wallet, def }, { +async function createBolt11FromWallet ({ wallet, def }, { msats, description, descriptionHash, expiry = 360 }, { logger, models }) { - // check for pending withdrawals - const pendingWithdrawals = await models.withdrawl.count({ + // check for pending payouts + const pendingPayOutBolt11Count = await models.payOutBolt11.count({ where: { walletId: wallet.id, - status: null - } - }) - - // and pending forwards - const pendingForwards = await models.invoiceForward.count({ - where: { - walletId: wallet.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(