Compare commits

..

No commits in common. "cb2efb0a7fa4a8b6393ed25e6e5e35071c77e0f9" and "454ad26bd792633fa686c33696b458854f6c0f69" have entirely different histories.

38 changed files with 495 additions and 1335 deletions

View File

@ -1,4 +1,3 @@
import { toPositiveNumber } from '@/lib/validate'
import lndService from 'ln-service' import lndService from 'ln-service'
const { lnd } = lndService.authenticatedLndGrpc({ const { lnd } = lndService.authenticatedLndGrpc({
@ -16,80 +15,4 @@ lndService.getWalletInfo({ lnd }, (err, result) => {
console.log('LND GRPC connection successful') console.log('LND GRPC connection successful')
}) })
export async function estimateRouteFee ({ lnd, destination, tokens, mtokens, request, timeout }) {
return await new Promise((resolve, reject) => {
lnd.router.estimateRouteFee({
dest: Buffer.from(destination, 'hex'),
amt_sat: tokens ? toPositiveNumber(tokens) : toPositiveNumber(BigInt(mtokens) / BigInt(1e3)),
payment_request: request,
timeout
}, (err, res) => {
if (err) {
reject(err)
return
}
if (res?.failure_reason) {
reject(new Error(`Unable to estimate route: ${res.failure_reason}`))
return
}
if (res.routing_fee_msat < 0 || res.time_lock_delay <= 0) {
reject(new Error('Unable to estimate route, excessive values: ' + JSON.stringify(res)))
return
}
resolve({
routingFeeMsat: toPositiveNumber(res.routing_fee_msat),
timeLockDelay: toPositiveNumber(res.time_lock_delay)
})
})
})
}
// created_height is the accepted_height, timeout is the expiry height
// ln-service remaps the `htlcs` field of lookupInvoice to `payments` and
// see: https://github.com/alexbosworth/lightning/blob/master/lnd_responses/htlc_as_payment.js
// and: https://lightning.engineering/api-docs/api/lnd/lightning/lookup-invoice/index.html#lnrpcinvoicehtlc
export function hodlInvoiceCltvDetails (inv) {
if (!inv.payments) {
throw new Error('No payments found')
}
if (!inv.is_held) {
throw new Error('Invoice is not held')
}
const acceptHeight = inv.payments.reduce((max, htlc) => {
const createdHeight = toPositiveNumber(htlc.created_height)
return createdHeight > max ? createdHeight : max
}, 0)
const expiryHeight = inv.payments.reduce((min, htlc) => {
const timeout = toPositiveNumber(htlc.timeout)
return timeout < min ? timeout : min
}, Number.MAX_SAFE_INTEGER)
return {
expiryHeight: toPositiveNumber(expiryHeight),
acceptHeight: toPositiveNumber(acceptHeight)
}
}
export function getPaymentFailureStatus (withdrawal) {
if (withdrawal && !withdrawal.is_failed) {
throw new Error('withdrawal is not failed')
}
if (withdrawal?.failed.is_insufficient_balance) {
return 'INSUFFICIENT_BALANCE'
} else if (withdrawal?.failed.is_invalid_payment) {
return 'INVALID_PAYMENT'
} else if (withdrawal?.failed.is_pathfinding_timeout) {
return 'PATHFINDING_TIMEOUT'
} else if (withdrawal?.failed.is_route_not_found) {
return 'ROUTE_NOT_FOUND'
}
return 'UNKNOWN_FAILURE'
}
export default lnd export default lnd

View File

@ -2,38 +2,6 @@
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. 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.
<details>
<summary>internals</summary>
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
```
</details>
## Payment Flows ## Payment Flows
There are three payment flows: There are three payment flows:
@ -49,20 +17,11 @@ For paid actions that support it, if the stacker doesn't have enough fee credits
<details> <details>
<summary>Internals</summary> <summary>Internals</summary>
Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress. Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress. All optimistic actions start in a `PENDING` state and have the following transitions:
```mermaid - `PENDING` -> `PAID`: when the invoice is paid
stateDiagram-v2 - `PENDING` -> `FAILED`: when the invoice expires or is cancelled
[*] --> PENDING - `FAILED` -> `RETRYING`: when the invoice for the action is replaced with a new invoice
PENDING --> PAID
PENDING --> CANCELING
PENDING --> FAILED
PAID --> [*]
CANCELING --> FAILED
FAILED --> RETRYING
FAILED --> [*]
RETRYING --> [*]
```
</details> </details>
### Pessimistic ### Pessimistic
@ -73,21 +32,12 @@ Internally, pessimistic flows use hold invoices. If the action doesn't succeed,
<details> <details>
<summary>Internals</summary> <summary>Internals</summary>
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. 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. All pessimistic actions start in a `PENDING_HELD` state and has the following transitions:
```mermaid - `PENDING_HELD` -> `HELD`: when the invoice is paid and the action's `perform` is run and the invoice is settled
stateDiagram-v2 - `HELD` -> `PAID`: when the action's `onPaid` is called
PAID --> [*] - `PENDING_HELD` -> `FAILED`: when the invoice for the action expires or is cancelled
CANCELING --> FAILED - `HELD` -> `FAILED`: when the action fails after the invoice is paid
FAILED --> [*]
[*] --> PENDING_HELD
PENDING_HELD --> HELD
PENDING_HELD --> CANCELING
PENDING_HELD --> FAILED
HELD --> PAID
HELD --> CANCELING
HELD --> FAILED
```
</details> </details>
### Table of existing paid actions and their supported flows ### Table of existing paid actions and their supported flows
@ -104,35 +54,6 @@ stateDiagram-v2
| update posts | x | | x | | x | | x | | update posts | x | | x | | x | | x |
| update comments | x | | x | | x | | x | | update comments | x | | x | | x | | x |
## Not-custodial zaps (ie p2p wrapped payments)
Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap.
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.
<details>
<summary>Internals</summary>
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
```
</details>
## Paid Action Interface ## 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: Each paid action is implemented in its own file in the `paidAction` directory. Each file exports a module with the following properties:
@ -142,7 +63,7 @@ Each paid action is implemented in its own file in the `paidAction` directory. E
- `supportsPessimism`: supports a pessimistic payment flow - `supportsPessimism`: supports a pessimistic payment flow
- `supportsOptimism`: supports an optimistic payment flow - `supportsOptimism`: supports an optimistic payment flow
### Functions #### Functions
All functions have the following signature: `function(args: Object, context: Object): Promise` All functions have the following signature: `function(args: Object, context: Object): Promise`
@ -163,8 +84,6 @@ All functions have the following signature: `function(args: Object, context: Obj
- this function is called when an optimistic action is retried - this function is called when an optimistic action is retried
- it's passed the original `invoiceId` and the `newInvoiceId` - 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` - this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING`
- `invoiceablePeer`: 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 - `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 - for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description
@ -229,7 +148,7 @@ COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200 -- 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. Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions doesn't know to exist yet.
#### Subqueries are still incorrect #### Subqueries are still incorrect
@ -288,8 +207,6 @@ From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.
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. 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 ### Incorrect
```sql ```sql
@ -306,7 +223,9 @@ UPDATE users set msats = msats + 1 WHERE id = 1;
-- deadlock occurs because neither transaction can proceed to here -- 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. If both transactions lock the rows in the same order, the deadlock is avoided.
Most often this 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 ### Incorrect

View File

@ -1,4 +1,4 @@
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' import { createHodlInvoice, createInvoice } from 'ln-service'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import { createHmac } from '../resolvers/wallet' import { createHmac } from '../resolvers/wallet'
@ -13,8 +13,6 @@ import * as TERRITORY_UPDATE from './territoryUpdate'
import * as TERRITORY_BILLING from './territoryBilling' import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive' import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate' import * as DONATE from './donate'
import wrapInvoice from 'wallets/wrap'
import { createInvoice as createUserInvoice } from 'wallets/server'
export const paidActions = { export const paidActions = {
ITEM_CREATE, ITEM_CREATE,
@ -124,12 +122,12 @@ async function performOptimisticAction (actionType, args, context) {
const action = paidActions[actionType] const action = paidActions[actionType]
context.optimistic = true context.optimistic = true
const invoiceArgs = await createLightningInvoice(actionType, args, context) context.lndInvoice = await createLndInvoice(actionType, args, context)
return await models.$transaction(async tx => { return await models.$transaction(async tx => {
context.tx = tx context.tx = tx
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs) const invoice = await createDbInvoice(actionType, args, context)
return { return {
invoice, invoice,
@ -147,9 +145,9 @@ async function performPessimisticAction (actionType, args, context) {
} }
// just create the invoice and complete action when it's paid // just create the invoice and complete action when it's paid
const invoiceArgs = await createLightningInvoice(actionType, args, context) context.lndInvoice = await createLndInvoice(actionType, args, context)
return { return {
invoice: await createDbInvoice(actionType, args, context, invoiceArgs), invoice: await createDbInvoice(actionType, args, context),
paymentMethod: 'PESSIMISTIC' paymentMethod: 'PESSIMISTIC'
} }
} }
@ -182,16 +180,15 @@ export async function retryPaidAction (actionType, args, context) {
context.optimistic = true context.optimistic = true
context.me = await models.user.findUnique({ where: { id: me.id } }) context.me = await models.user.findUnique({ where: { id: me.id } })
const { msatsRequested, actionId } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } }) const { msatsRequested } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
context.cost = BigInt(msatsRequested) context.cost = BigInt(msatsRequested)
context.actionId = actionId context.lndInvoice = await createLndInvoice(actionType, args, context)
const invoiceArgs = await createSNInvoice(actionType, args, context)
return await models.$transaction(async tx => { return await models.$transaction(async tx => {
context.tx = tx context.tx = tx
// update the old invoice to RETRYING, so that it's not confused with FAILED // update the old invoice to RETRYING, so that it's not confused with FAILED
await tx.invoice.update({ const { actionId } = await tx.invoice.update({
where: { where: {
id: invoiceId, id: invoiceId,
actionState: 'FAILED' actionState: 'FAILED'
@ -201,8 +198,10 @@ export async function retryPaidAction (actionType, args, context) {
} }
}) })
context.actionId = actionId
// create a new invoice // create a new invoice
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs) const invoice = await createDbInvoice(actionType, args, context)
return { return {
result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context), result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context),
@ -212,117 +211,65 @@ export async function retryPaidAction (actionType, args, context) {
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
} }
const INVOICE_EXPIRE_SECS = 600 const OPTIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
export async function createLightningInvoice (actionType, args, context) {
// if the action has an invoiceable peer, we'll create a peer invoice
// wrap it, and return the wrapped invoice
const { cost, models, lnd } = context
const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)
if (userId) {
try {
const description = await paidActions[actionType].describe(args, context)
const { invoice: bolt11, wallet } = await createUserInvoice(userId, {
// this is the amount the stacker will receive, the other 1/10th is the fee
msats: cost * BigInt(9) / BigInt(10),
description,
expiry: INVOICE_EXPIRE_SECS
}, { models })
const { invoice: wrappedInvoice, maxFee } = await wrapInvoice(
bolt11, { description }, { lnd })
return {
bolt11,
wrappedBolt11: wrappedInvoice.request,
wallet,
maxFee
}
} catch (e) {
console.error('failed to create stacker invoice, falling back to SN invoice', e)
}
}
return await createSNInvoice(actionType, args, context)
}
// we seperate the invoice creation into two functions because // we seperate the invoice creation into two functions because
// because if lnd is slow, it'll timeout the interactive tx // because if lnd is slow, it'll timeout the interactive tx
async function createSNInvoice (actionType, args, context) { async function createLndInvoice (actionType, args, context) {
const { me, lnd, cost, optimistic } = context const { me, lnd, cost, optimistic } = context
const action = paidActions[actionType] const action = paidActions[actionType]
const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice const [createLNDInvoice, expirePivot] = optimistic
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE]
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE]
if (cost < 1000n) { if (cost < 1000n) {
// sanity check // sanity check
throw new Error('The cost of the action must be at least 1 sat') throw new Error('The cost of the action must be at least 1 sat')
} }
const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) const expiresAt = datePivot(new Date(), expirePivot)
const invoice = await createLNDInvoice({ return await createLNDInvoice({
description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context), description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
lnd, lnd,
mtokens: String(cost), mtokens: String(cost),
expires_at: expiresAt expires_at: expiresAt
}) })
return { bolt11: invoice.request, preimage: invoice.secret }
} }
async function createDbInvoice (actionType, args, context, async function createDbInvoice (actionType, args, context) {
{ bolt11, wrappedBolt11, preimage, wallet, maxFee }) { const { me, models, tx, lndInvoice, cost, optimistic, actionId } = context
const { me, models, tx, cost, optimistic, actionId } = context
const db = tx ?? models const db = tx ?? models
const [expirePivot, actionState] = optimistic
? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
: [PESSIMISTIC_INVOICE_EXPIRE, 'PENDING_HELD']
if (cost < 1000n) { if (cost < 1000n) {
// sanity check // sanity check
throw new Error('The cost of the action must be at least 1 sat') throw new Error('The cost of the action must be at least 1 sat')
} }
const servedBolt11 = wrappedBolt11 ?? bolt11 const expiresAt = datePivot(new Date(), expirePivot)
const servedInvoice = parsePaymentRequest({ request: servedBolt11 }) const invoice = await db.invoice.create({
const expiresAt = new Date(servedInvoice.expires_at) data: {
hash: lndInvoice.id,
const invoiceData = { msatsRequested: cost,
hash: servedInvoice.id, preimage: optimistic ? undefined : lndInvoice.secret,
msatsRequested: BigInt(servedInvoice.mtokens), bolt11: lndInvoice.request,
preimage: optimistic ? undefined : preimage,
bolt11: servedBolt11,
userId: me?.id ?? USER_ID.anon, userId: me?.id ?? USER_ID.anon,
actionType, actionType,
actionState: wrappedBolt11 ? 'PENDING_HELD' : optimistic ? 'PENDING' : 'PENDING_HELD', actionState,
actionOptimistic: optimistic,
actionArgs: args, actionArgs: args,
expiresAt, expiresAt,
actionId actionId
} }
})
let invoice
if (wrappedBolt11) {
invoice = (await db.invoiceForward.create({
include: { invoice: true },
data: {
bolt11,
maxFeeMsats: maxFee,
invoice: {
create: invoiceData
},
wallet: {
connect: {
id: wallet.id
}
}
}
})).invoice
} else {
invoice = await db.invoice.create({ data: invoiceData })
}
// insert a job to check the invoice after it's set to expire // insert a job to check the invoice after it's set to expire
await db.$executeRaw` await db.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority) INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority)
VALUES ('checkInvoice', VALUES ('checkInvoice',
jsonb_build_object('hash', ${invoice.hash}::TEXT), 21, true, jsonb_build_object('hash', ${lndInvoice.id}::TEXT), 21, true,
${expiresAt}::TIMESTAMP WITH TIME ZONE, ${expiresAt}::TIMESTAMP WITH TIME ZONE,
${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)` ${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)`

View File

@ -10,23 +10,6 @@ export async function getCost ({ sats }) {
return satsToMsats(sats) return satsToMsats(sats)
} }
export async function invoiceablePeer ({ id }, { models }) {
const item = await models.item.findUnique({
where: { id: parseInt(id) },
include: {
itemForwards: true,
user: {
include: {
wallets: true
}
}
}
})
// request peer invoice if they have an attached wallet and have not forwarded the item
return item.user.wallets.length > 0 && item.itemForwards.length === 0 ? item.userId : null
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) { export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
const feeMsats = cost / BigInt(10) // 10% fee const feeMsats = cost / BigInt(10) // 10% fee
const zapMsats = cost - feeMsats const zapMsats = cost - feeMsats
@ -95,20 +78,16 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
SELECT COALESCE(SUM(msats), 0) as msats SELECT COALESCE(SUM(msats), 0) as msats
FROM forwardees FROM forwardees
), recipients AS ( ), recipients AS (
SELECT "userId", msats, msats AS "stackedMsats" FROM forwardees SELECT "userId", msats FROM forwardees
UNION UNION
SELECT ${itemAct.item.userId}::INTEGER as "userId", SELECT ${itemAct.item.userId}::INTEGER as "userId",
CASE WHEN ${!!invoice?.invoiceForward}::BOOLEAN ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT as msats
THEN 0::BIGINT
ELSE ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT
END as msats,
${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT as "stackedMsats"
ORDER BY "userId" ASC -- order to prevent deadlocks ORDER BY "userId" ASC -- order to prevent deadlocks
) )
UPDATE users UPDATE users
SET SET
msats = users.msats + recipients.msats, msats = users.msats + recipients.msats,
"stackedMsats" = users."stackedMsats" + recipients."stackedMsats" "stackedMsats" = users."stackedMsats" + recipients.msats
FROM recipients FROM recipients
WHERE users.id = recipients."userId"` WHERE users.id = recipients."userId"`

View File

@ -11,10 +11,10 @@ async function fetchBlockHeight () {
try { try {
const height = await lndService.getHeight({ lnd }) const height = await lndService.getHeight({ lnd })
blockHeight = height.current_block_height blockHeight = height.current_block_height
cache.set('block', { height: blockHeight, createdAt: Date.now() })
} catch (err) { } catch (err) {
console.error('fetchBlockHeight', err) console.error('fetchBlockHeight', err)
} }
cache.set('block', { height: blockHeight, createdAt: Date.now() })
return blockHeight return blockHeight
} }

View File

@ -212,15 +212,6 @@ const subClause = (sub, num, table, me, showNsfw) => {
return excludeMuted + ' AND ' + HIDE_NSFW_CLAUSE return excludeMuted + ' AND ' + HIDE_NSFW_CLAUSE
} }
function investmentClause (sats) {
return `(
CASE WHEN "Item"."parentId" IS NULL
THEN ("Item".cost + "Item".boost + ("Item".msats / 1000)) >= ${sats}
ELSE ("Item".cost + "Item".boost + ("Item".msats / 1000)) >= ${Math.min(sats, 1)}
END
)`
}
export async function filterClause (me, models, type) { export async function filterClause (me, models, type) {
// if you are explicitly asking for marginal content, don't filter them // if you are explicitly asking for marginal content, don't filter them
if (['outlawed', 'borderland', 'freebies'].includes(type)) { if (['outlawed', 'borderland', 'freebies'].includes(type)) {
@ -234,11 +225,11 @@ export async function filterClause (me, models, type) {
// handle freebies // handle freebies
// by default don't include freebies unless they have upvotes // by default don't include freebies unless they have upvotes
let satsFilter = investmentClause(10) let investmentClause = '("Item".cost + "Item".boost + ("Item".msats / 1000)) >= 10'
if (me) { if (me) {
const user = await models.user.findUnique({ where: { id: me.id } }) const user = await models.user.findUnique({ where: { id: me.id } })
satsFilter = `(${investmentClause(user.satsFilter)} OR "Item"."userId" = ${me.id})` investmentClause = `(("Item".cost + "Item".boost + ("Item".msats / 1000)) >= ${user.satsFilter} OR "Item"."userId" = ${me.id})`
if (user.wildWestMode) { if (user.wildWestMode) {
return investmentClause return investmentClause
@ -253,7 +244,7 @@ export async function filterClause (me, models, type) {
} }
const outlawClause = '(' + outlawClauses.join(' OR ') + ')' const outlawClause = '(' + outlawClauses.join(' OR ') + ')'
return [satsFilter, outlawClause] return [investmentClause, outlawClause]
} }
function typeClause (type) { function typeClause (type) {

View File

@ -26,12 +26,7 @@ function paidActionType (actionType) {
export default { export default {
Query: { Query: {
paidAction: async (parent, { invoiceId }, { models, me }) => { paidAction: async (parent, { invoiceId }, { models, me }) => {
const invoice = await models.invoice.findUnique({ const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } })
where: {
id: invoiceId,
userId: me?.id ?? USER_ID.anon
}
})
if (!invoice) { if (!invoice) {
throw new Error('Invoice not found') throw new Error('Invoice not found')
} }
@ -40,7 +35,7 @@ export default {
type: paidActionType(invoice.actionType), type: paidActionType(invoice.actionType),
invoice, invoice,
result: invoice.actionResult, result: invoice.actionResult,
paymentMethod: invoice.actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC' paymentMethod: invoice.preimage ? 'PESSIMISTIC' : 'OPTIMISTIC'
} }
} }
}, },

View File

@ -1,17 +1,17 @@
import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, getInvoice as getInvoiceFromLnd, getNode, deletePayment, getPayment, getIdentity } from 'ln-service' import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, deletePayment, getPayment, getIdentity } from 'ln-service'
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import crypto, { timingSafeEqual } from 'crypto' import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial' import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item' import { SELECT, itemQueryWithMeta } from './item'
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format' import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, walletValidate } from '@/lib/validate' import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, walletValidate } from '@/lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from 'worker/wallet' import { checkInvoice } from 'worker/wallet'
import walletDefs from 'wallets/server' import walletDefs from 'wallets/server'
import { generateResolverName } from '@/lib/wallet' import { generateResolverName } from '@/lib/wallet'
import { lnAddrOptions } from '@/lib/lnurl' import { lnAddrOptions } from '@/lib/lnurl'
@ -23,13 +23,7 @@ function injectResolvers (resolvers) {
console.log(resolverName) console.log(resolverName)
resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => { resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => {
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented) await walletValidate(w, { ...data, ...settings })
const validData = await walletValidate(w, { ...data, ...settings })
if (validData) {
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
}
return await upsertWallet({ return await upsertWallet({
wallet: { field: w.walletField, type: w.walletType }, wallet: { field: w.walletField, type: w.walletType },
testConnectServer: (data) => w.testConnectServer(data, { me, models }) testConnectServer: (data) => w.testConnectServer(data, { me, models })
@ -91,8 +85,7 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
id: Number(id) id: Number(id)
}, },
include: { include: {
user: true, user: true
invoiceForward: true
} }
}) })
@ -104,6 +97,14 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } })
} }
try {
if (wdrwl.status === 'CONFIRMED') {
wdrwl.preimage = (await getPayment({ id: wdrwl.hash, lnd })).payment.secret
}
} catch (err) {
console.error('error fetching payment from LND', err)
}
return wdrwl return wdrwl
} }
@ -184,8 +185,8 @@ const resolvers = {
jsonb_build_object( jsonb_build_object(
'bolt11', bolt11, 'bolt11', bolt11,
'status', CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED' 'status', CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
WHEN "expiresAt" <= $2 THEN 'EXPIRED'
WHEN cancelled THEN 'CANCELLED' WHEN cancelled THEN 'CANCELLED'
WHEN "expiresAt" <= $2 AND NOT "isHeld" THEN 'EXPIRED'
ELSE 'PENDING' END, ELSE 'PENDING' END,
'description', "desc", 'description', "desc",
'invoiceComment', comment, 'invoiceComment', comment,
@ -199,19 +200,17 @@ const resolvers = {
if (include.has('withdrawal')) { if (include.has('withdrawal')) {
queries.push( queries.push(
`(SELECT `(SELECT
"Withdrawl".id, "Withdrawl".created_at as "createdAt", id, created_at as "createdAt",
COALESCE("msatsPaid", "msatsPaying") as msats, COALESCE("msatsPaid", "msatsPaying") as msats,
CASE WHEN bool_and("InvoiceForward".id IS NULL) THEN 'withdrawal' ELSE 'p2p' END as type, 'withdrawal' as type,
jsonb_build_object( jsonb_build_object(
'bolt11', "Withdrawl".bolt11, 'bolt11', bolt11,
'autoWithdraw', "autoWithdraw", 'autoWithdraw', "autoWithdraw",
'status', COALESCE(status::text, 'PENDING'), 'status', COALESCE(status::text, 'PENDING'),
'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other 'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
FROM "Withdrawl" FROM "Withdrawl"
LEFT JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId" WHERE "userId" = $1
WHERE "Withdrawl"."userId" = $1 AND created_at <= $2)`
AND "Withdrawl".created_at <= $2
GROUP BY "Withdrawl".id)`
) )
} }
@ -313,9 +312,6 @@ const resolvers = {
case 'withdrawal': case 'withdrawal':
f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee) f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
break break
case 'p2p':
f.msats = -1 * Number(f.msats)
break
case 'spent': case 'spent':
case 'donation': case 'donation':
case 'billing': case 'billing':
@ -402,12 +398,14 @@ const resolvers = {
}, },
createWithdrawl: createWithdrawal, createWithdrawl: createWithdrawal,
sendToLnAddr, sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => { cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
const hmac2 = createHmac(hash) const hmac2 = createHmac(hash)
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
} }
await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) await cancelHodlInvoice({ id: hash, lnd })
// transition invoice to cancelled action state
await checkInvoice({ data: { hash }, models, lnd })
return await models.invoice.findFirst({ where: { hash } }) return await models.invoice.findFirst({ where: { hash } })
}, },
dropBolt11: async (parent, { id }, { me, models, lnd }) => { dropBolt11: async (parent, { id }, { me, models, lnd }) => {
@ -425,7 +423,6 @@ const resolvers = {
AND id = ${Number(id)} AND id = ${Number(id)}
AND now() > created_at + interval '${retention}' AND now() > created_at + interval '${retention}'
AND hash IS NOT NULL AND hash IS NOT NULL
AND status IS NOT NULL
), updated_rows AS ( ), updated_rows AS (
UPDATE "Withdrawl" UPDATE "Withdrawl"
SET hash = NULL, bolt11 = NULL SET hash = NULL, bolt11 = NULL
@ -479,18 +476,8 @@ const resolvers = {
Withdrawl: { Withdrawl: {
satsPaying: w => msatsToSats(w.msatsPaying), satsPaying: w => msatsToSats(w.msatsPaying),
satsPaid: w => msatsToSats(w.msatsPaid), satsPaid: w => msatsToSats(w.msatsPaid),
satsFeePaying: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaying), satsFeePaying: w => msatsToSats(w.msatsFeePaying),
satsFeePaid: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaid), satsFeePaid: w => msatsToSats(w.msatsFeePaid)
p2p: w => !!w.invoiceForward?.length,
preimage: async (withdrawl, args, { lnd }) => {
try {
if (withdrawl.status === 'CONFIRMED') {
return (await getPayment({ id: withdrawl.hash, lnd })).payment.secret
}
} catch (err) {
console.error('error fetching payment from LND', err)
}
}
}, },
Invoice: { Invoice: {
@ -556,9 +543,9 @@ const resolvers = {
export default injectResolvers(resolvers) export default injectResolvers(resolvers)
export const addWalletLog = async ({ wallet, level, message }, { models }) => { export const addWalletLog = async ({ wallet, level, message }, { me, models }) => {
try { try {
await models.walletLog.create({ data: { userId: wallet.userId, wallet: wallet.type, level, message } }) await models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level, message } })
} catch (err) { } catch (err) {
console.error('error creating wallet log:', err) console.error('error creating wallet log:', err)
} }
@ -577,9 +564,8 @@ async function upsertWallet (
} catch (err) { } catch (err) {
console.error(err) console.error(err)
const message = 'failed to create test invoice: ' + (err.message || err.toString?.()) const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
wallet = { ...wallet, userId: me.id } await addWalletLog({ wallet, level: 'ERROR', message }, { me, models })
await addWalletLog({ wallet, level: 'ERROR', message }, { models }) await addWalletLog({ wallet, level: 'INFO', message: 'receives disabled' }, { me, models })
await addWalletLog({ wallet, level: 'INFO', message: 'receives disabled' }, { models })
throw new GraphQLError(message, { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError(message, { extensions: { code: 'BAD_INPUT' } })
} }
} }
@ -704,7 +690,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
request: invoice, request: invoice,
// can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141 // can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141
max_fee: Number(maxFee), max_fee: Number(maxFee),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS pathfinding_timeout: 30000
}).catch(console.error) }).catch(console.error)
return withdrawl return withdrawl

View File

@ -72,6 +72,7 @@ export default gql`
noReferralLinks: Boolean! noReferralLinks: Boolean!
fiatCurrency: String! fiatCurrency: String!
satsFilter: Int! satsFilter: Int!
greeterMode: Boolean!
hideBookmarks: Boolean! hideBookmarks: Boolean!
hideCowboyHat: Boolean! hideCowboyHat: Boolean!
hideGithub: Boolean! hideGithub: Boolean!

View File

@ -125,7 +125,6 @@ const typeDefs = `
satsFeePaid: Int satsFeePaid: Int
status: String status: String
autoWithdraw: Boolean! autoWithdraw: Boolean!
p2p: Boolean!
preimage: String preimage: String
} }

View File

@ -2,7 +2,7 @@ import { InputGroup } from 'react-bootstrap'
import { Checkbox, Input } from './form' import { Checkbox, Input } from './form'
import { useMe } from './me' import { useMe } from './me'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { isNumber } from '@/lib/validate' import { isNumber } from 'mathjs'
function autoWithdrawThreshold ({ me }) { function autoWithdrawThreshold ({ me }) {
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000 return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000

View File

@ -1,40 +1,56 @@
import { useRef, useState } from 'react'
import { Popover } from 'react-bootstrap' import { Popover } from 'react-bootstrap'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger' import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import { useRef, useState } from 'react' import styles from './hoverable-popover.module.css'
export default function HoverablePopover ({ id, trigger, body, onShow }) {
const [showOverlay, setShowOverlay] = useState(false)
export default function HoverablePopover ({ trigger, body, onShow }) {
const [show, setShow] = useState(false)
const popRef = useRef(null)
const timeoutId = useRef(null) const timeoutId = useRef(null)
const onToggle = show => { const handleMouseEnter = () => {
clearTimeout(timeoutId.current) clearTimeout(timeoutId.current)
if (show) { onShow && onShow()
onShow?.() timeoutId.current = setTimeout(() => {
timeoutId.current = setTimeout(() => setShow(true), 500) setShowOverlay(true)
} else { }, 500)
timeoutId.current = setTimeout(() => setShow(!!popRef.current?.matches(':hover')), 500)
} }
const handleMouseLeave = () => {
clearTimeout(timeoutId.current)
timeoutId.current = setTimeout(() => setShowOverlay(false), 100)
} }
return ( return (
<OverlayTrigger <OverlayTrigger
show={showOverlay}
placement='bottom' placement='bottom'
trigger={['hover', 'focus']} onHide={handleMouseLeave}
show={show} popperConfig={{
onToggle={onToggle} modifiers: {
delay={1} preventOverflow: {
transition={false} enabled: false
rootClose }
}
}}
overlay={ overlay={
<Popover style={{ position: 'fixed' }} onPointerLeave={() => onToggle(false)}> <Popover
<Popover.Body ref={popRef}> onPointerEnter={handleMouseEnter}
onPointerLeave={handleMouseLeave}
onMouseLeave={handleMouseLeave}
className={styles.HoverablePopover}
style={{ position: 'fixed' }}
>
<Popover.Body className={styles.HoverablePopover}>
{body} {body}
</Popover.Body> </Popover.Body>
</Popover> </Popover>
} }
> >
<span> <span
onPointerEnter={handleMouseEnter}
onPointerLeave={handleMouseLeave}
>
{trigger} {trigger}
</span> </span>
</OverlayTrigger> </OverlayTrigger>

View File

@ -0,0 +1,8 @@
.hoverablePopover {
border: 1px solid var(--theme-toolbarActive)
}
.hoverablePopBody {
font-weight: 500;
font-size: 0.9rem;
}

View File

@ -458,11 +458,9 @@ function Invoicification ({ n: { invoice, sortTime } }) {
function WithdrawlPaid ({ n }) { function WithdrawlPaid ({ n }) {
return ( return (
<div className='fw-bold text-info'> <div className='fw-bold text-info'>
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })} <Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account
{n.withdrawl.p2p || n.withdrawl.autoWithdraw ? 'sent to your attached wallet' : 'withdrawn from your account'}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small> <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{(n.withdrawl.p2p && <Badge className={styles.badge} bg={null}>p2p</Badge>) || {n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
(n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>)}
</div> </div>
) )
} }

View File

@ -1,8 +1,4 @@
x-env_file: &env_file x-env_file: &env_file .env.development
- path: .env.development
required: true
- path: .env.local
required: false
x-healthcheck: &healthcheck x-healthcheck: &healthcheck
interval: 10s interval: 10s
timeout: 10s timeout: 10s
@ -211,7 +207,7 @@ services:
CONNECT: "localhost:5601" CONNECT: "localhost:5601"
cpu_shares: "${CPU_SHARES_LOW}" cpu_shares: "${CPU_SHARES_LOW}"
bitcoin: bitcoin:
image: polarlightning/bitcoind:27.0 image: polarlightning/bitcoind:26.0
container_name: bitcoin container_name: bitcoin
restart: unless-stopped restart: unless-stopped
profiles: profiles:
@ -239,7 +235,6 @@ services:
- '-fallbackfee=0.0002' - '-fallbackfee=0.0002'
- '-blockfilterindex=1' - '-blockfilterindex=1'
- '-peerblockfilters=1' - '-peerblockfilters=1'
- '-maxmempool=5'
expose: expose:
- "${RPC_PORT}" - "${RPC_PORT}"
- "${P2P_PORT}" - "${P2P_PORT}"
@ -252,40 +247,19 @@ services:
ofelia.job-exec.minecron.schedule: "@every 1m" ofelia.job-exec.minecron.schedule: "@every 1m"
ofelia.job-exec.minecron.command: > ofelia.job-exec.minecron.command: >
bash -c ' bash -c '
bitcoin-cli () { blockcount=$$(bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} getblockcount 2>/dev/null)
command bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} "$$@"
}
blockcount=$$(bitcoin-cli getblockcount 2>/dev/null)
nodes=(${LND_ADDR} ${STACKER_LND_ADDR} ${STACKER_CLN_ADDR})
if (( blockcount <= 0 )); then if (( blockcount <= 0 )); then
echo "Creating wallet and address..." echo "Mining 10 blocks to sn_lnd, stacker_lnd, stacker_cln..."
bitcoin-cli createwallet "" bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 100 ${LND_ADDR}
nodes+=($$(bitcoin-cli getnewaddress)) bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 100 ${STACKER_LND_ADDR}
echo "Mining 100 blocks to sn_lnd, stacker_lnd, stacker_cln..." bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 100 ${STACKER_CLN_ADDR}
for addr in "$${nodes[@]}"; do
bitcoin-cli generatetoaddress 100 $$addr
echo "Mining 100 blocks to a random address..."
bitcoin-cli generatetoaddress 100 $$(bitcoin-cli getnewaddress)
done
else else
echo "generating txs for fee rate estimation..." echo "Mining a block to sn_lnd... ${LND_ADDR}"
while true bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 1 ${LND_ADDR}
do echo "Mining a block to stacker_lnd... ${STACKER_LND_ADDR}"
i=0 bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 1 ${STACKER_LND_ADDR}
range=$$(( $$RANDOM % 11 + 20 )) echo "Mining a block to stacker_cln... ${STACKER_CLN_ADDR}"
while [ $$i -lt $$range ] bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 1 ${STACKER_CLN_ADDR}
do
address=$$(bitcoin-cli getnewaddress)
bitcoin-cli -named sendtoaddress address=$$address amount=0.01 fee_rate=$$(( $$RANDOM % 25 + 1 ))
((++i))
done
echo "generating block..."
bitcoin-cli generatetoaddress 1 "$${nodes[ $$RANDOM % $${#nodes[@]} ]}"
if [[ $$(bitcoin-cli estimatesmartfee 6) =~ "\\"feerate\\":" ]]; then
echo "fee estimation succeeded..."
break
fi
done
fi fi
' '
cpu_shares: "${CPU_SHARES_MODERATE}" cpu_shares: "${CPU_SHARES_MODERATE}"

View File

@ -1,4 +1,4 @@
FROM polarlightning/lnd:0.17.5-beta FROM polarlightning/lnd:0.17.4-beta
ARG LN_NODE_FOR ARG LN_NODE_FOR
ENV LN_NODE_FOR=$LN_NODE_FOR ENV LN_NODE_FOR=$LN_NODE_FOR

View File

@ -184,8 +184,6 @@ export const NOTIFICATIONS = gql`
earnedSats earnedSats
withdrawl { withdrawl {
autoWithdraw autoWithdraw
p2p
satsFeePaid
} }
} }
... on Reminder { ... on Reminder {

View File

@ -16,8 +16,6 @@ export const createInvoice = async ({ socket, rune, cert, label, description, ms
agent = protocol === 'https:' agent = protocol === 'https:'
? new HttpsProxyAgent({ ...proxyOptions, ...httpsAgentOptions, rejectUnauthorized: false }) ? new HttpsProxyAgent({ ...proxyOptions, ...httpsAgentOptions, rejectUnauthorized: false })
: new HttpProxyAgent(proxyOptions) : new HttpProxyAgent(proxyOptions)
} else if (process.env.NODE_ENV === 'development' && !cert) {
protocol = 'http:'
} else { } else {
// we only support HTTPS over clearnet // we only support HTTPS over clearnet
agent = new https.Agent(httpsAgentOptions) agent = new https.Agent(httpsAgentOptions)

View File

@ -67,7 +67,6 @@ export const OLD_ITEM_DAYS = 3
export const ANON_FEE_MULTIPLIER = 100 export const ANON_FEE_MULTIPLIER = 100
export const SSR = typeof window === 'undefined' export const SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5 export const MAX_FORWARDS = 5
export const LND_PATHFINDING_TIMEOUT_MS = 30000
export const LNURLP_COMMENT_MAX_LENGTH = 1000 export const LNURLP_COMMENT_MAX_LENGTH = 1000
export const RESERVED_MAX_USER_ID = 615 export const RESERVED_MAX_USER_ID = 615
export const GLOBAL_SEED = USER_ID.k00b export const GLOBAL_SEED = USER_ID.k00b

View File

@ -86,7 +86,7 @@ export const HEX_REGEX = /^[0-9a-fA-F]+$/
export const ensureB64 = hexOrB64Url => { export const ensureB64 = hexOrB64Url => {
if (HEX_REGEX.test(hexOrB64Url)) { if (HEX_REGEX.test(hexOrB64Url)) {
hexOrB64Url = hexToB64(hexOrB64Url) return hexToB64(hexOrB64Url)
} }
hexOrB64Url = ensureB64Padding(hexOrB64Url) hexOrB64Url = ensureB64Padding(hexOrB64Url)

View File

@ -21,9 +21,9 @@ const { NAME_QUERY } = usersFragments
export async function ssValidate (schema, data, args) { export async function ssValidate (schema, data, args) {
try { try {
if (typeof schema === 'function') { if (typeof schema === 'function') {
return await schema(args).validate(data) await schema(args).validate(data)
} else { } else {
return await schema.validate(data) await schema.validate(data)
} }
} catch (e) { } catch (e) {
if (e instanceof ValidationError) { if (e instanceof ValidationError) {
@ -34,19 +34,18 @@ export async function ssValidate (schema, data, args) {
} }
export async function formikValidate (validate, data) { export async function formikValidate (validate, data) {
const result = await validate(data) const errors = await validate(data)
if (Object.keys(result).length > 0) { if (Object.keys(errors).length > 0) {
const [key, message] = Object.entries(result)[0] const [key, message] = Object.entries(errors)[0]
throw new Error(`${key}: ${message}`) throw new Error(`${key}: ${message}`)
} }
return result
} }
export async function walletValidate (wallet, data) { export async function walletValidate (wallet, data) {
if (typeof wallet.fieldValidation === 'function') { if (typeof wallet.fieldValidation === 'function') {
return await formikValidate(wallet.fieldValidation, data) return await formikValidate(wallet.fieldValidation, data)
} else { } else {
return await ssValidate(wallet.fieldValidation, data) await ssValidate(wallet.fieldValidation, data)
} }
} }
@ -194,12 +193,6 @@ const hexOrBase64Validator = string().test({
return false return false
} }
} }
}).transform(val => {
try {
return ensureB64(val)
} catch {
return val
}
}) })
async function usernameExists (name, { client, models }) { async function usernameExists (name, { client, models }) {
@ -760,19 +753,3 @@ export const lud18PayerDataSchema = (k1) => object({
// check if something is _really_ a number. // check if something is _really_ a number.
// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity] // returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity]
export const isNumber = x => typeof x === 'number' && !Number.isNaN(x) export const isNumber = x => typeof x === 'number' && !Number.isNaN(x)
export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
if (typeof x === 'undefined') {
throw new Error('value is required')
}
const n = Number(x)
if (isNumber(n)) {
if (x < min || x > max) {
throw new Error(`value ${x} must be between ${min} and ${max}`)
}
return n
}
throw new Error(`value ${x} is not a number`)
}
export const toPositiveNumber = (x) => toNumber(x, 0)

64
package-lock.json generated
View File

@ -16,7 +16,7 @@
"@lightninglabs/lnc-web": "^0.3.1-alpha", "@lightninglabs/lnc-web": "^0.3.1-alpha",
"@noble/curves": "^1.2.0", "@noble/curves": "^1.2.0",
"@opensearch-project/opensearch": "^2.4.0", "@opensearch-project/opensearch": "^2.4.0",
"@prisma/client": "^5.17.0", "@prisma/client": "^5.14.0",
"@slack/web-api": "^6.9.0", "@slack/web-api": "^6.9.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^1.1.10", "@yudiel/react-qr-scanner": "^1.1.10",
@ -62,7 +62,7 @@
"page-metadata-parser": "^1.1.4", "page-metadata-parser": "^1.1.4",
"pg-boss": "^9.0.3", "pg-boss": "^9.0.3",
"piexifjs": "^1.0.6", "piexifjs": "^1.0.6",
"prisma": "^5.17.0", "prisma": "^5.16.1",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
@ -4092,9 +4092,9 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.17.0", "version": "5.14.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.17.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.14.0.tgz",
"integrity": "sha512-N2tnyKayT0Zf7mHjwEyE8iG7FwTmXDHFZ1GnNhQp0pJUObsuel4ZZ1XwfuAYkq5mRIiC/Kot0kt0tGCfLJ70Jw==", "integrity": "sha512-akMSuyvLKeoU4LeyBAUdThP/uhVP3GuLygFE3MlYzaCb3/J8SfsYBE5PkaFuLuVpLyA6sFoW+16z/aPhNAESqg==",
"hasInstallScript": true, "hasInstallScript": true,
"engines": { "engines": {
"node": ">=16.13" "node": ">=16.13"
@ -4109,43 +4109,43 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "5.17.0", "version": "5.16.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.17.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.16.1.tgz",
"integrity": "sha512-l7+AteR3P8FXiYyo496zkuoiJ5r9jLQEdUuxIxNCN1ud8rdbH3GTxm+f+dCyaSv9l9WY+29L9czaVRXz9mULfg==" "integrity": "sha512-JsNgZAg6BD9RInLSrg7ZYzo11N7cVvYArq3fHGSD89HSgtN0VDdjV6bib7YddbcO6snzjchTiLfjeTqBjtArVQ=="
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "5.17.0", "version": "5.16.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.17.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.16.1.tgz",
"integrity": "sha512-+r+Nf+JP210Jur+/X8SIPLtz+uW9YA4QO5IXA+KcSOBe/shT47bCcRMTYCbOESw3FFYFTwe7vU6KTWHKPiwvtg==", "integrity": "sha512-KkyF3eIUtBIyp5A/rJHCtwQO18OjpGgx18PzjyGcJDY/+vNgaVyuVd+TgwBgeq6NLdd1XMwRCI+58vinHsAdfA==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.17.0", "@prisma/debug": "5.16.1",
"@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", "@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"@prisma/fetch-engine": "5.17.0", "@prisma/fetch-engine": "5.16.1",
"@prisma/get-platform": "5.17.0" "@prisma/get-platform": "5.16.1"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", "version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303.tgz",
"integrity": "sha512-tUuxZZysZDcrk5oaNOdrBnnkoTtmNQPkzINFDjz7eG6vcs9AVDmA/F6K5Plsb2aQc/l5M2EnFqn3htng9FA4hg==" "integrity": "sha512-HkT2WbfmFZ9WUPyuJHhkiADxazHg8Y4gByrTSVeb3OikP6tjQ7txtSUGu9OBOBH0C13dPKN2qqH12xKtHu/Hiw=="
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "5.17.0", "version": "5.16.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.17.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.16.1.tgz",
"integrity": "sha512-ESxiOaHuC488ilLPnrv/tM2KrPhQB5TRris/IeIV4ZvUuKeaicCl4Xj/JCQeG9IlxqOgf1cCg5h5vAzlewN91Q==", "integrity": "sha512-oOkjaPU1lhcA/Rvr4GVfd1NLJBwExgNBE36Ueq7dr71kTMwy++a3U3oLd2ZwrV9dj9xoP6LjCcky799D9nEt4w==",
"dependencies": { "dependencies": {
"@prisma/debug": "5.17.0", "@prisma/debug": "5.16.1",
"@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", "@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"@prisma/get-platform": "5.17.0" "@prisma/get-platform": "5.16.1"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "5.17.0", "version": "5.16.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.17.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.16.1.tgz",
"integrity": "sha512-UlDgbRozCP1rfJ5Tlkf3Cnftb6srGrEQ4Nm3og+1Se2gWmCZ0hmPIi+tQikGDUVLlvOWx3Gyi9LzgRP+HTXV9w==", "integrity": "sha512-R4IKnWnMkR2nUAbU5gjrPehdQYUUd7RENFD2/D+xXTNhcqczp0N+WEGQ3ViyI3+6mtVcjjNIMdnUTNyu3GxIgA==",
"dependencies": { "dependencies": {
"@prisma/debug": "5.17.0" "@prisma/debug": "5.16.1"
} }
}, },
"node_modules/@protobufjs/aspromise": { "node_modules/@protobufjs/aspromise": {
@ -15813,12 +15813,12 @@
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "5.17.0", "version": "5.16.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.17.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.16.1.tgz",
"integrity": "sha512-m4UWkN5lBE6yevqeOxEvmepnL5cNPEjzMw2IqDB59AcEV6w7D8vGljDLd1gPFH+W6gUxw9x7/RmN5dCS/WTPxA==", "integrity": "sha512-Z1Uqodk44diztImxALgJJfNl2Uisl9xDRvqybMKEBYJLNKNhDfAHf+ZIJbZyYiBhLMbKU9cYGdDVG5IIXEnL2Q==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/engines": "5.17.0" "@prisma/engines": "5.16.1"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

View File

@ -21,7 +21,7 @@
"@lightninglabs/lnc-web": "^0.3.1-alpha", "@lightninglabs/lnc-web": "^0.3.1-alpha",
"@noble/curves": "^1.2.0", "@noble/curves": "^1.2.0",
"@opensearch-project/opensearch": "^2.4.0", "@opensearch-project/opensearch": "^2.4.0",
"@prisma/client": "^5.17.0", "@prisma/client": "^5.14.0",
"@slack/web-api": "^6.9.0", "@slack/web-api": "^6.9.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^1.1.10", "@yudiel/react-qr-scanner": "^1.1.10",
@ -67,7 +67,7 @@
"page-metadata-parser": "^1.1.4", "page-metadata-parser": "^1.1.4",
"pg-boss": "^9.0.3", "pg-boss": "^9.0.3",
"piexifjs": "^1.0.6", "piexifjs": "^1.0.6",
"prisma": "^5.17.0", "prisma": "^5.16.1",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",

View File

@ -139,7 +139,7 @@ function Detail ({ fact }) {
(fact.description && <span className='d-block'>{fact.description}</span>)} (fact.description && <span className='d-block'>{fact.description}</span>)}
<PayerData data={fact.invoicePayerData} className='text-muted' header /> <PayerData data={fact.invoicePayerData} className='text-muted' header />
{fact.invoiceComment && <small className='text-muted'><b>sender says:</b> {fact.invoiceComment}</small>} {fact.invoiceComment && <small className='text-muted'><b>sender says:</b> {fact.invoiceComment}</small>}
<Satus status={fact.status} />{fact.autoWithdraw && <Badge className={styles.badge} bg={null}>{fact.type === 'p2p' ? 'p2p' : 'autowithdraw'}</Badge>} <Satus status={fact.status} />{fact.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
</Link> </Link>
</div> </div>
) )

View File

@ -1,76 +0,0 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "InvoiceActionState" ADD VALUE 'FORWARDING';
ALTER TYPE "InvoiceActionState" ADD VALUE 'FORWARDED';
ALTER TYPE "InvoiceActionState" ADD VALUE 'FAILED_FORWARD';
ALTER TYPE "InvoiceActionState" ADD VALUE 'CANCELING';
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "actionOptimistic" BOOLEAN;
-- AlterTable
ALTER TABLE "Withdrawl" ADD COLUMN "preimage" TEXT;
-- CreateTable
CREATE TABLE "InvoiceForward" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"bolt11" TEXT NOT NULL,
"maxFeeMsats" INTEGER NOT NULL,
"walletId" INTEGER NOT NULL,
"expiryHeight" INTEGER,
"acceptHeight" INTEGER,
"invoiceId" INTEGER NOT NULL,
"withdrawlId" INTEGER,
CONSTRAINT "InvoiceForward_pkey" PRIMARY KEY ("id")
);
-- historically, optimistic actions were exclusively non-hold invoices
-- with invoice forwards, we can now have optimistic hold invoices
UPDATE "Invoice"
SET "actionOptimistic" = preimage IS NULL
WHERE "actionType" IS NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "InvoiceForward_invoiceId_key" ON "InvoiceForward"("invoiceId");
-- CreateIndex
CREATE INDEX "InvoiceForward_invoiceId_idx" ON "InvoiceForward"("invoiceId");
-- CreateIndex
CREATE INDEX "InvoiceForward_walletId_idx" ON "InvoiceForward"("walletId");
-- CreateIndex
CREATE INDEX "InvoiceForward_withdrawlId_idx" ON "InvoiceForward"("withdrawlId");
-- CreateIndex
CREATE INDEX "Invoice_isHeld_idx" ON "Invoice"("isHeld");
-- CreateIndex
CREATE INDEX "Invoice_confirmedAt_idx" ON "Invoice"("confirmedAt");
-- CreateIndex
CREATE INDEX "Withdrawl_walletId_idx" ON "Withdrawl"("walletId");
-- CreateIndex
CREATE INDEX "Withdrawl_autoWithdraw_idx" ON "Withdrawl"("autoWithdraw");
-- CreateIndex
CREATE INDEX "Withdrawl_status_idx" ON "Withdrawl"("status");
-- AddForeignKey
ALTER TABLE "InvoiceForward" ADD CONSTRAINT "InvoiceForward_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InvoiceForward" ADD CONSTRAINT "InvoiceForward_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InvoiceForward" ADD CONSTRAINT "InvoiceForward_withdrawlId_fkey" FOREIGN KEY ("withdrawlId") REFERENCES "Withdrawl"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -121,7 +121,7 @@ model User {
Sub Sub[] Sub Sub[]
SubAct SubAct[] SubAct SubAct[]
MuteSub MuteSub[] MuteSub MuteSub[]
wallets Wallet[] Wallet Wallet[]
TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser") TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser")
TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser") TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser")
AncestorReplies Reply[] @relation("AncestorReplyUser") AncestorReplies Reply[] @relation("AncestorReplyUser")
@ -194,7 +194,6 @@ model Wallet {
walletCLN WalletCLN? walletCLN WalletCLN?
walletLNbits WalletLNbits? walletLNbits WalletLNbits?
withdrawals Withdrawl[] withdrawals Withdrawl[]
InvoiceForward InvoiceForward[]
@@index([userId]) @@index([userId])
} }
@ -792,11 +791,7 @@ enum InvoiceActionState {
HELD HELD
PAID PAID
FAILED FAILED
FORWARDING
FORWARDED
FAILED_FORWARD
RETRYING RETRYING
CANCELING
} }
model ItemMention { model ItemMention {
@ -833,11 +828,9 @@ model Invoice {
comment String? comment String?
lud18Data Json? lud18Data Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invoiceForward InvoiceForward?
actionState InvoiceActionState? actionState InvoiceActionState?
actionType InvoiceActionType? actionType InvoiceActionType?
actionOptimistic Boolean?
actionId Int? actionId Int?
actionArgs Json? @db.JsonB actionArgs Json? @db.JsonB
actionError String? actionError String?
@ -851,35 +844,8 @@ model Invoice {
@@index([createdAt], map: "Invoice.created_at_index") @@index([createdAt], map: "Invoice.created_at_index")
@@index([userId], map: "Invoice.userId_index") @@index([userId], map: "Invoice.userId_index")
@@index([confirmedIndex], map: "Invoice.confirmedIndex_index") @@index([confirmedIndex], map: "Invoice.confirmedIndex_index")
@@index([isHeld])
@@index([confirmedAt])
@@index([actionType])
@@index([actionState]) @@index([actionState])
} @@index([actionType])
model InvoiceForward {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
bolt11 String
maxFeeMsats Int
walletId Int
// we get these values when the invoice is held
expiryHeight Int?
acceptHeight Int?
// we get these values when the outgoing invoice is settled
invoiceId Int @unique
withdrawlId Int?
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull)
@@index([invoiceId])
@@index([walletId])
@@index([withdrawlId])
} }
model Withdrawl { model Withdrawl {
@ -888,7 +854,6 @@ model Withdrawl {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int userId Int
hash String? hash String?
preimage String?
bolt11 String? bolt11 String?
msatsPaying BigInt msatsPaying BigInt
msatsPaid BigInt? msatsPaid BigInt?
@ -899,14 +864,10 @@ model Withdrawl {
walletId Int? walletId Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull)
invoiceForward InvoiceForward[]
@@index([createdAt], map: "Withdrawl.created_at_index") @@index([createdAt], map: "Withdrawl.created_at_index")
@@index([userId], map: "Withdrawl.userId_index") @@index([userId], map: "Withdrawl.userId_index")
@@index([hash]) @@index([hash])
@@index([walletId])
@@index([autoWithdraw])
@@index([status])
} }
model Account { model Account {

4
sndev
View File

@ -2,9 +2,9 @@
set -e set -e
set -a # automatically export all variables set -a # automatically export all variables
. ./.env.development . .env.development
if [ -f .env.local ]; then if [ -f .env.local ]; then
. ./.env.local . .env.local
fi fi
docker__compose() { docker__compose() {

View File

@ -1,24 +1,40 @@
import { ensureB64 } from '@/lib/format'
import { createInvoice as clnCreateInvoice } from '@/lib/cln' import { createInvoice as clnCreateInvoice } from '@/lib/cln'
import { addWalletLog } from '@/api/resolvers/wallet'
export * from 'wallets/cln' export * from 'wallets/cln'
export const testConnectServer = async ({ socket, rune, cert }) => { export const testConnectServer = async (
return await createInvoice({ msats: 1, expiry: 1, description: '' }, { socket, rune, cert }) { socket, rune, cert },
} { me, models }
export const createInvoice = async (
{ msats, description, descriptionHash, expiry },
{ socket, rune, cert }
) => { ) => {
cert = ensureB64(cert)
const inv = await clnCreateInvoice({ const inv = await clnCreateInvoice({
socket, socket,
rune, rune,
cert, cert,
description, description: 'SN connection test',
descriptionHash, msats: 'any',
msats, expiry: 0
expiry
}) })
await addWalletLog({ wallet: { type: 'CLN' }, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
return inv
}
export const createInvoice = async (
{ amount },
{ socket, rune, cert },
{ me, models, lnd }
) => {
cert = ensureB64(cert)
const inv = await clnCreateInvoice({
socket,
rune,
cert,
description: me.hideInvoiceDesc ? undefined : 'autowithdraw to CLN from SN',
msats: amount + 'sat',
expiry: 360
})
return inv.bolt11 return inv.bolt11
} }

View File

@ -1,28 +1,28 @@
import { addWalletLog, fetchLnAddrInvoice } from '@/api/resolvers/wallet'
import { lnAddrOptions } from '@/lib/lnurl' import { lnAddrOptions } from '@/lib/lnurl'
export * from 'wallets/lightning-address' export * from 'wallets/lightning-address'
export const testConnectServer = async ({ address }) => { export const testConnectServer = async (
return await createInvoice({ msats: 1000 }, { address }) { address },
{ me, models }
) => {
const options = await lnAddrOptions(address)
await addWalletLog({ wallet: { type: 'LIGHTNING_ADDRESS' }, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
return options
} }
export const createInvoice = async ( export const createInvoice = async (
{ msats, description }, { amount, maxFee },
{ address } { address },
{ me, models, lnd, lnService }
) => { ) => {
const { callback, commentAllowed } = await lnAddrOptions(address) const res = await fetchLnAddrInvoice({ addr: address, amount, maxFee }, {
const callbackUrl = new URL(callback) me,
callbackUrl.searchParams.append('amount', msats) models,
lnd,
if (commentAllowed >= description?.length) { lnService,
callbackUrl.searchParams.append('comment', description) autoWithdraw: true
} })
// call callback with amount and conditionally comment
const res = await (await fetch(callbackUrl.toString())).json()
if (res.status === 'ERROR') {
throw new Error(res.reason)
}
return res.pr return res.pr
} }

View File

@ -1,12 +1,6 @@
export * from 'wallets/lnbits' export * from 'wallets/lnbits'
export async function testConnectServer ({ url, invoiceKey }) { async function _createInvoice ({ url, invoiceKey, amount, expiry }, { me }) {
return await createInvoice({ msats: 1, expiry: 1 }, { url, invoiceKey })
}
export async function createInvoice (
{ msats, description, descriptionHash, expiry },
{ url, invoiceKey }) {
const path = '/api/v1/payments' const path = '/api/v1/payments'
const headers = new Headers() const headers = new Headers()
@ -14,13 +8,8 @@ export async function createInvoice (
headers.append('Content-Type', 'application/json') headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', invoiceKey) headers.append('X-Api-Key', invoiceKey)
const body = JSON.stringify({ const memo = me.hideInvoiceDesc ? undefined : 'autowithdraw to LNbits from SN'
amount: msats, const body = JSON.stringify({ amount, unit: 'sat', expiry, memo, out: false })
unit: 'msat',
expiry,
memo: description,
out: false
})
const res = await fetch(url + path, { method: 'POST', headers, body }) const res = await fetch(url + path, { method: 'POST', headers, body })
if (!res.ok) { if (!res.ok) {
@ -31,3 +20,11 @@ export async function createInvoice (
const payment = await res.json() const payment = await res.json()
return payment.payment_request return payment.payment_request
} }
export async function testConnectServer ({ url, invoiceKey }, { me }) {
return await _createInvoice({ url, invoiceKey, amount: 1, expiry: 1 }, { me })
}
export async function createInvoice ({ amount, maxFee }, { url, invoiceKey }, { me }) {
return await _createInvoice({ url, invoiceKey, amount, expiry: 360 }, { me })
}

View File

@ -1,15 +1,46 @@
import { ensureB64 } from '@/lib/format'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-service' import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-service'
import { addWalletLog } from '@/api/resolvers/wallet'
export * from 'wallets/lnd' export * from 'wallets/lnd'
export const testConnectServer = async ({ cert, macaroon, socket }) => { export const testConnectServer = async (
return await createInvoice({ msats: 1, expiry: 1 }, { cert, macaroon, socket }) { cert, macaroon, socket },
{ me, models }
) => {
try {
cert = ensureB64(cert)
macaroon = ensureB64(macaroon)
const { lnd } = await authenticatedLndGrpc({
cert,
macaroon,
socket
})
const inv = await lndCreateInvoice({
description: 'SN connection test',
lnd,
tokens: 0,
expires_at: new Date()
})
// we wrap both calls in one try/catch since connection attempts happen on RPC calls
await addWalletLog({ wallet: { type: 'LND' }, level: 'SUCCESS', message: 'connected to LND' }, { me, models })
return inv
} catch (err) {
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
const details = err[2]?.err?.details || err.message || err.toString?.()
throw new Error(details)
}
} }
export const createInvoice = async ( export const createInvoice = async (
{ msats, description, descriptionHash, expiry }, { amount },
{ cert, macaroon, socket } { cert, macaroon, socket },
{ me }
) => { ) => {
const { lnd } = await authenticatedLndGrpc({ const { lnd } = await authenticatedLndGrpc({
cert, cert,
@ -18,11 +49,10 @@ export const createInvoice = async (
}) })
const invoice = await lndCreateInvoice({ const invoice = await lndCreateInvoice({
description: me.hideInvoiceDesc ? undefined : 'autowithdraw to LND from SN',
lnd, lnd,
description, tokens: amount,
description_hash: descriptionHash, expires_at: datePivot(new Date(), { seconds: 360 })
mtokens: String(msats),
expires_at: datePivot(new Date(), { seconds: expiry })
}) })
return invoice.request return invoice.request

View File

@ -13,7 +13,7 @@ export const fields = [
export const card = { export const card = {
title: 'NWC', title: 'NWC',
subtitle: 'use Nostr Wallet Connect for payments', subtitle: 'use Nostr Wallet Connect for payments',
badges: ['send only', 'budgetable'] badges: ['send only']
} }
export const fieldValidation = nwcSchema export const fieldValidation = nwcSchema

View File

@ -2,75 +2,5 @@ import * as lnd from 'wallets/lnd/server'
import * as cln from 'wallets/cln/server' import * as cln from 'wallets/cln/server'
import * as lnAddr from 'wallets/lightning-address/server' import * as lnAddr from 'wallets/lightning-address/server'
import * as lnbits from 'wallets/lnbits/server' import * as lnbits from 'wallets/lnbits/server'
import { addWalletLog } from '@/api/resolvers/wallet'
import walletDefs from 'wallets/server'
import { parsePaymentRequest } from 'ln-service'
import { toPositiveNumber } from '@/lib/validate'
export default [lnd, cln, lnAddr, lnbits] export default [lnd, cln, lnAddr, lnbits]
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
// get the wallets in order of priority
const wallets = await models.wallet.findMany({
where: { userId, enabled: true },
include: {
user: true
},
orderBy: [
{ priority: 'asc' },
// use id as tie breaker (older wallet first)
{ id: 'asc' }
]
})
msats = toPositiveNumber(msats)
for (const wallet of wallets) {
const w = walletDefs.find(w => w.walletType === wallet.type)
try {
const { walletType, walletField, createInvoice } = w
const walletFull = await models.wallet.findFirst({
where: {
userId,
type: walletType
},
include: {
[walletField]: true
}
})
if (!walletFull || !walletFull[walletField]) {
throw new Error(`no ${walletType} wallet found`)
}
const invoice = await createInvoice({
msats,
description: wallet.user.hideInvoiceDesc ? undefined : description,
descriptionHash,
expiry
}, walletFull[walletField])
const bolt11 = await parsePaymentRequest({ request: invoice })
if (BigInt(bolt11.mtokens) !== BigInt(msats)) {
throw new Error('invoice has incorrect amount')
}
return { invoice, wallet }
} catch (error) {
console.error(error)
// TODO: I think this is a bug, `createInvoice` should parse the error
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
const details = error[2]?.err?.details || error.message || error.toString?.()
await addWalletLog({
wallet,
level: 'ERROR',
message: `creating invoice for ${description ?? ''} failed: ` + details
}, { models })
}
}
throw new Error('no wallet available')
}

View File

@ -1,164 +0,0 @@
import { createHodlInvoice, getHeight, parsePaymentRequest } from 'ln-service'
import { estimateRouteFee } from '../api/lnd'
import { toPositiveNumber } from '@/lib/validate'
const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice
const MAX_OUTGOING_MSATS = BigInt(9_000_000_000) // the maximum msats we'll allow for the outgoing invoice
const MAX_EXPIRATION_INCOMING_MSECS = 900_000 // the maximum expiration time we'll allow for the incoming invoice
const INCOMING_EXPIRATION_BUFFER_MSECS = 300_000 // the buffer enforce for the incoming invoice expiration
const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for the outgoing invoice
export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
const MAX_FEE_ESTIMATE_PERCENT = 0.025 // the maximum fee relative to outgoing we'll allow for the fee estimate
const ZAP_SYBIL_FEE_MULT = 10 / 9 // the fee for the zap sybil service
/*
The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice.
@param bolt11 {string} the bolt11 invoice to wrap
@param options {object}
@returns {
invoice: the wrapped incoming invoice,
outgoingMaxFeeMsat: number
}
*/
export default async function wrapInvoice (bolt11, { description, descriptionHash }, { lnd }) {
try {
console.group('wrapInvoice', description)
// create a new object to hold the wrapped invoice values
const wrapped = {}
let outgoingMsat
// decode the invoice
const inv = await parsePaymentRequest({ request: bolt11 })
if (!inv) {
throw new Error('Unable to decode invoice')
}
console.log('invoice', inv.mtokens, inv.expires_at, inv.cltv_delta)
// validate amount
if (inv.mtokens) {
outgoingMsat = toPositiveNumber(inv.mtokens)
if (outgoingMsat < MIN_OUTGOING_MSATS) {
throw new Error(`Invoice amount is too low: ${outgoingMsat}`)
}
if (inv.mtokens > MAX_OUTGOING_MSATS) {
throw new Error(`Invoice amount is too high: ${outgoingMsat}`)
}
} else {
throw new Error('Invoice amount is missing')
}
// validate features
if (inv.features) {
for (const f of inv.features) {
switch (Number(f.bit)) {
// supported features
case 8: // variable length routing onion
case 9:
case 14: // payment secret
case 15:
case 16: // basic multi-part payment
case 17:
case 25: // blinded paths
case 48: // TLV payment data
case 49:
case 149: // trampoline routing
case 151: // electrum trampoline routing
break
default:
throw new Error(`Unsupported feature bit: ${f.bit}`)
}
}
} else {
throw new Error('Invoice features are missing')
}
// validate the payment hash
if (inv.id) {
wrapped.id = inv.id
} else {
throw new Error('Invoice hash is missing')
}
// validate the description
if (description && descriptionHash) {
throw new Error('Only one of description or descriptionHash is allowed')
} else if (description) {
// use our wrapped description
wrapped.description = description
} else if (descriptionHash) {
// use our wrapped description hash
wrapped.description_hash = descriptionHash
} else if (inv.description_hash) {
// use the invoice description hash
wrapped.description_hash = inv.description_hash
} else {
// use the invoice description
wrapped.description = inv.description
}
// validate the expiration
if (new Date(inv.expires_at) < new Date(Date.now() + INCOMING_EXPIRATION_BUFFER_MSECS)) {
throw new Error('Invoice expiration is too soon')
} else if (new Date(inv.expires_at) > new Date(Date.now() + MAX_EXPIRATION_INCOMING_MSECS)) {
// trim the expiration to the maximum allowed with a buffer
wrapped.expires_at = new Date(Date.now() + MAX_EXPIRATION_INCOMING_MSECS - INCOMING_EXPIRATION_BUFFER_MSECS)
} else {
// give the existing expiration a buffer
wrapped.expires_at = new Date(new Date(inv.expires_at).getTime() - INCOMING_EXPIRATION_BUFFER_MSECS)
}
// get routing estimates
const { routingFeeMsat, timeLockDelay } =
await estimateRouteFee({
lnd,
destination: inv.destination,
mtokens: inv.mtokens,
request: bolt11,
timeout: FEE_ESTIMATE_TIMEOUT_SECS
})
const { current_block_height: blockHeight } = await getHeight({ lnd })
/*
we want the incoming invoice to have MIN_SETTLEMENT_CLTV_DELTA higher final cltv delta than
the expected ctlv_delta of the outgoing invoice's entire route
timeLockDelay is the absolute height the outgoing route is estimated to expire in the worst case.
It excludes the final hop's cltv_delta, so we add it. We subtract the blockheight,
then add on how many blocks we want to reserve to settle the incoming payment,
assuming the outgoing payment settles at the worst case (ie largest) height.
*/
wrapped.cltv_delta = toPositiveNumber(
toPositiveNumber(timeLockDelay) + toPositiveNumber(inv.cltv_delta) -
toPositiveNumber(blockHeight) + MIN_SETTLEMENT_CLTV_DELTA)
console.log('routingFeeMsat', routingFeeMsat, 'timeLockDelay', timeLockDelay, 'blockHeight', blockHeight)
// validate the cltv delta
if (wrapped.cltv_delta > MAX_OUTGOING_CLTV_DELTA) {
throw new Error('Estimated outgoing cltv delta is too high: ' + wrapped.cltv_delta)
} else if (wrapped.cltv_delta < MIN_SETTLEMENT_CLTV_DELTA + toPositiveNumber(inv.cltv_delta)) {
throw new Error('Estimated outgoing cltv delta is too low: ' + wrapped.cltv_delta)
}
// validate the fee budget
const minEstFees = toPositiveNumber(routingFeeMsat)
const outgoingMaxFeeMsat = Math.ceil(outgoingMsat * MAX_FEE_ESTIMATE_PERCENT)
if (minEstFees > outgoingMaxFeeMsat) {
throw new Error('Estimated fees are too high')
}
// calculate the incoming invoice amount, without fees
wrapped.mtokens = String(Math.ceil(outgoingMsat * ZAP_SYBIL_FEE_MULT))
console.log('outgoingMaxFeeMsat', outgoingMaxFeeMsat, 'wrapped', wrapped)
return {
invoice: await createHodlInvoice({ lnd, ...wrapped }),
maxFee: outgoingMaxFeeMsat
}
} finally {
console.groupEnd()
}
}

View File

@ -1,6 +1,6 @@
import { msatsToSats, satsToMsats } from '@/lib/format' import { msatsToSats, satsToMsats } from '@/lib/format'
import { createWithdrawal } from '@/api/resolvers/wallet' import { createWithdrawal, addWalletLog } from '@/api/resolvers/wallet'
import { createInvoice } from 'wallets/server' import walletDefs from 'wallets/server'
export async function autoWithdraw ({ data: { id }, models, lnd }) { export async function autoWithdraw ({ data: { id }, models, lnd }) {
const user = await models.user.findUnique({ where: { id } }) const user = await models.user.findUnique({ where: { id } })
@ -12,14 +12,11 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
// excess must be greater than 10% of threshold // excess must be greater than 10% of threshold
if (excess < Number(threshold) * 0.1) return if (excess < Number(threshold) * 0.1) return
const maxFeeMsats = Math.ceil(excess * (user.autoWithdrawMaxFeePercent / 100.0)) const maxFee = msatsToSats(Math.ceil(excess * (user.autoWithdrawMaxFeePercent / 100.0)))
const msats = excess - maxFeeMsats const amount = msatsToSats(excess) - maxFee
// must be >= 1 sat // must be >= 1 sat
if (msats < 1000) return if (amount < 1) return
// maxFee is expected to be in sats, ie "msatsFeePaying" is always divisible by 1000
const maxFee = msatsToSats(maxFeeMsats)
// check that // check that
// 1. the user doesn't have an autowithdraw pending // 1. the user doesn't have an autowithdraw pending
@ -39,8 +36,73 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
if (pendingOrFailed.exists) return if (pendingOrFailed.exists) return
const { invoice, wallet } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models }) // get the wallets in order of priority
return await createWithdrawal(null, const wallets = await models.wallet.findMany({
{ invoice, maxFee }, where: { userId: user.id, enabled: true },
{ me: { id }, models, lnd, walletId: wallet.id }) orderBy: [
{ priority: 'asc' },
// use id as tie breaker (older wallet first)
{ id: 'asc' }
]
})
for (const wallet of wallets) {
const w = walletDefs.find(w => w.walletType === wallet.type)
try {
const { walletType, walletField, createInvoice } = w
return await autowithdraw(
{ walletType, walletField, createInvoice },
{ amount, maxFee },
{ me: user, models, lnd }
)
} catch (error) {
console.error(error)
// TODO: I think this is a bug, `walletCreateInvoice` in `autowithdraw` should parse the error
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
const details = error[2]?.err?.details || error.message || error.toString?.()
await addWalletLog({
wallet,
level: 'ERROR',
message: 'autowithdrawal failed: ' + details
}, { me: user, models })
}
}
// none of the wallets worked
}
async function autowithdraw (
{ walletType, walletField, createInvoice: walletCreateInvoice },
{ amount, maxFee },
{ me, models, lnd }) {
if (!me) {
throw new Error('me not specified')
}
const wallet = await models.wallet.findFirst({
where: {
userId: me.id,
type: walletType
},
include: {
[walletField]: true
}
})
if (!wallet || !wallet[walletField]) {
throw new Error(`no ${walletType} wallet found`)
}
const bolt11 = await walletCreateInvoice(
{ amount, maxFee },
wallet[walletField],
{
me,
models,
lnd
})
return await createWithdrawal(null, { invoice: bolt11, maxFee }, { me, models, lnd, walletId: wallet.id })
} }

View File

@ -3,7 +3,6 @@ import nextEnv from '@next/env'
import createPrisma from '@/lib/create-prisma.js' import createPrisma from '@/lib/create-prisma.js'
import { import {
autoDropBolt11s, checkInvoice, checkPendingDeposits, checkPendingWithdrawals, autoDropBolt11s, checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
checkWithdrawal,
finalizeHodlInvoice, subscribeToWallet finalizeHodlInvoice, subscribeToWallet
} from './wallet.js' } from './wallet.js'
import { repin } from './repin.js' import { repin } from './repin.js'
@ -26,11 +25,7 @@ import { ofac } from './ofac.js'
import { autoWithdraw } from './autowithdraw.js' import { autoWithdraw } from './autowithdraw.js'
import { saltAndHashEmails } from './saltAndHashEmails.js' import { saltAndHashEmails } from './saltAndHashEmails.js'
import { remindUser } from './reminder.js' import { remindUser } from './reminder.js'
import { import { holdAction, settleAction, settleActionError } from './paidAction.js'
paidActionPaid, paidActionForwarding, paidActionForwarded,
paidActionFailedForward, paidActionHeld, paidActionFailed,
paidActionCanceling
} from './paidAction.js'
import { thisDay } from './thisDay.js' import { thisDay } from './thisDay.js'
import { isServiceEnabled } from '@/lib/sndev.js' import { isServiceEnabled } from '@/lib/sndev.js'
@ -95,20 +90,10 @@ async function work () {
await boss.work('checkPendingWithdrawals', jobWrapper(checkPendingWithdrawals)) await boss.work('checkPendingWithdrawals', jobWrapper(checkPendingWithdrawals))
await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s)) await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s))
await boss.work('autoWithdraw', jobWrapper(autoWithdraw)) await boss.work('autoWithdraw', jobWrapper(autoWithdraw))
await boss.work('settleActionError', jobWrapper(settleActionError))
await boss.work('settleAction', jobWrapper(settleAction))
await boss.work('checkInvoice', jobWrapper(checkInvoice)) await boss.work('checkInvoice', jobWrapper(checkInvoice))
await boss.work('checkWithdrawal', jobWrapper(checkWithdrawal)) await boss.work('holdAction', jobWrapper(holdAction))
// paidAction jobs
await boss.work('paidActionForwarding', jobWrapper(paidActionForwarding))
await boss.work('paidActionForwarded', jobWrapper(paidActionForwarded))
await boss.work('paidActionFailedForward', jobWrapper(paidActionFailedForward))
await boss.work('paidActionHeld', jobWrapper(paidActionHeld))
await boss.work('paidActionCanceling', jobWrapper(paidActionCanceling))
await boss.work('paidActionFailed', jobWrapper(paidActionFailed))
await boss.work('paidActionPaid', jobWrapper(paidActionPaid))
// we renamed these jobs so we leave them so they can "migrate"
await boss.work('holdAction', jobWrapper(paidActionHeld))
await boss.work('settleActionError', jobWrapper(paidActionFailed))
await boss.work('settleAction', jobWrapper(paidActionPaid))
} }
if (isServiceEnabled('search')) { if (isServiceEnabled('search')) {
await boss.work('indexItem', jobWrapper(indexItem)) await boss.work('indexItem', jobWrapper(indexItem))

View File

@ -1,52 +1,31 @@
import { getPaymentFailureStatus, hodlInvoiceCltvDetails } from '@/api/lnd'
import { paidActions } from '@/api/paidAction' import { paidActions } from '@/api/paidAction'
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { toPositiveNumber } from '@/lib/validate'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { import { getInvoice, settleHodlInvoice } from 'ln-service'
cancelHodlInvoice,
getInvoice, getPayment, parsePaymentRequest,
payViaPaymentRequest, settleHodlInvoice
} from 'ln-service'
import { MIN_SETTLEMENT_CLTV_DELTA } from 'wallets/wrap'
// aggressive finalization retry options async function transitionInvoice (jobName, { invoiceId, fromState, toState, toData, onTransition }, { models, lnd, boss }) {
const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 }
async function transitionInvoice (jobName, { invoiceId, fromState, toState, transition }, { models, lnd, boss }) {
console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`) console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`)
let dbInvoice
try { try {
const currentDbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } }) dbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
console.log('invoice is in state', currentDbInvoice.actionState) console.log('invoice is in state', dbInvoice.actionState)
if (['FAILED', 'PAID', 'RETRYING'].includes(currentDbInvoice.actionState)) { if (['FAILED', 'PAID'].includes(dbInvoice.actionState)) {
console.log('invoice is already in a terminal state, skipping transition') console.log('invoice is already in a terminal state, skipping transition')
return return
} }
const lndInvoice = await getInvoice({ id: dbInvoice.hash, lnd })
const data = toData(lndInvoice)
if (!Array.isArray(fromState)) { if (!Array.isArray(fromState)) {
fromState = [fromState] fromState = [fromState]
} }
const lndInvoice = await getInvoice({ id: currentDbInvoice.hash, lnd }) await models.$transaction(async tx => {
dbInvoice = await tx.invoice.update({
const transitionedInvoice = await models.$transaction(async tx => { include: { user: true },
const include = {
user: true,
invoiceForward: {
include: {
invoice: true,
withdrawl: true,
wallet: true
}
}
}
// grab optimistic concurrency lock and the invoice
const dbInvoice = await tx.invoice.update({
include,
where: { where: {
id: invoiceId, id: invoiceId,
actionState: { actionState: {
@ -54,26 +33,18 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
} }
}, },
data: { data: {
actionState: toState actionState: toState,
...data
} }
}) })
// our own optimistic concurrency check // our own optimistic concurrency check
if (!dbInvoice) { if (!dbInvoice) {
console.log('record not found in our own concurrency check, assuming concurrent worker transitioned it') console.log('record not found, assuming concurrent worker transitioned it')
return return
} }
const data = await transition({ lndInvoice, dbInvoice, tx }) await onTransition({ lndInvoice, dbInvoice, tx })
if (data) {
return await tx.invoice.update({
include,
where: { id: dbInvoice.id },
data
})
}
return dbInvoice
}, { }, {
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
// we only need to do this because we settleHodlInvoice inside the transaction // we only need to do this because we settleHodlInvoice inside the transaction
@ -81,10 +52,7 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
timeout: 60000 timeout: 60000
}) })
if (transitionedInvoice) {
console.log('transition succeeded') console.log('transition succeeded')
return transitionedInvoice
}
} catch (e) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') { if (e.code === 'P2025') {
@ -98,20 +66,67 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
} }
console.error('unexpected error', e) console.error('unexpected error', e)
await boss.send( boss.send(
jobName, jobName,
{ invoiceId }, { invoiceId },
{ startAfter: datePivot(new Date(), { seconds: 30 }), priority: 1000 }) { startAfter: datePivot(new Date(), { minutes: 1 }), priority: 1000 })
} finally { } finally {
console.groupEnd() console.groupEnd()
} }
} }
async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) { export async function settleAction ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('settleAction', {
invoiceId,
fromState: ['HELD', 'PENDING'],
toState: 'PAID',
toData: invoice => {
if (!invoice.is_confirmed) {
throw new Error('invoice is not confirmed')
}
return {
confirmedAt: new Date(invoice.confirmed_at),
confirmedIndex: invoice.confirmed_index,
msatsReceived: BigInt(invoice.received_mtokens)
}
},
onTransition: async ({ dbInvoice, tx }) => {
await paidActions[dbInvoice.actionType].onPaid?.({ invoice: dbInvoice }, { models, tx, lnd })
await tx.$executeRaw`INSERT INTO pgboss.job (name, data) VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}))`
}
}, { models, lnd, boss })
}
export async function holdAction ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('holdAction', {
invoiceId,
fromState: 'PENDING_HELD',
toState: 'HELD',
toData: invoice => {
// 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 (!(invoice.is_held || invoice.is_confirmed)) {
throw new Error('invoice is not held')
}
return {
isHeld: true,
msatsReceived: BigInt(invoice.received_mtokens)
}
},
onTransition: async ({ dbInvoice, tx }) => {
// make sure settled or cancelled in 60 seconds to minimize risk of force closures
const expiresAt = new Date(Math.min(dbInvoice.expiresAt, datePivot(new Date(), { seconds: 60 })))
// do outside of transaction because we don't want this to rollback if the rest of the job fails
await models.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${dbInvoice.hash}), 21, true, ${expiresAt})`
// perform the action now that we have the funds
try { try {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id } const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const result = await paidActions[dbInvoice.actionType].perform(args, const result = await paidActions[dbInvoice.actionType].perform(args,
{ models, tx, lnd, cost: BigInt(lndInvoice.received_mtokens), me: dbInvoice.user }) { models, tx, lnd, cost: dbInvoice.msatsReceived, me: dbInvoice.user })
await tx.invoice.update({ await tx.invoice.update({
where: { id: dbInvoice.id }, where: { id: dbInvoice.id },
data: { data: {
@ -126,270 +141,33 @@ async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, ln
data: { data: {
actionError: e.message actionError: e.message
} }
}).catch(e => console.error('failed to store action error', e)) }).catch(e => console.error('failed to cancel invoice', e))
boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS) boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash })
.catch(e => console.error('failed to finalize', e))
throw e throw e
} }
}
export async function paidActionPaid ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('paidActionPaid', {
invoiceId,
fromState: ['HELD', 'PENDING', 'FORWARDED'],
toState: 'PAID',
transition: async ({ lndInvoice, dbInvoice, tx }) => {
if (!lndInvoice.is_confirmed) {
throw new Error('invoice is not confirmed')
}
await paidActions[dbInvoice.actionType].onPaid?.({ invoice: dbInvoice }, { models, tx, lnd })
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data)
VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}))`
return {
confirmedAt: new Date(lndInvoice.confirmed_at),
confirmedIndex: lndInvoice.confirmed_index,
msatsReceived: BigInt(lndInvoice.received_mtokens)
}
}
}, { models, lnd, boss })
}
// this performs forward creating the outgoing payment
export async function paidActionForwarding ({ data: { invoiceId }, models, lnd, boss }) {
const transitionedInvoice = await transitionInvoice('paidActionForwarding', {
invoiceId,
fromState: 'PENDING_HELD',
toState: 'FORWARDING',
transition: async ({ lndInvoice, dbInvoice, tx }) => {
if (!lndInvoice.is_held) {
throw new Error('invoice is not held')
}
const { invoiceForward } = dbInvoice
if (!invoiceForward) {
throw new Error('invoice is not associated with a forward')
}
const { expiryHeight, acceptHeight } = hodlInvoiceCltvDetails(lndInvoice)
const { bolt11, maxFeeMsats } = invoiceForward
const invoice = await parsePaymentRequest({ request: bolt11 })
// maxTimeoutDelta is the number of blocks left for the outgoing payment to settle
const maxTimeoutDelta = toPositiveNumber(expiryHeight) - toPositiveNumber(acceptHeight) - MIN_SETTLEMENT_CLTV_DELTA
if (maxTimeoutDelta - toPositiveNumber(invoice.cltv_delta) < 0) {
// the payment will certainly fail, so we can
// cancel and allow transition from PENDING[_HELD] -> FAILED
boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS)
.catch(e => console.error('failed to finalize', e))
throw new Error('invoice has insufficient cltv delta for forward')
}
// if this is a pessimistic action, we want to perform it now
// ... we don't want it to fail after the outgoing payment is in flight
if (!dbInvoice.actionOptimistic) {
await performPessimisticAction({ lndInvoice, dbInvoice, tx, models, lnd, boss })
}
return {
isHeld: true,
msatsReceived: BigInt(lndInvoice.received_mtokens),
invoiceForward: {
update: {
expiryHeight,
acceptHeight,
withdrawl: {
create: {
hash: invoice.id,
bolt11,
msatsPaying: BigInt(invoice.mtokens),
msatsFeePaying: maxFeeMsats,
autoWithdraw: true,
walletId: invoiceForward.walletId,
userId: invoiceForward.wallet.userId
}
}
}
}
}
}
}, { models, lnd, boss })
// only pay if we successfully transitioned which can only happen once
// we can't do this inside the transaction because it isn't necessarily idempotent
if (transitionedInvoice?.invoiceForward) {
const { bolt11, maxFeeMsats, expiryHeight, acceptHeight } = transitionedInvoice.invoiceForward
// 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', maxFeeMsats, 'max_timeout_height', maxTimeoutHeight,
'accept_height', acceptHeight, 'expiry_height', expiryHeight)
payViaPaymentRequest({
lnd,
request: bolt11,
max_fee_mtokens: String(maxFeeMsats),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS,
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 paidActionForwarded ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('paidActionForwarded', {
invoiceId,
fromState: 'FORWARDING',
toState: 'FORWARDED',
transition: async ({ lndInvoice, dbInvoice, tx }) => {
if (!(lndInvoice.is_held || lndInvoice.is_confirmed)) {
throw new Error('invoice is not held')
}
const { hash, msatsPaying } = dbInvoice.invoiceForward.withdrawl
const { payment, is_confirmed: isConfirmed } = await getPayment({ id: hash, lnd })
if (!isConfirmed) {
throw new Error('payment is not confirmed')
}
// settle the invoice, allowing us to transition to PAID
await settleHodlInvoice({ secret: payment.secret, lnd })
return {
invoiceForward: {
update: {
withdrawl: {
update: {
status: 'CONFIRMED',
msatsPaid: msatsPaying,
msatsFeePaid: BigInt(payment.fee_mtokens),
preimage: payment.secret
}
}
}
}
}
}
}, { models, lnd, boss })
}
// when the pending forward fails, we need to cancel the incoming invoice
export async function paidActionFailedForward ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('paidActionFailedForward', {
invoiceId,
fromState: 'FORWARDING',
toState: 'FAILED_FORWARD',
transition: async ({ lndInvoice, dbInvoice, tx }) => {
if (!(lndInvoice.is_held || lndInvoice.is_cancelled)) {
throw new Error('invoice is not held')
}
let withdrawal
let notSent = false
try {
withdrawal = await getPayment({ id: dbInvoice.invoiceForward.withdrawl.hash, lnd })
} catch (err) {
if (err[1] === 'SentPaymentNotFound' &&
dbInvoice.invoiceForward.withdrawl.createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) {
// if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it
notSent = true
} else {
throw err
}
}
if (!(withdrawal?.is_failed || notSent)) {
throw new Error('payment has not failed')
}
// cancel to transition to FAILED ... this is really important we do not transition unless this call succeeds
// which once it does succeed will ensure we will try to cancel the held invoice until it actually cancels
await boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS)
return {
invoiceForward: {
update: {
withdrawl: {
update: {
status: getPaymentFailureStatus(withdrawal)
}
}
}
}
}
}
}, { models, lnd, boss })
}
export async function paidActionHeld ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('paidActionHeld', {
invoiceId,
fromState: 'PENDING_HELD',
toState: 'HELD',
transition: async ({ lndInvoice, dbInvoice, tx }) => {
// 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 (!(lndInvoice.is_held || lndInvoice.is_confirmed)) {
throw new Error('invoice is not held')
}
if (dbInvoice.invoiceForward) {
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(dbInvoice.expiresAt, datePivot(new Date(), { seconds: 60 })))
boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, { startAfter: expiresAt, ...FINALIZE_OPTIONS })
.catch(e => console.error('failed to finalize', e))
// perform the action now that we have the funds
await performPessimisticAction({ lndInvoice, dbInvoice, tx, models, lnd, boss })
// settle the invoice, allowing us to transition to PAID // settle the invoice, allowing us to transition to PAID
await settleHodlInvoice({ secret: dbInvoice.preimage, lnd }) await settleHodlInvoice({ secret: dbInvoice.preimage, lnd })
return {
isHeld: true,
msatsReceived: BigInt(lndInvoice.received_mtokens)
}
} }
}, { models, lnd, boss }) }, { models, lnd, boss })
} }
export async function paidActionCanceling ({ data: { invoiceId }, models, lnd, boss }) { export async function settleActionError ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('paidActionCanceling', { return await transitionInvoice('settleActionError', {
invoiceId,
fromState: ['HELD', 'PENDING', 'PENDING_HELD', 'FAILED_FORWARD'],
toState: 'CANCELING',
transition: async ({ lndInvoice, dbInvoice, tx }) => {
if (lndInvoice.is_confirmed) {
throw new Error('invoice is confirmed already')
}
await cancelHodlInvoice({ id: dbInvoice.hash, lnd })
}
}, { models, lnd, boss })
}
export async function paidActionFailed ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('paidActionFailed', {
invoiceId, invoiceId,
// any of these states can transition to FAILED // any of these states can transition to FAILED
fromState: ['PENDING', 'PENDING_HELD', 'HELD', 'FAILED_FORWARD', 'CANCELING'], fromState: ['PENDING', 'PENDING_HELD', 'HELD'],
toState: 'FAILED', toState: 'FAILED',
transition: async ({ lndInvoice, dbInvoice, tx }) => { toData: invoice => {
if (!lndInvoice.is_canceled) { if (!invoice.is_canceled) {
throw new Error('invoice is not cancelled') throw new Error('invoice is not cancelled')
} }
await paidActions[dbInvoice.actionType].onFail?.({ invoice: dbInvoice }, { models, tx, lnd })
return { return {
cancelled: true cancelled: true
} }
},
onTransition: async ({ dbInvoice, tx }) => {
await paidActions[dbInvoice.actionType].onFail?.({ invoice: dbInvoice }, { models, tx, lnd })
} }
}, { models, lnd, boss }) }, { models, lnd, boss })
} }

