diff --git a/api/payIn/README.md b/api/payIn/README.md new file mode 100644 index 000000000..a32588076 --- /dev/null +++ b/api/payIn/README.md @@ -0,0 +1,371 @@ +# Paid Actions + +Paid actions are actions that require payments to perform. Given that we support several payment flows, some of which require more than one round of communication either with LND or the client, and several paid actions, we have this plugin-like interface to easily add new paid actions. + +
+ internals + +All paid action progress, regardless of flow, is managed using a state machine that's transitioned by the invoice progress and payment progress (in the case of p2p paid action). Below is the full state machine for paid actions: + +```mermaid +stateDiagram-v2 + [*] --> PENDING + PENDING --> PAID + PENDING --> CANCELING + PENDING --> FAILED + PAID --> [*] + CANCELING --> FAILED + FAILED --> RETRYING + FAILED --> [*] + RETRYING --> [*] + [*] --> PENDING_HELD + PENDING_HELD --> HELD + PENDING_HELD --> FORWARDING + PENDING_HELD --> CANCELING + PENDING_HELD --> FAILED + HELD --> PAID + HELD --> CANCELING + HELD --> FAILED + FORWARDING --> FORWARDED + FORWARDING --> FAILED_FORWARD + FORWARDED --> PAID + FAILED_FORWARD --> CANCELING + FAILED_FORWARD --> FAILED +``` +
+ +## Payment Flows + +There are three payment flows: + +### Fee credits +The stacker has enough fee credits to pay for the action. This is the simplest flow and is similar to a normal request. + +### Optimistic +The optimistic flow is useful for actions that require immediate feedback to the client, but don't require the action to be immediately visible to everyone else. + +For paid actions that support it, if the stacker doesn't have enough fee credits, we store the action in a `PENDING` state on the server, which is visible only to the stacker, then return a payment request to the client. The client then pays the invoice however and whenever they wish, and the server monitors payment progress. If the payment succeeds, the action is executed fully becoming visible to everyone and is marked as `PAID`. Otherwise, the action is marked as `FAILED`, the client is notified the payment failed and the payment can be retried. + +
+ Internals + +Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress. + +```mermaid +stateDiagram-v2 + [*] --> PENDING + PENDING --> PAID + PENDING --> CANCELING + PENDING --> FAILED + PAID --> [*] + CANCELING --> FAILED + FAILED --> RETRYING + FAILED --> [*] + RETRYING --> [*] +``` +
+ +### Pessimistic +For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without performing the action and only storing the action's arguments. After the client pays the invoice, the server performs the action with original arguments. Pessimistic actions require the payment to complete before being visible to them and everyone else. + +Internally, pessimistic flows use hold invoices. If the action doesn't succeed, the payment is cancelled and it's as if the payment never happened (ie it's a lightning native refund mechanism). + +
+ Internals + +Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps. + +```mermaid +stateDiagram-v2 + PAID --> [*] + CANCELING --> FAILED + FAILED --> [*] + [*] --> PENDING_HELD + PENDING_HELD --> HELD + PENDING_HELD --> CANCELING + PENDING_HELD --> FAILED + HELD --> PAID + HELD --> CANCELING + HELD --> FAILED +``` +
+ +### Table of existing paid actions and their supported flows + +| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects | reward sats | p2p direct | +| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ | ----------- | ---------- | +| zaps | x | x | x | x | x | x | x | | | +| posts | x | x | x | x | x | | x | x | | +| comments | x | x | x | x | x | | x | x | | +| downzaps | x | x | | | x | | x | x | | +| poll votes | x | x | | | x | | | x | | +| territory actions | x | | x | | x | | | x | | +| donations | x | | x | x | x | | | x | | +| update posts | x | | x | | x | | x | x | | +| update comments | x | | x | | x | | x | x | | +| receive | | x | | | x | x | x | | x | +| buy fee credits | | | x | | x | | | x | | +| invite gift | x | | | | | | x | x | | + +## Not-custodial zaps (ie p2p wrapped payments) +Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap. + +This works by requesting an invoice from the recipient's wallet and reusing the payment hash in a hold invoice paid to SN (to collect the sybil fee) which we serve to the sender. When the sender pays this wrapped invoice, we forward our own money to the recipient, who then reveals the preimage to us, allowing us to settle the wrapped invoice and claim the sender's funds. This effectively does what a lightning node does when forwarding a payment but allows us to do it at the application layer. + +
+ Internals + + Internally, p2p wrapped payments make use of the same paid action state machine but it's transitioned by both the incoming invoice payment progress *and* the outgoing invoice payment progress. + +```mermaid +stateDiagram-v2 + PAID --> [*] + CANCELING --> FAILED + FAILED --> RETRYING + FAILED --> [*] + RETRYING --> [*] + [*] --> PENDING_HELD + PENDING_HELD --> FORWARDING + PENDING_HELD --> CANCELING + PENDING_HELD --> FAILED + FORWARDING --> FORWARDED + FORWARDING --> FAILED_FORWARD + FORWARDED --> PAID + FAILED_FORWARD --> CANCELING + FAILED_FORWARD --> FAILED +``` +
+ +## Paid Action Interface + +Each paid action is implemented in its own file in the `paidAction` directory. Each file exports a module with the following properties: + +### Boolean flags +- `anonable`: can be performed anonymously + +### Payment methods +- `paymentMethods`: an array of payment methods that the action supports ordered from most preferred to least preferred + - P2P: a p2p payment made directly from the client to the recipient + - after wrapping the invoice, anonymous users will follow a PESSIMISTIC flow to pay the invoice and logged in users will follow an OPTIMISTIC flow + - FEE_CREDIT: a payment made from the user's fee credit balance + - OPTIMISTIC: an optimistic payment flow + - PESSIMISTIC: a pessimistic payment flow + +### Functions + +All functions have the following signature: `function(args: Object, context: Object): Promise` + +- `getCost`: returns the cost of the action in msats as a `BigInt` +- `perform`: performs the action + - returns: an object with the result of the action as defined in the `graphql` schema + - if the action supports optimism and an `invoiceId` is provided, the action should be performed optimistically + - any action data that needs to be hidden while it's pending, should store in its rows a `PENDING` state along with its `invoiceId` + - it can optionally store in the invoice with the `invoiceId` the `actionId` to be able to link the action with the invoice regardless of retries +- `onPaid`: called when the action is paid + - if the action does not support optimism, this function is optional + - this function should be used to mark the rows created in `perform` as `PAID` and perform critical side effects of the action (like denormalizations) +- `nonCriticalSideEffects`: called after the action is paid to run any side effects whose failure does not affect the action's execution + - this function is always optional + - it's passed the result of the action (or the action's paid invoice) and the current context + - this is where things like push notifications should be handled +- `onFail`: called when the action fails + - if the action does not support optimism, this function is optional + - this function should be used to mark the rows created in `perform` as `FAILED` +- `retry`: called when the action is retried with any new invoice information + - return: an object with the result of the action as defined in the `graphql` schema (same as `perform`) + - this function is called when an optimistic action is retried + - it's passed the original `invoiceId` and the `newInvoiceId` + - this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING` +- `getInvoiceablePeer`: returns the userId of the peer that's capable of generating an invoice so they can be paid for the action + - this is only used for p2p wrapped zaps currently +- `describe`: returns a description as a string of the action + - for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description +- `getSybilFeePercent` (required if `getInvoiceablePeer` is implemented): returns the action sybil fee percent as a `BigInt` (eg. 30n for 30%) + +#### Function arguments + +`args` contains the arguments for the action as defined in the `graphql` schema. If the action is optimistic or pessimistic, `args` will contain an `invoiceId` field which can be stored alongside the paid action's data. If this is a call to `retry`, `args` will contain the original `invoiceId` and `newInvoiceId` fields. + +`context` contains the following fields: +- `me`: the user performing the action (undefined if anonymous) +- `cost`: the cost of the action in msats as a `BigInt` +- `sybilFeePercent`: the sybil fee percent as a `BigInt` (eg. 30n for 30%) +- `tx`: the current transaction (for anything that needs to be done atomically with the payment) +- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment) +- `lnd`: the current lnd client + +## Recording Cowboy Credits + +To avoid adding sats and credits together everywhere to show an aggregate sat value, in most cases we denormalize a `sats` field that carries the "sats value", the combined sats + credits of something, and a `credits` field that carries only the earned `credits`. For example, the `Item` table has an `msats` field that carries the sum of the `mcredits` and `msats` earned and a `mcredits` field that carries the value of the `mcredits` earned. So, the sats value an item earned is `item.msats` BUT the real sats earned is `item.msats - item.mcredits`. + +The ONLY exception to this are for the `users` table where we store a stacker's rewards sats and credits balances separately. + +## `IMPORTANT: transaction isolation` + +We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies). + +### This is a big deal +1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that. +2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction: + - independent statements + - `WITH` queries (CTEs) in the same statement + - subqueries in the same statement + +### How to handle it +1. take row level locks on the rows you read, using something like a `SELECT ... FOR UPDATE` statement + - NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read. + - read about row level locks available in postgres: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS +2. check that the data you read is still valid before writing it back to the database i.e. optimistic concurrency control + - NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read. +3. avoid having to read data from one row to modify the data of another row all together + +### Example + +Let's say you are aggregating total sats for an item from a table `zaps` and updating the total sats for that item in another table `item_zaps`. Two 100 sat zaps are requested for the same item at the same time in two concurrent transactions. The total sats for the item should be 200, but because of the way `read committed` works, the following statements lead to a total sats of 100: + +*the statements here are listed in the order they are executed, but each transaction is happening concurrently* + +#### Incorrect + +```sql +-- transaction 1 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1; +-- total_sats is 100 +-- transaction 2 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1; +-- total_sats is still 100, because transaction 1 hasn't committed yet +-- transaction 1 +UPDATE item_zaps SET sats = total_sats WHERE item_id = 1; +-- sets sats to 100 +-- transaction 2 +UPDATE item_zaps SET sats = total_sats WHERE item_id = 1; +-- sets sats to 100 +COMMIT; +-- transaction 1 +COMMIT; +-- item_zaps.sats is 100, but we would expect it to be 200 +``` + +Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions don't know to exist yet. + +#### Subqueries are still incorrect + +```sql +-- transaction 1 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1; +-- item_zaps.sats is 100 +-- transaction 2 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1; +-- item_zaps.sats is still 100, because transaction 1 hasn't committed yet +-- transaction 1 +COMMIT; +-- transaction 2 +COMMIT; +-- item_zaps.sats is 100, but we would expect it to be 200 +``` + +Note that while the `UPDATE` transaction 2's update statement will block until transaction 1 commits, the subquery is computed before it blocks and is not re-evaluated after the block. + +#### Correct + +```sql +-- transaction 1 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +-- transaction 2 +BEGIN; +INSERT INTO zaps (item_id, sats) VALUES (1, 100); +-- transaction 1 +UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1; +-- transaction 2 +UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1; +COMMIT; +-- transaction 1 +COMMIT; +-- item_zaps.sats is 200 +``` + +The above works because `UPDATE` takes a lock on the rows it's updating, so transaction 2 will block until transaction 1 commits, and once transaction 2 is unblocked, it will re-evaluate the `sats` value of the row it's updating. + +#### More resources +- https://stackoverflow.com/questions/61781595/postgres-read-commited-doesnt-re-read-updated-row?noredirect=1#comment109279507_61781595 +- https://www.cybertec-postgresql.com/en/transaction-anomalies-with-select-for-update/ + +From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED): +> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client. + +From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350): +> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows. + +## `IMPORTANT: deadlocks` + +Deadlocks can occur when two transactions are waiting for each other to release locks. This can happen when two transactions lock rows in different orders whether explicit or implicit. + +If both transactions lock the rows in the same order, the deadlock is avoided. + +### Incorrect + +```sql +-- transaction 1 +BEGIN; +UPDATE users set msats = msats + 1 WHERE id = 1; +-- transaction 2 +BEGIN; +UPDATE users set msats = msats + 1 WHERE id = 2; +-- transaction 1 (blocks here until transaction 2 commits) +UPDATE users set msats = msats + 1 WHERE id = 2; +-- transaction 2 (blocks here until transaction 1 commits) +UPDATE users set msats = msats + 1 WHERE id = 1; +-- deadlock occurs because neither transaction can proceed to here +``` + +In practice, this most often occurs when selecting multiple rows for update in different orders. Recently, we had a deadlock when spliting zaps to multiple users. The solution was to select the rows for update in the same order. + +### Incorrect + +```sql +WITH forwardees AS ( + SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats + FROM "ItemForward" + WHERE "itemId" = $2::INTEGER +), +UPDATE users + SET + msats = users.msats + forwardees.msats, + "stackedMsats" = users."stackedMsats" + forwardees.msats + FROM forwardees + WHERE users.id = forwardees."userId"; +``` + +If forwardees are selected in a different order in two concurrent transactions, e.g. (1,2) in tx 1 and (2,1) in tx 2, a deadlock can occur. To avoid this, always select rows for update in the same order. + +### Correct + +We fixed the deadlock by selecting the forwardees in the same order in these transactions. + +```sql +WITH forwardees AS ( + SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats + FROM "ItemForward" + WHERE "itemId" = $2::INTEGER + ORDER BY "userId" ASC +), +UPDATE users + SET + msats = users.msats + forwardees.msats, + "stackedMsats" = users."stackedMsats" + forwardees.msats + FROM forwardees + WHERE users.id = forwardees."userId"; +``` + +### More resources + +- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS diff --git a/api/payIn/errors.js b/api/payIn/errors.js new file mode 100644 index 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 new file mode 100644 index 000000000..4f77caeba --- /dev/null +++ b/api/payIn/index.js @@ -0,0 +1,263 @@ +import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, USER_ID } from '@/lib/constants' +import { Prisma } from '@prisma/client' +import { payViaPaymentRequest } from 'ln-service' +import lnd from '../lnd' +import payInTypeModules from './types' +import { msatsToSats } from '@/lib/format' +import { payInBolt11Prospect, payInBolt11WrapProspect } from './lib/payInBolt11' +import { isPessimistic, isWithdrawal } from './lib/is' +import { PAY_IN_INCLUDE, payInCreate } from './lib/payInCreate' +import { payOutBolt11Replacement } from './lib/payOutBolt11' +import { payInClone } from './lib/payInPrisma' +import { PayInFailureReasonError } from './errors' + +export default async function pay (payInType, payInArgs, { models, me }) { + try { + const payInModule = payInTypeModules[payInType] + + console.group('payIn', payInType, payInArgs) + + if (!payInModule) { + throw new Error(`Invalid payIn type ${payInType}`) + } + + if (!me && !payInModule.anonable) { + throw new Error('You must be logged in to perform this action') + } + + // need to double check all old usage of !me for detecting anon users + me ??= { id: USER_ID.anon } + + const payIn = await payInModule.getInitial(models, payInArgs, { me }) + return await begin(models, payIn, payInArgs, { me }) + } catch (e) { + console.error('performPaidAction failed', e) + throw e + } finally { + console.groupEnd() + } +} + +async function begin (models, payInInitial, payInArgs, { me }) { + const payInModule = payInTypeModules[payInInitial.payInType] + + const { payIn, mCostRemaining } = await models.$transaction(async tx => { + 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) { + return { + payIn, + mCostRemaining + } + } + + // if it's optimistic or already paid, we perform the action + 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 (payIn.payInState === 'PAID') { + await onPaid(tx, payIn.id, { me }) + return { + payIn, + mCostRemaining: 0n + } + } + + 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 + } + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + + return await afterBegin(models, { payIn, mCostRemaining }, { me }) +} + +async function afterBegin (models, { payIn, mCostRemaining }, { me }) { + async function afterInvoiceCreation ({ payInState, payInBolt11 }) { + return await models.payIn.update({ + where: { + id: payIn.id, + payInState: { in: ['PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP'] } + }, + data: { + payInState, + payInStateChangedAt: new Date(), + payInBolt11: { + create: payInBolt11 + } + }, + include: PAY_IN_INCLUDE + }) + } + + try { + if (payIn.payInState === 'PAID') { + payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payIn.id).catch(console.error) + } else if (payIn.payInState === 'PENDING_INVOICE_CREATION') { + const payInBolt11 = await payInBolt11Prospect(models, payIn, { msats: mCostRemaining }) + return await afterInvoiceCreation({ + payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING', + payInBolt11 + }) + } else if (payIn.payInState === 'PENDING_INVOICE_WRAP') { + const payInBolt11 = await payInBolt11WrapProspect(models, payIn, { msats: mCostRemaining }) + return await afterInvoiceCreation({ + payInState: 'PENDING_HELD', + payInBolt11 + }) + } else if (payIn.payInState === 'PENDING_WITHDRAWAL') { + const { mtokens } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + payViaPaymentRequest({ + lnd, + request: payIn.payOutBolt11.bolt11, + max_fee: msatsToSats(mtokens), + pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, + confidence: LND_PATHFINDING_TIME_PREF_PPM + }).catch(console.error) + } else { + throw new Error('Invalid payIn begin state') + } + } catch (e) { + 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('payInId', ${payIn.id}::INTEGER, 'payInFailureReason', ${payInFailureReason}), now(), 1000)`.catch(console.error) + throw e + } + + return payIn +} + +export async function onFail (tx, payInId) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payInCustodialTokens: true, beneficiaries: true } }) + if (!payIn) { + throw new Error('PayIn not found') + } + + // refund the custodial tokens + for (const payInCustodialToken of payIn.payInCustodialTokens) { + await tx.$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 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, beneficiaries: true } }) + if (!payIn) { + throw new Error('PayIn not found') + } + + for (const payOut of payIn.payOutCustodialTokens) { + // if the payOut is not for a user, it's a system payOut + if (!payOut.userId) { + continue + } + await tx.$queryRaw` + WITH user AS ( + UPDATE users + SET msats = msats + ${payOut.custodialTokenType === 'SATS' ? 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 + ) + UPDATE "PayOutCustodialToken" + SET "msatsBefore" = user.msatsBefore, "mcreditsBefore" = user.mcreditsBefore + FROM user + 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) + for (const beneficiary of payIn.beneficiaries) { + 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') + } + + let payOutBolt11 + if (payInFailed.payOutBolt11) { + payOutBolt11 = await payOutBolt11Replacement(models, payInFailed.genesisId ?? payInFailed.id, payInFailed.payOutBolt11) + } + + const { payIn, mCostRemaining } = await models.$transaction(async tx => { + const { payIn, mCostRemaining } = await payInCreate(tx, payInClone({ ...payInFailed, payOutBolt11 }), { 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[payIn.payInType].onRetry?.(tx, payInFailed.id, payIn.id) + for (const beneficiary of payIn.beneficiaries) { + await payInTypeModules[beneficiary.payInType].onRetry?.(tx, beneficiary.id, payIn.id) + } + + // if it's already paid, we run onPaid and do payOuts in the same transaction + if (payIn.payInState === 'PAID') { + await onPaid(tx, payIn.id, { me }) + return { + payIn, + mCostRemaining: 0n + } + } + + return { + payIn, + mCostRemaining + } + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + + return await afterBegin(models, { payIn, mCostRemaining }, { me }) +} diff --git a/api/payIn/lib/assert.js b/api/payIn/lib/assert.js new file mode 100644 index 000000000..72900f20b --- /dev/null +++ b/api/payIn/lib/assert.js @@ -0,0 +1,15 @@ +const MAX_PENDING_PAY_IN_BOLT_11_PER_USER = 100 + +export async function assertBelowMaxPendingPayInBolt11s (models, userId) { + const pendingBolt11s = await models.payInBolt11.count({ + where: { + userId, + confirmedAt: null, + cancelledAt: null + } + }) + + if (pendingBolt11s >= MAX_PENDING_PAY_IN_BOLT_11_PER_USER) { + throw new Error('You have too many pending paid actions, cancel some or wait for them to expire') + } +} diff --git a/api/payIn/lib/is.js b/api/payIn/lib/is.js new file mode 100644 index 000000000..eec14222b --- /dev/null +++ b/api/payIn/lib/is.js @@ -0,0 +1,40 @@ +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) +} + +export function isPayableWithCredits (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) +} + +export function isInvoiceable (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) || + payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) || + payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) +} + +export function isP2P (payIn) { + const payInModule = payInTypeModules[payIn.payInType] + return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P) +} + +export function isWithdrawal (payIn) { + return payIn.payInType === 'WITHDRAWAL' || payIn.payInType === 'AUTO_WITHDRAWAL' +} + +export function isReceiverFailure (payInFailureReason) { + return PAY_IN_RECEIVER_FAILURE_REASONS.includes(payInFailureReason) +} diff --git a/api/payIn/lib/item.js b/api/payIn/lib/item.js new file mode 100644 index 000000000..f7ed0156a --- /dev/null +++ b/api/payIn/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 (tx, { text }, { me }) { + 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 (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 { + 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 (tx, { text, id }, { me }) { + // 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/payIn/lib/payInBolt11.js b/api/payIn/lib/payInBolt11.js new file mode 100644 index 000000000..1b80c0f0e --- /dev/null +++ b/api/payIn/lib/payInBolt11.js @@ -0,0 +1,49 @@ +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' +import { PayInFailureReasonError } from '../errors' + +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 + } +} + +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) + } catch (e) { + throw new PayInFailureReasonError('Invoice creation failed', 'INVOICE_CREATION_FAILED') + } +} + +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 new file mode 100644 index 000000000..ef946bf68 --- /dev/null +++ b/api/payIn/lib/payInCreate.js @@ -0,0 +1,70 @@ +import { assertBelowMaxPendingPayInBolt11s } from './assert' +import { isInvoiceable, isPessimistic, isWithdrawal } from './is' +import { getCostBreakdown, getPayInCustodialTokens } from './payInCustodialTokens' +import { payInPrismaCreate } from './payInPrisma' + +export const PAY_IN_INCLUDE = { + payInCustodialTokens: true, + payOutBolt11: true, + pessimisticEnv: true, + user: true, + payOutCustodialTokens: true +} + +export async function payInCreate (tx, payInProspect, { me }) { + const { mCostRemaining, mP2PCost, payInCustodialTokens } = await getPayInCosts(tx, payInProspect, { me }) + const payInState = await getPayInState(payInProspect, { mCostRemaining, mP2PCost }) + if (!isWithdrawal(payInProspect) && payInState !== 'PAID') { + await assertBelowMaxPendingPayInBolt11s(tx, payInProspect.userId) + } + 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 }) { + if (mCostRemaining > 0n) { + if (!isInvoiceable(payIn)) { + throw new Error('Insufficient funds') + } + + if (mP2PCost > 0n) { + return 'PENDING_INVOICE_WRAP' + } else { + return 'PENDING_INVOICE_CREATION' + } + } + + if (isWithdrawal(payIn)) { + return 'PENDING_WITHDRAWAL' + } + + return 'PAID' +} diff --git a/api/payIn/lib/payInCustodialTokens.js b/api/payIn/lib/payInCustodialTokens.js new file mode 100644 index 000000000..0ad6d25b6 --- /dev/null +++ b/api/payIn/lib/payInCustodialTokens.js @@ -0,0 +1,59 @@ +import { isP2P, 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 +} + +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/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/payOutBolt11.js b/api/payIn/lib/payOutBolt11.js new file mode 100644 index 000000000..00d72e528 --- /dev/null +++ b/api/payIn/lib/payOutBolt11.js @@ -0,0 +1,61 @@ +import { parsePaymentRequest } from 'ln-service' +import { PAY_IN_RECEIVER_FAILURE_REASONS } from './is' +import { createBolt11FromWallets } from '@/wallets/server' +import { Prisma } from '@prisma/client' + +async function getLeastFailedWallets (models, { genesisId, userId }) { + return 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` +} + +async function getWallets (models, { userId }) { + return await models.wallet.findMany({ + where: { + userId, + enabled: true + }, + include: { + user: true + } + }) +} + +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 = 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, + msats: BigInt(invoice.mtokens), + bolt11, + hash: invoice.hash, + userId, + walletId: wallet.id + } +} diff --git a/api/payIn/lib/territory.js b/api/payIn/lib/territory.js new file mode 100644 index 000000000..849ff11c1 --- /dev/null +++ b/api/payIn/lib/territory.js @@ -0,0 +1,27 @@ +import { USER_ID } from '@/lib/constants' + +export const GLOBAL_SEEDS = [USER_ID.k00b, USER_ID.ek] + +export function initialTrust ({ name, userId }) { + const results = GLOBAL_SEEDS.map(id => ({ + subName: name, + userId: id, + zapPostTrust: 1, + subZapPostTrust: 1, + zapCommentTrust: 1, + subZapCommentTrust: 1 + })) + + if (!GLOBAL_SEEDS.includes(userId)) { + results.push({ + subName: name, + userId, + zapPostTrust: 0, + subZapPostTrust: 1, + zapCommentTrust: 0, + subZapCommentTrust: 1 + }) + } + + return results +} diff --git a/api/payIn/transitions.js b/api/payIn/transitions.js new file mode 100644 index 000000000..9ecf7e716 --- /dev/null +++ b/api/payIn/transitions.js @@ -0,0 +1,550 @@ +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' +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, 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 }) + + if (PAY_IN_TERMINAL_STATES.includes(currentPayIn.payInState)) { + console.log('payIn is already in a terminal state, skipping transition') + return + } + + if (!Array.isArray(fromStates)) { + fromStates = [fromStates] + } + + let lndPayInBolt11 + if (currentPayIn.payInBolt11) { + lndPayInBolt11 = invoice ?? await getInvoice({ id: currentPayIn.payInBolt11.hash, lnd }) + } + + let lndPayOutBolt11 + if (currentPayIn.payOutBolt11) { + lndPayOutBolt11 = withdrawal ?? await getPaymentOrNotSent({ id: currentPayIn.payOutBolt11.hash, lnd }) + } + + const transitionedPayIn = await models.$transaction(async tx => { + payIn = await tx.payIn.update({ + where: { + id: payInId, + payInState: { in: fromStates } + }, + data: { + payInState: toState, + payInStateChangedAt: new Date(), + beneficiaries: { + updateMany: { + data: { + payInState: toState, + payInStateChangedAt: new Date() + } + } + } + }, + include + }) + + if (!payIn) { + console.log('record not found in our own concurrency check, assuming concurrent worker transitioned it') + return + } + + const updateFields = await transitionFunc(tx, payIn, lndPayInBolt11, lndPayOutBolt11) + + if (updateFields) { + return await tx.payIn.update({ + where: { id: payIn.id }, + data: updateFields, + include + }) + } + + return payIn + }, { + isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, + timeout: 60000 + }) + + if (transitionedPayIn) { + console.log('transition succeeded') + return transitionedPayIn + } + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === 'P2025') { + console.log('record not found, assuming concurrent worker transitioned it') + return + } + if (error.code === 'P2034') { + console.log('write conflict, assuming concurrent worker is transitioning it') + return + } + } + + console.error('unexpected error', error) + if (cancelOnError) { + models.pessimisticEnv.updateMany({ + where: { payInId }, + data: { + error: error.message + } + }).catch(e => console.error('failed to store payIn error', e)) + const reason = error instanceof PayInFailureReasonError + ? error.payInFailureReason + : 'EXECUTION_FAILED' + boss.send('payInCancel', { payInId, payInFailureReason: reason }, FINALIZE_OPTIONS) + .catch(e => console.error('failed to cancel payIn', e)) + } else { + // retry the job + boss.send( + jobName, + data, + { startAfter: datePivot(new Date(), { seconds: 30 }), priority: 1000 }) + .catch(e => console.error('failed to retry payIn', e)) + } + + console.error(`${jobName} failed for payIn ${payInId}: ${error}`) + throw error + } +} + +export async function payInWithdrawalPaid ({ data, models, ...args }) { + const { payInId } = data + + const transitionedPayIn = await transitionPayIn('payInWithdrawalPaid', data, { + payInId, + fromState: 'PENDING_WITHDRAWAL', + toState: 'WITHDRAWAL_PAID', + transition: async (tx, payIn, lndPayOutBolt11) => { + if (!lndPayOutBolt11.is_confirmed) { + throw new Error('withdrawal is not confirmed') + } + + // refund the routing fee + const { mtokens, id: routingFeeId } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + await tx.payOutCustodialToken.update({ + where: { id: routingFeeId }, + data: { + mtokens: toPositiveBigInt(lndPayOutBolt11.payment.fee_mtokens) + } + }) + 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 + } + } + } + } + }, { models, ...args }) + + 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 + }) + } +} + +export async function payInWithdrawalFailed ({ data, models, ...args }) { + const { payInId } = data + let message + const transitionedPayIn = await transitionPayIn('payInWithdrawalFailed', data, { + payInId, + fromState: 'PENDING_WITHDRAWAL', + toState: 'WITHDRAWAL_FAILED', + transition: async (tx, payIn, lndPayOutBolt11) => { + if (!lndPayOutBolt11?.is_failed) { + throw new Error('withdrawal is not failed') + } + + await onFail(tx, payIn.id) + + const { status, message: failureMessage } = getPaymentFailureStatus(lndPayOutBolt11) + message = failureMessage + + return { + payInFailureReason: 'WITHDRAWAL_FAILED', + payOutBolt11: { + update: { status } + } + } + } + }, { models, ...args }) + + if (transitionedPayIn) { + const { mtokens } = transitionedPayIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + const 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, models, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInPaid', data, { + 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) + + return { + payInBolt11: { + update: { + confirmedAt: new Date(lndPayInBolt11.confirmed_at), + confirmedIndex: lndPayInBolt11.confirmed_index, + msatsReceived: BigInt(lndPayInBolt11.received_mtokens) + } + } + } + } + }, { models, ...args }) + + if (transitionedPayIn) { + // run non critical side effects in the background + // after the transaction has been committed + payInTypeModules[transitionedPayIn.payInType] + .nonCriticalSideEffects?.(payInId, { models }) + .catch(console.error) + } +} + +// this performs forward creating the outgoing payment +export async function payInForwarding ({ data, models, boss, lnd, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInForwarding', data, { + payInId, + 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 + throw new PayInFailureReasonError('invoice has insufficient cltv delta for forward', 'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW') + } + + // if this is a pessimistic action, we want to perform it now + // ... we don't want it to fail after the outgoing payment is in flight + let pessimisticEnv + if (payIn.pessimisticEnv) { + pessimisticEnv = { + update: { + result: await payInTypeModules[payIn.payInType].onBegin?.(tx, payIn.id, payIn.pessimisticEnv.args) + } + } + } + + return { + payInBolt11: { + update: { + msatsReceived: BigInt(lndPayInBolt11.received_mtokens), + expiryHeight, + acceptHeight + } + }, + pessimisticEnv + } + }, + cancelOnError: true + }, { models, boss, lnd, ...args }) + + // only pay if we successfully transitioned which can only happen once + // we can't do this inside the transaction because it isn't necessarily idempotent + if (transitionedPayIn?.payInBolt11 && transitionedPayIn.payOutBolt11) { + const { 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, models, lnd, boss, ...args }) { + const { payInId } = data + const transitionedPayIn = await transitionPayIn('payInForwarded', data, { + payInId, + fromState: 'FORWARDING', + toState: 'FORWARDED', + transition: async ({ tx, payIn, lndPayInBolt11, lndPayOutBolt11 }) => { + if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_confirmed)) { + throw new Error('invoice is not held') + } + + if (!lndPayOutBolt11.is_confirmed) { + throw new Error('payment is not confirmed') + } + + const { payment } = lndPayOutBolt11 + + // settle the invoice, allowing us to transition to PAID + await settleHodlInvoice({ secret: payment.secret, lnd }) + + // adjust the routing fee and move the rest to the rewards pool + const { mtokens: mtokensFeeEstimated, id: payOutRoutingFeeId } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE') + const { id: payOutRewardsPoolId } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'REWARDS_POOL') + + return { + payInBolt11: { + update: { + preimage: payment.secret + } + }, + payOutBolt11: { + update: { + status: 'CONFIRMED', + 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 } + } + ] + } + } + } + }, { models, lnd, boss, ...args }) + + 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, models, lnd, boss, ...args }) { + const { payInId } = data + let message + const transitionedPayIn = await transitionPayIn('payInFailedForward', data, { + payInId, + fromState: 'FORWARDING', + toState: 'FAILED_FORWARD', + transition: async ({ tx, payIn, lndPayInBolt11, lndPayOutBolt11 }) => { + if (!(lndPayInBolt11.is_held || lndPayInBolt11.is_cancelled)) { + throw new Error('invoice is not held') + } + + if (!(lndPayOutBolt11.is_failed || lndPayOutBolt11.notSent)) { + throw new Error('payment is not failed') + } + + // cancel to transition to FAILED ... this is really important we do not transition unless this call succeeds + // which once it does succeed will ensure we will try to cancel the held invoice until it actually cancels + await boss.send('payInCancel', { payInId, payInFailureReason: 'INVOICE_FORWARDING_FAILED' }, FINALIZE_OPTIONS) + + const { status, message: failureMessage } = getPaymentFailureStatus(lndPayOutBolt11) + message = failureMessage + + return { + payOutBolt11: { + update: { + status + } + } + } + } + }, { models, lnd, boss, ...args }) + + if (transitionedPayIn) { + const fwd = transitionedPayIn.payOutBolt11 + const logger = walletLogger({ wallet: fwd.wallet, models }) + logger.warn( + `incoming payment failed: ${message}`, { + payInId: transitionedPayIn.id + }) + } + + return transitionedPayIn +} + +export async function payInHeld ({ data: { 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].onBegin?.(tx, payIn.id, payIn.pessimisticEnv.args) + } + } + } + + // settle the invoice, allowing us to transition to PAID + await settleHodlInvoice({ secret: payIn.payInBolt11.preimage, lnd }) + + return { + payInBolt11: { + update: { + msatsReceived: BigInt(lndPayInBolt11.received_mtokens) + } + }, + pessimisticEnv + } + }, + cancelOnError: true + }, { models, lnd, boss }) +} + +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', + 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' + } + } + }, { models, lnd, boss, ...args }) + + if (transitionedPayIn) { + if (transitionedPayIn.payOutBolt11) { + const { wallet, bolt11 } = transitionedPayIn.payOutBolt11 + const logger = walletLogger({ wallet, models }) + const decoded = await parsePaymentRequest({ request: bolt11 }) + logger.info( + `invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { + bolt11, + payInId: transitionedPayIn.id + }) + } + } + + return transitionedPayIn +} + +export async function payInFailed ({ data, models, lnd, boss, ...args }) { + const { payInId, payInFailureReason } = data + return await transitionPayIn('payInFailed', data, { + payInId, + // any of these states can transition to FAILED + fromState: ['PENDING', 'PENDING_HELD', 'HELD', 'FAILED_FORWARD', 'CANCELLED', 'PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP'], + 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 reason = payInFailureReason ?? payIn.payInFailureReason ?? 'UNKNOWN_FAILURE' + + return { + payInFailureReason: reason, + payInBolt11 + } + } + }, { models, lnd, boss, ...args }) +} diff --git a/api/payIn/types/autoWithdrawal.js b/api/payIn/types/autoWithdrawal.js new file mode 100644 index 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/boost.js b/api/payIn/types/boost.js new file mode 100644 index 000000000..ef3cfa8df --- /dev/null +++ b/api/payIn/types/boost.js @@ -0,0 +1,84 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, 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 +] + +async function getPayOuts (models, { sats, id, sub }, { me }) { + if (id) { + sub = (await models.item.findUnique({ where: { id }, include: { sub: true } })).sub + } + + if (!sub) { + throw new Error('Sub not found') + } + + 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: sub.userId, + mtokens: revenueMsats, + custodialTokenType: 'SATS', + subPayOutCustodialToken: { + subName: sub.name + } + }, + { + payOutType: 'REWARD_POOL', + userId: null, + mtokens: rewardMsats, + custodialTokenType: 'SATS' + } + ] + } +} + +export async function getInitial (models, payInArgs, { me }) { + const { sats } = payInArgs + return { + payInType: 'BOOST', + userId: me?.id, + mcost: satsToMsats(sats), + itemPayIn: { + itemId: parseInt(payInArgs.id) + }, + ...(await getPayOuts(models, payInArgs, { me })) + } +} + +export async function onRetry (tx, oldPayInId, newPayInId) { + await tx.itemPayIn.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) +} + +export async function onPaid (tx, payInId) { + const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { itemPayIn: true } }) + + // increment boost on item + await tx.item.update({ + where: { id: payIn.itemPayIn.itemId }, + data: { + boost: { increment: msatsToSats(payIn.mcost) } + } + }) + + // TODO: expireBoost job needs to be updated to use payIn + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) + VALUES ('expireBoost', jsonb_build_object('id', ${payIn.itemPayIn.itemId}::INTEGER), 21, true, + now() + interval '30 days', now() + interval '40 days')` +} + +export async function describe (models, payInId, { me }) { + const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { itemPayIn: true } }) + return `SN: boost #${payIn.itemPayIn.itemId} by ${numWithUnits(msatsToSats(payIn.mcost), { abbreviate: false })}` +} diff --git a/api/payIn/types/buyCredits.js b/api/payIn/types/buyCredits.js new file mode 100644 index 000000000..46138929a --- /dev/null +++ b/api/payIn/types/buyCredits.js @@ -0,0 +1,30 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, 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 getInitial (models, { credits }, { me }) { + return { + payInType: 'BUY_CREDITS', + userId: me?.id, + mcost: satsToMsats(credits), + payOutCustodialTokens: [ + { + payOutType: 'CREDITS', + userId: me.id, + mtokens: credits, + custodialTokenType: '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 new file mode 100644 index 000000000..457947922 --- /dev/null +++ b/api/payIn/types/donate.js @@ -0,0 +1,26 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' + +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { sats }, { me }) { + return { + payInType: 'DONATE', + userId: me?.id, + mcost: satsToMsats(sats), + payOutCustodialTokens: [ + { payOutType: 'REWARDS_POOL', userId: null, mtokens: satsToMsats(sats), custodialTokenType: 'SATS' } + ] + } +} + +export async function describe (models, payInId, { 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 new file mode 100644 index 000000000..9e0562a14 --- /dev/null +++ b/api/payIn/types/downZap.js @@ -0,0 +1,92 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { msatsToSats, satsToMsats, numWithUnits } 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 +] + +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) + + return { + payOutCustodialTokens: [ + { payOutType: 'REWARDS_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' }, + { + payOutType: 'TERRITORY_REVENUE', + userId: item.sub.userId, + mtokens: revenueMsats, + custodialTokenType: 'SATS', + subPayOutCustodialToken: { + subName: item.sub.name + } + } + ] + } +} + +export async function getInitial (models, { sats, id: itemId }, { me }) { + return { + payInType: 'DOWNZAP', + userId: me?.id, + mcost: satsToMsats(sats), + itemPayIn: { + itemId: parseInt(itemId) + }, + ...(await getPayOuts(models, { sats, id: itemId }, { me })) + } +} + +export async function onRetry (tx, oldPayInId, 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: { payIn: true, item: true } }) + + const msats = BigInt(itemAct.payIn.mcost) + 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.payIn.userId}::INTEGER + ), zap AS ( + INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats") + 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 + ) + 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 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 new file mode 100644 index 000000000..02e105370 --- /dev/null +++ b/api/payIn/types/index.js @@ -0,0 +1,33 @@ +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' +import * as WITHDRAWAL from './types/withdrawal' + +export default { + BUY_CREDITS, + ITEM_CREATE, + ITEM_UPDATE, + ZAP, + DOWN_ZAP, + BOOST, + DONATE, + POLL_VOTE, + INVITE_GIFT, + TERRITORY_CREATE, + TERRITORY_UPDATE, + TERRITORY_BILLING, + TERRITORY_UNARCHIVE, + PROXY_PAYMENT, + WITHDRAWAL +} diff --git a/api/payIn/types/inviteGift.js b/api/payIn/types/inviteGift.js new file mode 100644 index 000000000..683a55d23 --- /dev/null +++ b/api/payIn/types/inviteGift.js @@ -0,0 +1,74 @@ +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 +] + +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') + } + return satsToMsats(invite.gift) +} + +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 } + }) + + if (invite.limit && invite.giftedCount >= invite.limit) { + throw new Error('invite limit reached') + } + + // check that user was created in last hour + // check that user did not already redeem an invite + await tx.user.update({ + where: { + id: userId, + inviteId: null, + createdAt: { + gt: new Date(Date.now() - 1000 * 60 * 60) + } + }, + data: { + inviteId: id, + referrerId: me.id + } + }) + + await tx.invite.update({ + where: { id, userId: me.id, revoked: false, ...(invite.limit ? { giftedCount: { lt: invite.limit } } : {}) }, + data: { + giftedCount: { + increment: 1 + } + } + }) +} + +export async function onPaidSideEffects (models, payInId, { me }) { + notifyInvite(me.id) +} diff --git a/api/payIn/types/itemCreate.js b/api/payIn/types/itemCreate.js new file mode 100644 index 000000000..fa299dcd7 --- /dev/null +++ b/api/payIn/types/itemCreate.js @@ -0,0 +1,273 @@ +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 { satsToMsats } from '@/lib/format' +import { GqlInputError } from '@/lib/error' +import * as BOOST from './boost' + +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +const DEFAULT_ITEM_COST = 1000n + +async function getBaseCost (models, { bio, parentId, subName }) { + if (bio) return DEFAULT_ITEM_COST + + 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) +} + +async function getCost (models, { subName, parentId, uploadIds, boost = 0, bio }, { me }) { + const baseCost = await getBaseCost(models, { bio, parentId, subName }) + + // cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost + const [{ cost }] = await models.$queryRaw` + SELECT ${baseCost}::INTEGER + * POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? 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[])) 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 && boost <= 0 + + return freebie ? BigInt(0) : BigInt(cost) +} + +export async function getInitial (models, args, { me }) { + const mcost = await getCost(models, args, { me }) + const sub = await 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', + subPayOutCustodialToken: { + subName: sub.name + } + }, + { payOutType: 'REWARD_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' } + ], + beneficiaries + } +} + +// TODO: uploads should just have an itemId +export async function onBegin (tx, payInId, args, { me }) { + const { invoiceId, parentId, uploadIds = [], forwardUsers = [], options: pollOptions = [], boost = 0, ...data } = args + + const deletedUploads = [] + for (const uploadId of uploadIds) { + if (!await tx.upload.findUnique({ where: { id: uploadId } })) { + deletedUploads.push(uploadId) + } + } + if (deletedUploads.length > 0) { + throw new Error(`upload(s) ${deletedUploads.join(', ')} are expired, consider reuploading.`) + } + + const mentions = await getMentions(tx, args, { me }) + const itemMentions = await getItemMentions(tx, args, { me }) + + // start with median vote + if (me !== USER_ID.anon) { + const [row] = await tx.$queryRaw`SELECT + COALESCE(percentile_cont(0.5) WITHIN GROUP( + ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + AS median FROM "Item" WHERE "userId" = ${me.id}::INTEGER` + if (row?.median < 0) { + data.weightedDownVotes = -row.median + } + } + + const itemData = { + parentId: parentId ? parseInt(parentId) : null, + ...data, + itemPayIn: { + create: { + payInId + } + }, + threadSubscriptions: { + createMany: { + data: [ + { userId: data.userId }, + ...forwardUsers.map(({ userId }) => ({ userId })) + ] + } + }, + itemForwards: { + createMany: { + data: forwardUsers + } + }, + pollOptions: { + createMany: { + data: pollOptions.map(option => ({ option })) + } + }, + itemUploads: { + create: uploadIds.map(id => ({ uploadId: id })) + }, + mentions: { + createMany: { + data: mentions + } + }, + itemReferrers: { + create: itemMentions + } + } + + let item + if (data.bio && 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 + } + } + + 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.itemPayIn.updateMany({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) +} + +export async function onPaid (tx, payInId) { + const { item } = await tx.itemPayIn.findUnique({ where: { payInId }, include: { item: true } }) + if (!item) { + throw new Error('Item not found') + } + + await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority) + VALUES ('timestampItem', jsonb_build_object('id', ${item.id}::INTEGER), now() + interval '10 minutes', -2)` + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')` + + if (item.parentId) { + // denormalize ncomments, lastCommentAt for ancestors, and insert into reply table + await tx.$executeRaw` + WITH comment AS ( + SELECT "Item".*, users.trust + FROM "Item" + JOIN users ON "Item"."userId" = users.id + WHERE "Item".id = ${item.id}::INTEGER + ), ancestors AS ( + SELECT "Item".* + FROM "Item", comment + WHERE "Item".path @> comment.path AND "Item".id <> comment.id + ORDER BY "Item".id + ), updated_ancestors AS ( + UPDATE "Item" + SET ncomments = "Item".ncomments + 1, + "lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at), + "nDirectComments" = "Item"."nDirectComments" + + CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END + FROM comment, ancestors + WHERE "Item".id = ancestors.id + RETURNING "Item".* + ) + INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level) + SELECT comment.created_at, comment.updated_at, ancestors.id, ancestors."userId", + comment.id, comment."userId", nlevel(comment.path) - nlevel(ancestors.path) + FROM ancestors, comment` + } +} + +export async function onPaidSideEffects (models, payInId, { me }) { + const { item } = await models.itemPayIn.findUnique({ + where: { payInId }, + include: { + item: { + include: { + mentions: true, + itemReferrers: { include: { refereeItem: true } }, + user: true + } + } + } + }) + + if (item.parentId) { + notifyItemParents({ item, models }).catch(console.error) + notifyThreadSubscribers({ models, item }).catch(console.error) + } + for (const { userId } of item.mentions) { + notifyMention({ models, item, userId }).catch(console.error) + } + for (const { refereeItem } of item.itemReferrers) { + notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error) + } + + notifyUserSubscribers({ models, item }).catch(console.error) + notifyTerritorySubscribers({ models, item }).catch(console.error) + + 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 }) { + 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 new file mode 100644 index 000000000..8cc1734be --- /dev/null +++ b/api/payIn/types/itemUpdate.js @@ -0,0 +1,182 @@ +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 * as BOOST from './boost' +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +async function getCost (models, { id, boost = 0, uploadIds, bio }, { me }) { + // the only reason updating items costs anything is when it has new uploads + // or more boost + const old = await models.item.findUnique({ where: { id: parseInt(id) }, include: { payIn: true } }) + const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) + const cost = BigInt(totalFeesMsats) + + 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 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: old.sub.userId, mtokens: revenueMsats, custodialTokenType: 'SATS' }, + { payOutType: 'REWARD_POOL', userId: null, mtokens: rewardMsats, custodialTokenType: 'SATS' } + ], + itemPayIn: { + itemId: parseInt(id) + }, + beneficiaries + } +} + +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: _, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args + + const old = await tx.item.findUnique({ + where: { id: parseInt(id) }, + include: { + threadSubscriptions: true, + mentions: true, + itemForwards: true, + itemReferrers: true, + itemUploads: true + } + }) + + // createMany is the set difference of the new - old + // deleteMany is the set difference of the old - new + // updateMany is the intersection of the old and new + const difference = (a = [], b = [], key = 'userId') => a.filter(x => !b.find(y => y[key] === x[key])) + const intersectionMerge = (a = [], b = [], key) => a.filter(x => b.find(y => y.userId === x.userId)) + .map(x => ({ [key]: x[key], ...b.find(y => y.userId === x.userId) })) + + const mentions = await getMentions(tx, args, { me }) + const itemMentions = await getItemMentions(tx, args, { me }) + const itemUploads = uploadIds.map(id => ({ uploadId: id })) + + // we put boost in the where clause because we don't want to update the boost + // if it has changed concurrently + await tx.item.update({ + where: { id: parseInt(id) }, + data: { + ...data, + pollOptions: { + createMany: { + data: pollOptions?.map(option => ({ option })) + } + }, + itemUploads: { + create: difference(itemUploads, old.itemUploads, 'uploadId').map(({ uploadId }) => ({ uploadId })), + deleteMany: { + uploadId: { + in: difference(old.itemUploads, itemUploads, 'uploadId').map(({ uploadId }) => uploadId) + } + } + }, + itemForwards: { + deleteMany: { + userId: { + in: difference(old.itemForwards, itemForwards).map(({ userId }) => userId) + } + }, + createMany: { + data: difference(itemForwards, old.itemForwards) + }, + update: intersectionMerge(old.itemForwards, itemForwards, 'id').map(({ id, ...data }) => ({ + where: { id }, + data + })) + }, + threadSubscriptions: { + deleteMany: { + userId: { + in: difference(old.itemForwards, itemForwards).map(({ userId }) => userId) + } + }, + createMany: { + data: difference(itemForwards, old.itemForwards).map(({ userId }) => ({ userId })) + } + }, + mentions: { + deleteMany: { + userId: { + in: difference(old.mentions, mentions).map(({ userId }) => userId) + } + }, + createMany: { + data: difference(mentions, old.mentions) + } + }, + itemReferrers: { + deleteMany: { + refereeId: { + in: difference(old.itemReferrers, itemMentions, 'refereeId').map(({ refereeId }) => refereeId) + } + }, + create: difference(itemMentions, old.itemReferrers, 'refereeId') + } + } + }) + + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil) + VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true, + now() + interval '5 seconds', now() + interval '1 day')` + + await performBotBehavior(tx, args, { me }) +} + +export async function onPaidSideEffects (models, payInId) { + const { item } = await models.itemPayIn.findUnique({ + where: { payInId }, + include: { + item: { + include: { + mentions: true, + itemReferrers: { include: { refereeItem: true } }, + user: true + } + } + } + }) + // compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits + for (const { userId, createdAt } of item.mentions) { + if (item.updatedAt.getTime() !== createdAt.getTime()) continue + notifyMention({ models, item, userId }).catch(console.error) + } + for (const { refereeItem, createdAt } of item.itemReferrers) { + if (item.updatedAt.getTime() !== createdAt.getTime()) continue + notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error) + } +} + +export async function describe (models, payInId) { + const { item } = await models.itemPayIn.findUnique({ where: { payInId }, include: { item: true } }) + return `SN: update ${item.parentId ? `reply to #${item.parentId}` : 'post'}` +} diff --git a/api/payIn/types/pollVote.js b/api/payIn/types/pollVote.js new file mode 100644 index 000000000..2c7d189ca --- /dev/null +++ b/api/payIn/types/pollVote.js @@ -0,0 +1,64 @@ +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 getInitial (models, { id }, { me }) { + const pollOption = await models.pollOption.findUnique({ + where: { id: parseInt(id) }, + include: { item: { include: { sub: true } } } + }) + + const mcost = satsToMsats(pollOption.item.pollCost) + const 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' + }], + pollBlindVote: { + itemId: pollOption.itemId, + userId: me.id + }, + pollVote: { + pollOptionId: pollOption.id, + itemId: pollOption.itemId + } + } +} + +export async function onRetry (tx, oldPayInId, 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 (tx, payInId) { + // anonymize the vote + await tx.pollVote.updateMany({ where: { payInId }, data: { payInId: null } }) +} + +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..3a58c76cf --- /dev/null +++ b/api/payIn/types/proxyPayment.js @@ -0,0 +1,73 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format' +import { notifyDeposit } from '@/lib/webPush' +import { payOutBolt11Prospect } from '../lib/payOutBolt11' +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.P2P +] + +// 3% to routing fee, 7% to rewards pool, 90% to invoice +export async function getInitial (models, { msats }, { me }) { + const mcost = toPositiveBigInt(msats) + const routingFeeMtokens = mcost * 3n / 100n + const rewardsPoolMtokens = mcost * 7n / 100n + const proxyPaymentMtokens = mcost - routingFeeMtokens - rewardsPoolMtokens + + const payOutBolt11 = await payOutBolt11Prospect(models, { userId: me.id, payOutType: 'PROXY_PAYMENT', msats: proxyPaymentMtokens }) + + 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' } + ], + payOutBolt11 + } +} + +// TODO: all of this needs to be updated elsewhere +export async function onBegin (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) { + const payInBolt11 = await tx.payInBolt11.findUnique({ where: { payInId } }) + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data) + VALUES ('nip57', jsonb_build_object('hash', ${payInBolt11.hash}))` +} + +export async function onPaidSideEffects (models, payInId) { + const payInBolt11 = await models.payInBolt11.findUnique({ where: { payInId } }) + await notifyDeposit(payInBolt11.userId, payInBolt11) +} + +export async function describe (models, payInId, { 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/territoryBilling.js b/api/payIn/types/territoryBilling.js new file mode 100644 index 000000000..2648faaaf --- /dev/null +++ b/api/payIn/types/territoryBilling.js @@ -0,0 +1,83 @@ +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { nextBilling } from '@/lib/territory' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { name }, { me }) { + const sub = await models.sub.findUnique({ + where: { + name + } + }) + + const mcost = satsToMsats(TERRITORY_PERIOD_COST(sub.billingType)) + + return { + payInType: 'TERRITORY_BILLING', + userId: me?.id, + mcost, + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' } + ] + } +} + +export async function 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 + } + }) + + 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) + + await tx.sub.update({ + // optimistic concurrency control + // make sure the sub hasn't changed since we fetched it + where: { + ...sub, + postTypes: { + equals: sub.postTypes + } + }, + data: { + billedLastAt, + billPaidUntil, + billingCost, + status: 'ACTIVE', + subPayIn: { + create: { + payInId + } + } + } + }) +} + +export async function describe (models, payInId) { + const { sub } = await models.subPayIn.findUnique({ where: { payInId }, include: { sub: true } }) + return `SN: billing for territory ${sub.name}` +} diff --git a/api/payIn/types/territoryCreate.js b/api/payIn/types/territoryCreate.js new file mode 100644 index 000000000..baf33c6bb --- /dev/null +++ b/api/payIn/types/territoryCreate.js @@ -0,0 +1,62 @@ +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { nextBilling } from '@/lib/territory' +import { initialTrust } from '../lib/territory' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { billingType }, { me }) { + const mcost = satsToMsats(TERRITORY_PERIOD_COST(billingType)) + return { + payInType: 'TERRITORY_CREATE', + userId: me?.id, + mcost, + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: 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) + + const sub = await tx.sub.create({ + data: { + ...data, + billedLastAt, + billPaidUntil, + billingCost, + rankingType: 'WOT', + userId: me.id, + subPayIn: { + create: { + payInId + } + }, + SubSubscription: { + create: { + userId: me.id + } + } + } + }) + + await tx.userSubTrust.createMany({ + data: initialTrust({ name: sub.name, userId: sub.userId }) + }) +} + +export async function describe (models, payInId) { + const { sub } = await models.subPayIn.findUnique({ where: { payInId }, include: { sub: true } }) + return `SN: create territory ${sub.name}` +} diff --git a/api/payIn/types/territoryUnarchive.js b/api/payIn/types/territoryUnarchive.js new file mode 100644 index 000000000..b3b4dc27b --- /dev/null +++ b/api/payIn/types/territoryUnarchive.js @@ -0,0 +1,95 @@ +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { nextBilling } from '@/lib/territory' +import { initialTrust } from '../lib/territory' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { billingType }, { me }) { + const mcost = satsToMsats(TERRITORY_PERIOD_COST(billingType)) + return { + payInType: 'TERRITORY_UNARCHIVE', + userId: me?.id, + mcost, + payOutCustodialTokens: [ + { payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' } + ] + } +} + +export async function 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 + } + }) + + 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 + 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.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 }) + }) +} + +export async function describe (models, payInId) { + const { sub } = await models.subPayIn.findUnique({ where: { payInId }, include: { sub: true } }) + return `SN: unarchive territory ${sub.name}` +} diff --git a/api/payIn/types/territoryUpdate.js b/api/payIn/types/territoryUpdate.js new file mode 100644 index 000000000..d1555e2ce --- /dev/null +++ b/api/payIn/types/territoryUpdate.js @@ -0,0 +1,82 @@ +import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' +import { satsToMsats } from '@/lib/format' +import { proratedBillingCost } from '@/lib/territory' +import { datePivot } from '@/lib/time' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +export async function getInitial (models, { oldName, billingType }, { me }) { + const oldSub = await models.sub.findUnique({ + where: { + name: oldName + } + }) + + const mcost = satsToMsats(proratedBillingCost(oldSub, billingType) ?? 0) + + return { + payInType: 'TERRITORY_UPDATE', + userId: me?.id, + mcost, + payOutCustodialTokens: [{ payOutType: 'SYSTEM_REVENUE', userId: null, mtokens: mcost, custodialTokenType: 'SATS' }] + } +} + +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 + } + }) + + 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') { + 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' + } + + 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 (models, payInId) { + const { sub } = await models.subPayIn.findUnique({ where: { payInId }, include: { sub: true } }) + return `SN: update territory billing ${sub.name}` +} diff --git a/api/payIn/types/withdrawal.js b/api/payIn/types/withdrawal.js new file mode 100644 index 000000000..6b01b346c --- /dev/null +++ b/api/payIn/types/withdrawal.js @@ -0,0 +1,38 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { parsePaymentRequest } from 'ln-service' +import { satsToMsats, numWithUnits, msatsToSats } from '@/lib/format' + +export const anonable = false + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS +] + +export async function getInitial (models, { bolt11, maxFee, 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), + 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/payIn/types/zap.js b/api/payIn/types/zap.js new file mode 100644 index 000000000..6b52a4bbe --- /dev/null +++ b/api/payIn/types/zap.js @@ -0,0 +1,220 @@ +import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' +import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' +import { notifyZapped } from '@/lib/webPush' +import { Prisma } from '@prisma/client' +import { payOutBolt11Prospect } from '../lib/payOutBolt11' + +export const anonable = true + +export const paymentMethods = [ + PAID_ACTION_PAYMENT_METHODS.P2P, + PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT, + PAID_ACTION_PAYMENT_METHODS.REWARD_SATS, + PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC, + PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC +] + +async function tryP2P (models, { id, sats, hasSendWallet }, { me }) { + 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 false + } + + const item = await models.item.findUnique({ + where: { id: parseInt(id) }, + include: { + itemForwards: true, + user: true + } + }) + + // bios, forwards, or dust don't get sats + if (item.bio || item.itemForwards.length > 0 || sats < item.user.receiveCreditsBelowSats) { + return false + } + + return true +} + +// 70% to the receiver(s), 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 founderMtokens = mcost * 21n / 100n + + const result = { + payInType: 'ZAP', + userId: me.id, + mcost, + 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) { + await tx.itemPayIn.update({ where: { payInId: oldPayInId }, data: { payInId: newPayInId } }) +} + +export async function onPaid (tx, payInId) { + const payIn = await tx.payIn.findUnique({ + where: { id: payInId }, + include: { + itemPayIn: { include: { item: true } }, + payOutBolt11: true + } + }) + + const msats = payIn.mcost + const sats = msatsToSats(msats) + const userId = payIn.userId + const item = payIn.itemPayIn.item + + // perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt + // NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking + await tx.$queryRaw` + WITH territory AS ( + SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName" + FROM "Item" i + LEFT JOIN "Item" r ON r.id = i."rootId" + WHERE i.id = ${item.id}::INTEGER + ), zapper AS ( + SELECT + COALESCE(${item.parentId + ? Prisma.sql`"zapCommentTrust"` + : Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust", + COALESCE(${item.parentId + ? Prisma.sql`"subZapCommentTrust"` + : Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust" + FROM territory + LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName" + AND ust."userId" = ${userId}::INTEGER + ), zap AS ( + INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats") + VALUES (${userId}::INTEGER, ${item.id}::INTEGER, ${sats}::INTEGER) + ON CONFLICT ("itemId", "userId") DO UPDATE + SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now() + RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote, + LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats + ), item_zapped AS ( + UPDATE "Item" + SET + "weightedVotes" = "weightedVotes" + zapper."zapTrust" * zap.log_sats, + "subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats, + upvotes = upvotes + zap.first_vote, + msats = "Item".msats + ${msats}::BIGINT, + mcredits = "Item".mcredits + ${payIn.payOutBolt11 ? 0n : msats}::BIGINT, + "lastZapAt" = now() + FROM zap, zapper + WHERE "Item".id = ${item.id}::INTEGER + RETURNING "Item".*, zapper."zapTrust" * zap.log_sats as "weightedVote" + ), ancestors AS ( + SELECT "Item".* + FROM "Item", item_zapped + WHERE "Item".path @> item_zapped.path AND "Item".id <> item_zapped.id + ORDER BY "Item".id + ) + UPDATE "Item" + SET "weightedComments" = "Item"."weightedComments" + item_zapped."weightedVote", + "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT, + "commentMcredits" = "Item"."commentMcredits" + ${payIn.payOutBolt11 ? 0n : msats}::BIGINT + FROM item_zapped, ancestors + WHERE "Item".id = ancestors.id` + + // record potential bounty payment + // NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust + // we won't miss a zap that aggregates into a bounty payment, regardless of the order of updates + await tx.$executeRaw` + WITH bounty AS ( + SELECT root.id, "ItemUserAgg"."zapSats" >= root.bounty AS paid, "ItemUserAgg"."itemId" AS target + FROM "ItemUserAgg" + JOIN "Item" ON "Item".id = "ItemUserAgg"."itemId" + LEFT JOIN "Item" root ON root.id = "Item"."rootId" + WHERE "ItemUserAgg"."userId" = ${userId}::INTEGER + AND "ItemUserAgg"."itemId" = ${item.id}::INTEGER + AND root."userId" = ${userId}::INTEGER + AND root.bounty IS NOT NULL + ) + UPDATE "Item" + SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL) + FROM bounty + WHERE "Item".id = bounty.id AND bounty.paid` +} + +export async function onPaidSideEffects (models, payInId) { + const payIn = await models.payIn.findUnique({ + where: { id: payInId }, + include: { itemPayIn: { include: { item: true } } } + }) + // avoid duplicate notifications with the same zap amount + // by checking if there are any other pending acts on the item + const pendingZaps = await models.itemPayIn.count({ + where: { + itemId: payIn.itemPayIn.itemId, + createdAt: { + gt: payIn.createdAt + }, + payIn: { + payInType: 'ZAP' + } + } + }) + if (pendingZaps === 0) notifyZapped({ models, item: payIn.itemPayIn.item }).catch(console.error) +} + +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}` +} 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 cb92eab87..a3366afbe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,80 +13,80 @@ model Snl { } model User { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - name String? @unique(map: "users.name_unique") @db.Citext - email String? @unique(map: "users.email_unique") - emailVerified DateTime? @map("email_verified") - emailHash String? @unique(map: "users.email_hash_unique") + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + name String? @unique(map: "users.name_unique") @db.Citext + email String? @unique(map: "users.email_unique") + emailVerified DateTime? @map("email_verified") + emailHash String? @unique(map: "users.email_hash_unique") image String? - msats BigInt @default(0) - freeComments Int @default(5) - freePosts Int @default(2) + msats BigInt @default(0) + freeComments Int @default(5) + freePosts Int @default(2) checkedNotesAt DateTime? foundNotesAt DateTime? - pubkey String? @unique(map: "users.pubkey_unique") - apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64) - apiKeyEnabled Boolean @default(false) - tipDefault Int @default(100) + pubkey String? @unique(map: "users.pubkey_unique") + apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64) + apiKeyEnabled Boolean @default(false) + tipDefault Int @default(100) tipRandomMin Int? tipRandomMax Int? bioId Int? inviteId String? - tipPopover Boolean @default(false) - upvotePopover Boolean @default(false) - trust Float @default(0) + tipPopover Boolean @default(false) + upvotePopover Boolean @default(false) + trust Float @default(0) lastSeenAt DateTime? - stackedMsats BigInt @default(0) - stackedMcredits BigInt @default(0) - noteAllDescendants Boolean @default(true) - noteDeposits Boolean @default(true) - noteWithdrawals Boolean @default(true) - noteEarning Boolean @default(true) - noteInvites Boolean @default(true) - noteItemSats Boolean @default(true) - noteMentions Boolean @default(true) - noteItemMentions Boolean @default(true) - noteForwardedSats Boolean @default(true) + stackedMsats BigInt @default(0) + stackedMcredits BigInt @default(0) + noteAllDescendants Boolean @default(true) + noteDeposits Boolean @default(true) + noteWithdrawals Boolean @default(true) + noteEarning Boolean @default(true) + noteInvites Boolean @default(true) + noteItemSats Boolean @default(true) + noteMentions Boolean @default(true) + noteItemMentions Boolean @default(true) + noteForwardedSats Boolean @default(true) lastCheckedJobs DateTime? - noteJobIndicator Boolean @default(true) + noteJobIndicator Boolean @default(true) photoId Int? - upvoteTrust Float @default(0) - hideInvoiceDesc Boolean @default(false) - wildWestMode Boolean @default(false) - satsFilter Int @default(10) - nsfwMode Boolean @default(false) - fiatCurrency String @default("USD") - withdrawMaxFeeDefault Int @default(10) - autoDropBolt11s Boolean @default(false) - hideFromTopUsers Boolean @default(false) - turboTipping Boolean @default(false) + upvoteTrust Float @default(0) + hideInvoiceDesc Boolean @default(false) + wildWestMode Boolean @default(false) + satsFilter Int @default(10) + nsfwMode Boolean @default(false) + fiatCurrency String @default("USD") + withdrawMaxFeeDefault Int @default(10) + autoDropBolt11s Boolean @default(false) + hideFromTopUsers Boolean @default(false) + turboTipping Boolean @default(false) zapUndos Int? - imgproxyOnly Boolean @default(false) - showImagesAndVideos Boolean @default(true) - hideWalletBalance Boolean @default(false) + imgproxyOnly Boolean @default(false) + showImagesAndVideos Boolean @default(true) + hideWalletBalance Boolean @default(false) disableFreebies Boolean? referrerId Int? nostrPubkey String? - greeterMode Boolean @default(false) - nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique") - nostrCrossposting Boolean @default(false) - slashtagId String? @unique(map: "users.slashtagId_unique") - noteCowboyHat Boolean @default(true) + greeterMode Boolean @default(false) + nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique") + nostrCrossposting Boolean @default(false) + slashtagId String? @unique(map: "users.slashtagId_unique") + noteCowboyHat Boolean @default(true) streak Int? gunStreak Int? horseStreak Int? - hasSendWallet Boolean @default(false) - hasRecvWallet Boolean @default(false) + hasSendWallet Boolean @default(false) + hasRecvWallet Boolean @default(false) subs String[] - hideCowboyHat Boolean @default(false) + hideCowboyHat Boolean @default(false) Bookmarks Bookmark[] Donation Donation[] Earn Earn[] - invites Invite[] @relation("Invites") + invites Invite[] @relation("Invites") invoices Invoice[] - items Item[] @relation("UserItems") + items Item[] @relation("UserItems") actions ItemAct[] mentions Mention[] messages Message[] @@ -95,63 +95,67 @@ model User { Streak Streak[] ThreadSubscriptions ThreadSubscription[] SubSubscriptions SubSubscription[] - Upload Upload[] @relation("Uploads") + Upload Upload[] @relation("Uploads") nostrRelays UserNostrRelay[] withdrawls Withdrawl[] - bio Item? @relation(fields: [bioId], references: [id]) - invite Invite? @relation(fields: [inviteId], references: [id]) - photo Upload? @relation(fields: [photoId], references: [id]) - referrer User? @relation("referrals", fields: [referrerId], references: [id]) - referrees User[] @relation("referrals") + bio Item? @relation(fields: [bioId], references: [id]) + invite Invite? @relation(fields: [inviteId], references: [id]) + photo Upload? @relation(fields: [photoId], references: [id]) + referrer User? @relation("referrals", fields: [referrerId], references: [id]) + referrees User[] @relation("referrals") Account Account[] Session Session[] itemForwards ItemForward[] - hideBookmarks Boolean @default(false) - hideGithub Boolean @default(true) - hideNostr Boolean @default(true) - hideTwitter Boolean @default(true) - noReferralLinks Boolean @default(false) + hideBookmarks Boolean @default(false) + hideGithub Boolean @default(true) + hideNostr Boolean @default(true) + hideTwitter Boolean @default(true) + noReferralLinks Boolean @default(false) githubId String? twitterId String? - followers UserSubscription[] @relation("follower") - followees UserSubscription[] @relation("followee") - hideWelcomeBanner Boolean @default(false) - hideWalletRecvPrompt Boolean @default(false) - diagnostics Boolean @default(false) - hideIsContributor Boolean @default(false) + followers UserSubscription[] @relation("follower") + followees UserSubscription[] @relation("followee") + hideWelcomeBanner Boolean @default(false) + hideWalletRecvPrompt Boolean @default(false) + diagnostics Boolean @default(false) + hideIsContributor Boolean @default(false) lnAddr String? autoWithdrawMaxFeePercent Float? autoWithdrawThreshold Int? autoWithdrawMaxFeeTotal Int? - mcredits BigInt @default(0) - receiveCreditsBelowSats Int @default(10) - sendCreditsBelowSats Int @default(10) - muters Mute[] @relation("muter") - muteds Mute[] @relation("muted") - ArcOut Arc[] @relation("fromUser") - ArcIn Arc[] @relation("toUser") + mcredits BigInt @default(0) + receiveCreditsBelowSats Int @default(10) + sendCreditsBelowSats Int @default(10) + muters Mute[] @relation("muter") + muteds Mute[] @relation("muted") + ArcOut Arc[] @relation("fromUser") + ArcIn Arc[] @relation("toUser") Sub Sub[] SubAct SubAct[] MuteSub MuteSub[] wallets Wallet[] - TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser") - TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser") - AncestorReplies Reply[] @relation("AncestorReplyUser") + TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser") + TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser") + AncestorReplies Reply[] @relation("AncestorReplyUser") Replies Reply[] walletLogs WalletLog[] Reminder Reminder[] PollBlindVote PollBlindVote[] ItemUserAgg ItemUserAgg[] - oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") - oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") - vaultKeyHash String @default("") + oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") + oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") + vaultKeyHash String @default("") 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[] + PayInBolt11 PayInBolt11[] + PayOutBolt11 PayOutBolt11[] + PayOutCustodialToken PayOutCustodialToken[] @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -245,6 +249,7 @@ model Wallet { withdrawals Withdrawl[] InvoiceForward InvoiceForward[] DirectPayment DirectPayment[] + PayOutBolt11 PayOutBolt11[] @@unique([userId, type]) @@index([userId]) @@ -268,18 +273,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]) } @@ -513,6 +520,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 @@ -605,6 +613,7 @@ model Item { ItemUserAgg ItemUserAgg[] AutoSocialPost AutoSocialPost[] randPollOptions Boolean @default(false) + ItemPayIn ItemPayIn[] @@index([uploadId]) @@index([lastZapAt]) @@ -792,15 +801,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]) @@ -851,6 +862,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") @@ -1298,3 +1310,269 @@ enum LogLevel { ERROR SUCCESS } + +// payIn playground +// TODO: add constraints on all sat value fields + +enum PayInType { + BUY_CREDITS + ITEM_CREATE + ITEM_UPDATE + ZAP + DOWN_ZAP + BOOST + DONATE + POLL_VOTE + INVITE_GIFT + TERRITORY_CREATE + TERRITORY_UPDATE + TERRITORY_BILLING + TERRITORY_UNARCHIVE + PROXY_PAYMENT + REWARDS + WITHDRAWAL + AUTO_WITHDRAWAL +} + +enum PayInState { + PENDING_INVOICE_CREATION + PENDING_INVOICE_WRAP + PENDING_WITHDRAWAL + WITHDRAWAL_PAID + WITHDRAWAL_FAILED + PENDING + PENDING_HELD + HELD + PAID + FAILED + FORWARDING + FORWARDED + FAILED_FORWARD + CANCELLED +} + +enum PayInFailureReason { + INVOICE_CREATION_FAILED + INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE + INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY + INVOICE_WRAPPING_FAILED_UNKNOWN + INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW + INVOICE_FORWARDING_FAILED + HELD_INVOICE_UNEXPECTED_ERROR + HELD_INVOICE_SETTLED_TOO_SLOW + WITHDRAWAL_FAILED + USER_CANCELLED + SYSTEM_CANCELLED + INVOICE_EXPIRED + EXECUTION_FAILED + UNKNOWN_FAILURE +} + +model ItemPayIn { + id Int @id @default(autoincrement()) + itemId Int + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + payInId Int @unique + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + @@unique([itemId, payInId]) + @@index([itemId]) + @@index([payInId]) +} + +model SubPayIn { + id Int @id @default(autoincrement()) + subName String @db.Citext + sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade) + payInId Int @unique + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + @@unique([subName, payInId]) + @@index([subName]) + @@index([payInId]) +} + +model PayIn { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + mcost BigInt + + payInType PayInType + payInState PayInState + payInFailureReason PayInFailureReason? // TODO: add check constraint + payInStateChangedAt DateTime? // TODO: set with a trigger? + genesisId Int? + successorId Int? @unique + benefactorId Int? + + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + msatsBefore BigInt? + mcreditsBefore BigInt? + + pessimisticEnv PessimisticEnv? + payInCustodialTokens PayInCustodialToken[] + payOutCustodialTokens PayOutCustodialToken[] + payOutBolt11 PayOutBolt11? + + genesis PayIn? @relation("PayInGenesis", fields: [genesisId], references: [id], onDelete: Cascade) + successor PayIn? @relation("PayInSuccessor", fields: [successorId], references: [id], onDelete: Cascade) + predecessor PayIn? @relation("PayInSuccessor") + benefactor PayIn? @relation("PayInBenefactor", fields: [benefactorId], references: [id], onDelete: Cascade) + beneficiaries PayIn[] @relation("PayInBenefactor") + itemPayIns ItemPayIn[] + subPayIns SubPayIn[] + progeny PayIn[] @relation("PayInGenesis") + + @@index([userId]) + @@index([payInType]) + @@index([successorId]) + @@index([payInStateChangedAt]) +} + +enum CustodialTokenType { + CREDITS + SATS +} + +model PayInCustodialToken { + id Int @id @default(autoincrement()) + payInId Int + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + mtokens BigInt + custodialTokenType CustodialTokenType +} + +model PessimisticEnv { + id Int @id @default(autoincrement()) + payInId Int @unique + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + args Json? @db.JsonB + error String? +} + +enum PayOutType { + TERRITORY_REVENUE + REWARDS_POOL + ROUTING_FEE + ROUTING_FEE_REFUND + PROXY_PAYMENT + ZAP + REWARD + INVITE_GIFT + WITHDRAWAL + 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") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payOutType PayOutType + + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + payInId Int + payIn PayIn @relation(fields: [payInId], references: [id], onDelete: Cascade) + + mtokens BigInt + custodialTokenType CustodialTokenType + + msatsBefore BigInt? + mcreditsBefore BigInt? + SubPayOutCustodialToken SubPayOutCustodialToken[] +} + +model PayInBolt11 { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payInId Int @unique + hash String @unique + preimage String? @unique + bolt11 String + expiresAt DateTime + confirmedAt DateTime? + confirmedIndex BigInt? + cancelledAt DateTime? + msatsRequested BigInt + msatsReceived BigInt? + expiryHeight Int? + acceptHeight Int? + User User? @relation(fields: [userId], references: [id]) + userId Int? + + lud18Data PayInBolt11Lud18? + nostrNote PayInBolt11NostrNote? + comment PayInBolt11Comment? + + @@index([createdAt]) + @@index([confirmedIndex]) + @@index([confirmedAt]) + @@index([cancelledAt]) +} + +model PayOutBolt11 { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + payOutType PayOutType + userId Int + hash String? + preimage String? + bolt11 String? + msats BigInt + status WithdrawlStatus? + 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]) + @@index([hash]) + @@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 +} diff --git a/wallets/server.js b/wallets/server.js index 529b70a64..3c59731cc 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -13,28 +13,20 @@ import * as webln from '@/wallets/webln' import { walletLogger } from '@/api/resolvers/wallet' 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' const walletDefs = [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] export default walletDefs 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(