View File

@ -4,17 +4,12 @@ import {
subscribeToInvoices, subscribeToPayments, subscribeToInvoice subscribeToInvoices, subscribeToPayments, subscribeToInvoice
} from 'ln-service' } from 'ln-service'
import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush' import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush'
import { INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
import { datePivot, sleep } from '@/lib/time.js' import { datePivot, sleep } from '@/lib/time.js'
import retry from 'async-retry' import retry from 'async-retry'
import { addWalletLog } from '@/api/resolvers/wallet' import { addWalletLog } from '@/api/resolvers/wallet'
import { msatsToSats, numWithUnits } from '@/lib/format' import { msatsToSats, numWithUnits } from '@/lib/format'
import { import { holdAction, settleAction, settleActionError } from './paidAction'
paidActionPaid, paidActionForwarded,
paidActionFailedForward, paidActionHeld, paidActionFailed,
paidActionForwarding,
paidActionCanceling
} from './paidAction.js'
export async function subscribeToWallet (args) { export async function subscribeToWallet (args) {
await subscribeToDeposits(args) await subscribeToDeposits(args)
@ -65,8 +60,8 @@ async function subscribeToDeposits (args) {
sub.on('invoice_updated', async (inv) => { sub.on('invoice_updated', async (inv) => {
try { try {
logEvent('invoice_updated', inv)
if (inv.secret) { if (inv.secret) {
logEvent('invoice_updated', inv)
await checkInvoice({ data: { hash: inv.id }, ...args }) await checkInvoice({ data: { hash: inv.id }, ...args })
} else { } else {
// this is a HODL invoice. We need to use SubscribeToInvoice which has is_held transitions // this is a HODL invoice. We need to use SubscribeToInvoice which has is_held transitions
@ -97,9 +92,10 @@ function subscribeToHodlInvoice (args) {
sub.on('invoice_updated', async (inv) => { sub.on('invoice_updated', async (inv) => {
logEvent('hodl_invoice_updated', inv) logEvent('hodl_invoice_updated', inv)
try { try {
// record the is_held transition
if (inv.is_held) {
await checkInvoice({ data: { hash: inv.id }, ...args }) await checkInvoice({ data: { hash: inv.id }, ...args })
// after settle or confirm we can stop listening for updates // after that we can stop listening for updates
if (inv.is_confirmed || inv.is_canceled) {
resolve() resolve()
} }
} catch (error) { } catch (error) {
@ -117,16 +113,7 @@ export async function checkInvoice ({ data: { hash }, boss, models, lnd }) {
// invoice could be created by LND but wasn't inserted into the database yet // invoice could be created by LND but wasn't inserted into the database yet
// this is expected and the function will be called again with the updates // this is expected and the function will be called again with the updates
const dbInv = await models.invoice.findUnique({ const dbInv = await models.invoice.findUnique({ where: { hash } })
where: { hash },
include: {
invoiceForward: {
include: {
withdrawl: true
}
}
}
})
if (!dbInv) { if (!dbInv) {
console.log('invoice not found in database', hash) console.log('invoice not found in database', hash)
return return
@ -134,7 +121,7 @@ export async function checkInvoice ({ data: { hash }, boss, models, lnd }) {
if (inv.is_confirmed) { if (inv.is_confirmed) {
if (dbInv.actionType) { if (dbInv.actionType) {
return await paidActionPaid({ data: { invoiceId: dbInv.id }, models, lnd, boss }) return await settleAction({ data: { invoiceId: dbInv.id }, models, lnd, boss })
} }
// NOTE: confirm invoice prevents double confirmations (idempotent) // NOTE: confirm invoice prevents double confirmations (idempotent)
@ -157,14 +144,7 @@ export async function checkInvoice ({ data: { hash }, boss, models, lnd }) {
if (inv.is_held) { if (inv.is_held) {
if (dbInv.actionType) { if (dbInv.actionType) {
if (dbInv.invoiceForward) { return await holdAction({ data: { invoiceId: dbInv.id }, models, lnd, boss })
if (dbInv.invoiceForward.withdrawl) {
// transitions when held are dependent on the withdrawl status
return await checkWithdrawal({ data: { hash: dbInv.invoiceForward.withdrawl.hash }, models, lnd, boss })
}
return await paidActionForwarding({ data: { invoiceId: dbInv.id }, models, lnd, boss })
}
return await paidActionHeld({ data: { invoiceId: dbInv.id }, models, lnd, boss })
} }
// First query makes sure that after payment, JIT invoices are settled // First query makes sure that after payment, JIT invoices are settled
// within 60 seconds or they will be canceled to minimize risk of // within 60 seconds or they will be canceled to minimize risk of
@ -190,7 +170,7 @@ export async function checkInvoice ({ data: { hash }, boss, models, lnd }) {
if (inv.is_canceled) { if (inv.is_canceled) {
if (dbInv.actionType) { if (dbInv.actionType) {
return await paidActionFailed({ data: { invoiceId: dbInv.id }, models, lnd, boss }) return await settleActionError({ data: { invoiceId: dbInv.id }, models, lnd, boss })
} }
return await serialize( return await serialize(
@ -238,21 +218,8 @@ async function subscribeToWithdrawals (args) {
await checkPendingWithdrawals(args) await checkPendingWithdrawals(args)
} }
export async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
const dbWdrwl = await models.withdrawl.findFirst({ const dbWdrwl = await models.withdrawl.findFirst({ where: { hash, status: null }, include: { wallet: true } })
where: {
hash
},
include: {
wallet: true,
invoiceForward: {
orderBy: { createdAt: 'desc' },
include: {
invoice: true
}
}
}
})
if (!dbWdrwl) { if (!dbWdrwl) {
// [WARNING] LND paid an invoice that wasn't created via the SN GraphQL API. // [WARNING] LND paid an invoice that wasn't created via the SN GraphQL API.
// >>> an adversary might be draining our funds right now <<< // >>> an adversary might be draining our funds right now <<<
@ -261,28 +228,20 @@ export async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
return return
} }
// already recorded and no invoiceForward to handle
if (dbWdrwl.status && dbWdrwl.invoiceForward.length === 0) return
let wdrwl let wdrwl
let notSent = false let notFound = false
try { try {
wdrwl = await getPayment({ id: hash, lnd }) wdrwl = await getPayment({ id: hash, lnd })
} catch (err) { } catch (err) {
if (err[1] === 'SentPaymentNotFound' && if (err[1] === 'SentPaymentNotFound') {
dbWdrwl.createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) { notFound = true
// if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it
notSent = true
} else { } else {
throw err console.error('error getting payment', err)
return
} }
} }
if (wdrwl?.is_confirmed) { if (wdrwl?.is_confirmed) {
if (dbWdrwl.invoiceForward.length > 0) {
return await paidActionForwarded({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id }, models, lnd, boss })
}
const fee = Number(wdrwl.payment.fee_mtokens) const fee = Number(wdrwl.payment.fee_mtokens)
const paid = Number(wdrwl.payment.mtokens) - fee const paid = Number(wdrwl.payment.mtokens) - fee
const [{ confirm_withdrawl: code }] = await serialize( const [{ confirm_withdrawl: code }] = await serialize(
@ -293,17 +252,11 @@ export async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
notifyWithdrawal(dbWdrwl.userId, wdrwl) notifyWithdrawal(dbWdrwl.userId, wdrwl)
if (dbWdrwl.wallet) { if (dbWdrwl.wallet) {
// this was an autowithdrawal // this was an autowithdrawal
const message = `autowithdrawal of ${ const message = `autowithdrawal of ${numWithUnits(msatsToSats(paid), { abbreviate: false })} with ${numWithUnits(msatsToSats(fee), { abbreviate: false })} as fee`
numWithUnits(msatsToSats(paid), { abbreviate: false })} with ${ await addWalletLog({ wallet: dbWdrwl.wallet, level: 'SUCCESS', message }, { models, me: { id: dbWdrwl.userId } })
numWithUnits(msatsToSats(fee), { abbreviate: false })} as fee`
await addWalletLog({ wallet: dbWdrwl.wallet, level: 'SUCCESS', message }, { models })
} }
} }
} else if (wdrwl?.is_failed || notSent) { } else if (wdrwl?.is_failed || notFound) {
if (dbWdrwl.invoiceForward.length > 0) {
return await paidActionFailedForward({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id }, models, lnd, boss })
}
let status = 'UNKNOWN_FAILURE'; let message = 'unknown failure' let status = 'UNKNOWN_FAILURE'; let message = 'unknown failure'
if (wdrwl?.failed.is_insufficient_balance) { if (wdrwl?.failed.is_insufficient_balance) {
status = 'INSUFFICIENT_BALANCE' status = 'INSUFFICIENT_BALANCE'
@ -331,7 +284,7 @@ export async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
wallet: dbWdrwl.wallet, wallet: dbWdrwl.wallet,
level: 'ERROR', level: 'ERROR',
message: 'autowithdrawal failed: ' + message message: 'autowithdrawal failed: ' + message
}, { models }) }, { models, me: { id: dbWdrwl.userId } })
} }
} }
} }
@ -347,7 +300,6 @@ export async function autoDropBolt11s ({ models, lnd }) {
WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s") WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s")
AND now() > created_at + interval '${retention}' AND now() > created_at + interval '${retention}'
AND hash IS NOT NULL AND hash IS NOT NULL
AND status IS NOT NULL
), updated_rows AS ( ), updated_rows AS (
UPDATE "Withdrawl" UPDATE "Withdrawl"
SET hash = NULL, bolt11 = NULL SET hash = NULL, bolt11 = NULL
@ -372,33 +324,14 @@ export async function autoDropBolt11s ({ models, lnd }) {
// The callback subscriptions above will NOT get called for JIT invoices that are already paid. // The callback subscriptions above will NOT get called for JIT invoices that are already paid.
// So we manually cancel the HODL invoice here if it wasn't settled by user action // So we manually cancel the HODL invoice here if it wasn't settled by user action
export async function finalizeHodlInvoice ({ data: { hash }, models, lnd, boss, ...args }) { export async function finalizeHodlInvoice ({ data: { hash }, models, lnd, ...args }) {
const inv = await getInvoice({ id: hash, lnd }) const inv = await getInvoice({ id: hash, lnd })
if (inv.is_confirmed) { if (inv.is_confirmed) {
return return
} }
const dbInv = await models.invoice.findUnique({
where: { hash },
include: {
invoiceForward: {
include: {
withdrawl: true
}
}
}
})
if (!dbInv) {
console.log('invoice not found in database', hash)
return
}
// if this is an actionType we need to cancel conditionally
if (dbInv.actionType) {
return await paidActionCanceling({ data: { invoiceId: dbInv.id }, models, lnd, boss })
}
await cancelHodlInvoice({ id: hash, lnd }) await cancelHodlInvoice({ id: hash, lnd })
// sync LND invoice status with invoice status in database // sync LND invoice status with invoice status in database
await checkInvoice({ data: { hash }, models, lnd, ...args }) await checkInvoice({ data: { hash }, models, lnd, ...args })
} }
@ -408,7 +341,7 @@ export async function checkPendingDeposits (args) {
const pendingDeposits = await models.invoice.findMany({ where: { confirmedAt: null, cancelled: false } }) const pendingDeposits = await models.invoice.findMany({ where: { confirmedAt: null, cancelled: false } })
for (const d of pendingDeposits) { for (const d of pendingDeposits) {
try { try {
await checkInvoice({ data: { hash: d.hash }, ...args }) await checkInvoice({ data: { id: d.id, hash: d.hash }, ...args })
await sleep(10) await sleep(10)
} catch { } catch {
console.error('error checking invoice', d.hash) console.error('error checking invoice', d.hash)
@ -421,10 +354,10 @@ export async function checkPendingWithdrawals (args) {
const pendingWithdrawals = await models.withdrawl.findMany({ where: { status: null } }) const pendingWithdrawals = await models.withdrawl.findMany({ where: { status: null } })
for (const w of pendingWithdrawals) { for (const w of pendingWithdrawals) {
try { try {
await checkWithdrawal({ data: { hash: w.hash }, ...args }) await checkWithdrawal({ data: { id: w.id, hash: w.hash }, ...args })
await sleep(10) await sleep(10)
} catch (err) { } catch {
console.error('error checking withdrawal', w.hash, err) console.error('error checking withdrawal', w.hash)
} }
} }
} }