Compare commits

...

6 Commits

Author SHA1 Message Date
k00b cb2efb0a7f fix sats filter for notifications 2024-08-13 16:27:22 -05:00
k00b 4d7e0a8296 fix settings mutation after greeterMode removal 2024-08-13 16:11:54 -05:00
k00b cfd63f4efb fix relative path env in sndev 2024-08-13 15:48:22 -05:00
k00b ac87322ac8 fix user/item popover 2024-08-13 15:30:43 -05:00
ekzyis 53465e3f46
Remove unnecessary me from addWalletLog params (#1296) 2024-08-13 09:53:44 -05:00
Keyan cc289089cf
not-custodial zap beta (#1178)
* not-custodial zap scaffolding

* invoice forward state machine

* small refinements to state machine

* make wrap invoice work

* get state machine working end to end

* untested logic layout for paidAction invoice wraps

* perform pessimisitic actions before outgoing payment

* working end to end

* remove unneeded params from wallets/server/createInvoice

* fix cltv relative/absolute confusion + cancelling forwards

* small refinements

* add p2p wrap info to paidAction docs

* fallback to SN invoice when wrap fails

* fix paidAction retry description

* consistent naming scheme for state machine

* refinements

* have sn pay bounded outbound fee

* remove debug logging

* reenable lnc permissions checks

* don't p2p zap on item forward splits

* make createInvoice params json encodeable

* direct -> p2p badge on notifications

* allow no tls in dev for core lightning

* fix autowithdraw to create invoice with msats

* fix autowithdraw msats/sats inconsitency

* label p2p zaps properly in satistics

* add fees to autowithdrawal notifications

* add RETRYING as terminal paid action state

* Update api/paidAction/README.md

Co-authored-by: ekzyis <ek@stacker.news>

* Update api/paidAction/README.md

Co-authored-by: ekzyis <ek@stacker.news>

* Update api/lnd/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* ek suggestions

* add bugetable to nwc card

* get paranoid with numbers

* better finalize retries and better max timeout height

* refine forward failure transitions

* more accurate satistics p2p status

* make sure paidaction cancel in state machine only

* dont drop bolt11s unless status is not null

* only allow PENDING_HELD to transition to FORWARDING

* add mermaid state machine diagrams to paid action doc

* fix cancel transition name

* cleanup readme

* move forwarding outside of transition

* refine testServerConnect and make sure ensureB64 transforms

* remove unused params from testServerConnect

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: k00b <k00b@stacker.news>
2024-08-13 09:48:30 -05:00
38 changed files with 1342 additions and 502 deletions

View File

@ -1,3 +1,4 @@
import { toPositiveNumber } from '@/lib/validate'
import lndService from 'ln-service'
const { lnd } = lndService.authenticatedLndGrpc({
@ -15,4 +16,80 @@ lndService.getWalletInfo({ lnd }, (err, result) => {
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

View File

@ -2,6 +2,38 @@
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
There are three payment flows:
@ -17,11 +49,20 @@ For paid actions that support it, if the stacker doesn't have enough fee credits
<details>
<summary>Internals</summary>
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:
Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress.
- `PENDING` -> `PAID`: when the invoice is paid
- `PENDING` -> `FAILED`: when the invoice expires or is cancelled
- `FAILED` -> `RETRYING`: when the invoice for the action is replaced with a new invoice
```mermaid
stateDiagram-v2
[*] --> PENDING
PENDING --> PAID
PENDING --> CANCELING
PENDING --> FAILED
PAID --> [*]
CANCELING --> FAILED
FAILED --> RETRYING
FAILED --> [*]
RETRYING --> [*]
```
</details>
### Pessimistic
@ -32,12 +73,21 @@ Internally, pessimistic flows use hold invoices. If the action doesn't succeed,
<details>
<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. All pessimistic actions start in a `PENDING_HELD` state and has the following transitions:
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.
- `PENDING_HELD` -> `HELD`: when the invoice is paid and the action's `perform` is run and the invoice is settled
- `HELD` -> `PAID`: when the action's `onPaid` is called
- `PENDING_HELD` -> `FAILED`: when the invoice for the action expires or is cancelled
- `HELD` -> `FAILED`: when the action fails after the invoice is paid
```mermaid
stateDiagram-v2
PAID --> [*]
CANCELING --> FAILED
FAILED --> [*]
[*] --> PENDING_HELD
PENDING_HELD --> HELD
PENDING_HELD --> CANCELING
PENDING_HELD --> FAILED
HELD --> PAID
HELD --> CANCELING
HELD --> FAILED
```
</details>
### Table of existing paid actions and their supported flows
@ -54,6 +104,35 @@ Internally, pessimistic flows use hold invoices. If the action doesn't succeed,
| update posts | 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
Each paid action is implemented in its own file in the `paidAction` directory. Each file exports a module with the following properties:
@ -63,7 +142,7 @@ Each paid action is implemented in its own file in the `paidAction` directory. E
- `supportsPessimism`: supports a pessimistic payment flow
- `supportsOptimism`: supports an optimistic payment flow
#### Functions
### Functions
All functions have the following signature: `function(args: Object, context: Object): Promise`
@ -84,6 +163,8 @@ All functions have the following signature: `function(args: Object, context: Obj
- this function is called when an optimistic action is retried
- it's passed the original `invoiceId` and the `newInvoiceId`
- this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING`
- `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
- for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description
@ -148,7 +229,7 @@ COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200
```
Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions doesn'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 don't know to exist yet.
#### Subqueries are still incorrect
@ -207,6 +288,8 @@ 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.
If both transactions lock the rows in the same order, the deadlock is avoided.
### Incorrect
```sql
@ -223,9 +306,7 @@ UPDATE users set msats = msats + 1 WHERE id = 1;
-- deadlock occurs because neither transaction can proceed to here
```
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.
In practice, this most often occurs when selecting multiple rows for update in different orders. Recently, we had a deadlock when spliting zaps to multiple users. The solution was to select the rows for update in the same order.
### Incorrect

View File

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

View File

@ -10,6 +10,23 @@ export async function getCost ({ 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 }) {
const feeMsats = cost / BigInt(10) // 10% fee
const zapMsats = cost - feeMsats
@ -78,16 +95,20 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
SELECT COALESCE(SUM(msats), 0) as msats
FROM forwardees
), recipients AS (
SELECT "userId", msats FROM forwardees
SELECT "userId", msats, msats AS "stackedMsats" FROM forwardees
UNION
SELECT ${itemAct.item.userId}::INTEGER as "userId",
${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT as msats
CASE WHEN ${!!invoice?.invoiceForward}::BOOLEAN
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
)
UPDATE users
SET
msats = users.msats + recipients.msats,
"stackedMsats" = users."stackedMsats" + recipients.msats
"stackedMsats" = users."stackedMsats" + recipients."stackedMsats"
FROM recipients
WHERE users.id = recipients."userId"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -458,9 +458,11 @@ function Invoicification ({ n: { invoice, sortTime } }) {
function WithdrawlPaid ({ n }) {
return (
<div className='fw-bold text-info'>
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })}
{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>
{n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
{(n.withdrawl.p2p && <Badge className={styles.badge} bg={null}>p2p</Badge>) ||
(n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>)}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -67,6 +67,7 @@ export const OLD_ITEM_DAYS = 3
export const ANON_FEE_MULTIPLIER = 100
export const SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5
export const LND_PATHFINDING_TIMEOUT_MS = 30000
export const LNURLP_COMMENT_MAX_LENGTH = 1000
export const RESERVED_MAX_USER_ID = 615
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 => {
if (HEX_REGEX.test(hexOrB64Url)) {
return hexToB64(hexOrB64Url)
hexOrB64Url = hexToB64(hexOrB64Url)
}
hexOrB64Url = ensureB64Padding(hexOrB64Url)

View File

@ -21,9 +21,9 @@ const { NAME_QUERY } = usersFragments
export async function ssValidate (schema, data, args) {
try {
if (typeof schema === 'function') {
await schema(args).validate(data)
return await schema(args).validate(data)
} else {
await schema.validate(data)
return await schema.validate(data)
}
} catch (e) {
if (e instanceof ValidationError) {
@ -34,18 +34,19 @@ export async function ssValidate (schema, data, args) {
}
export async function formikValidate (validate, data) {
const errors = await validate(data)
if (Object.keys(errors).length > 0) {
const [key, message] = Object.entries(errors)[0]
const result = await validate(data)
if (Object.keys(result).length > 0) {
const [key, message] = Object.entries(result)[0]
throw new Error(`${key}: ${message}`)
}
return result
}
export async function walletValidate (wallet, data) {
if (typeof wallet.fieldValidation === 'function') {
return await formikValidate(wallet.fieldValidation, data)
} else {
await ssValidate(wallet.fieldValidation, data)
return await ssValidate(wallet.fieldValidation, data)
}
}
@ -193,6 +194,12 @@ const hexOrBase64Validator = string().test({
return false
}
}
}).transform(val => {
try {
return ensureB64(val)
} catch {
return val
}
})
async function usernameExists (name, { client, models }) {
@ -753,3 +760,19 @@ export const lud18PayerDataSchema = (k1) => object({
// check if something is _really_ a number.
// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity]
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",
"@noble/curves": "^1.2.0",
"@opensearch-project/opensearch": "^2.4.0",
"@prisma/client": "^5.14.0",
"@prisma/client": "^5.17.0",
"@slack/web-api": "^6.9.0",
"@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^1.1.10",
@ -62,7 +62,7 @@
"page-metadata-parser": "^1.1.4",
"pg-boss": "^9.0.3",
"piexifjs": "^1.0.6",
"prisma": "^5.16.1",
"prisma": "^5.17.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-avatar-editor": "^13.0.0",
@ -4092,9 +4092,9 @@
}
},
"node_modules/@prisma/client": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.14.0.tgz",
"integrity": "sha512-akMSuyvLKeoU4LeyBAUdThP/uhVP3GuLygFE3MlYzaCb3/J8SfsYBE5PkaFuLuVpLyA6sFoW+16z/aPhNAESqg==",
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.17.0.tgz",
"integrity": "sha512-N2tnyKayT0Zf7mHjwEyE8iG7FwTmXDHFZ1GnNhQp0pJUObsuel4ZZ1XwfuAYkq5mRIiC/Kot0kt0tGCfLJ70Jw==",
"hasInstallScript": true,
"engines": {
"node": ">=16.13"
@ -4109,43 +4109,43 @@
}
},
"node_modules/@prisma/debug": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.16.1.tgz",
"integrity": "sha512-JsNgZAg6BD9RInLSrg7ZYzo11N7cVvYArq3fHGSD89HSgtN0VDdjV6bib7YddbcO6snzjchTiLfjeTqBjtArVQ=="
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.17.0.tgz",
"integrity": "sha512-l7+AteR3P8FXiYyo496zkuoiJ5r9jLQEdUuxIxNCN1ud8rdbH3GTxm+f+dCyaSv9l9WY+29L9czaVRXz9mULfg=="
},
"node_modules/@prisma/engines": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.16.1.tgz",
"integrity": "sha512-KkyF3eIUtBIyp5A/rJHCtwQO18OjpGgx18PzjyGcJDY/+vNgaVyuVd+TgwBgeq6NLdd1XMwRCI+58vinHsAdfA==",
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.17.0.tgz",
"integrity": "sha512-+r+Nf+JP210Jur+/X8SIPLtz+uW9YA4QO5IXA+KcSOBe/shT47bCcRMTYCbOESw3FFYFTwe7vU6KTWHKPiwvtg==",
"hasInstallScript": true,
"dependencies": {
"@prisma/debug": "5.16.1",
"@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"@prisma/fetch-engine": "5.16.1",
"@prisma/get-platform": "5.16.1"
"@prisma/debug": "5.17.0",
"@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053",
"@prisma/fetch-engine": "5.17.0",
"@prisma/get-platform": "5.17.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303.tgz",
"integrity": "sha512-HkT2WbfmFZ9WUPyuJHhkiADxazHg8Y4gByrTSVeb3OikP6tjQ7txtSUGu9OBOBH0C13dPKN2qqH12xKtHu/Hiw=="
"version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053.tgz",
"integrity": "sha512-tUuxZZysZDcrk5oaNOdrBnnkoTtmNQPkzINFDjz7eG6vcs9AVDmA/F6K5Plsb2aQc/l5M2EnFqn3htng9FA4hg=="
},
"node_modules/@prisma/fetch-engine": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.16.1.tgz",
"integrity": "sha512-oOkjaPU1lhcA/Rvr4GVfd1NLJBwExgNBE36Ueq7dr71kTMwy++a3U3oLd2ZwrV9dj9xoP6LjCcky799D9nEt4w==",
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.17.0.tgz",
"integrity": "sha512-ESxiOaHuC488ilLPnrv/tM2KrPhQB5TRris/IeIV4ZvUuKeaicCl4Xj/JCQeG9IlxqOgf1cCg5h5vAzlewN91Q==",
"dependencies": {
"@prisma/debug": "5.16.1",
"@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"@prisma/get-platform": "5.16.1"
"@prisma/debug": "5.17.0",
"@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053",
"@prisma/get-platform": "5.17.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.16.1.tgz",
"integrity": "sha512-R4IKnWnMkR2nUAbU5gjrPehdQYUUd7RENFD2/D+xXTNhcqczp0N+WEGQ3ViyI3+6mtVcjjNIMdnUTNyu3GxIgA==",
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.17.0.tgz",
"integrity": "sha512-UlDgbRozCP1rfJ5Tlkf3Cnftb6srGrEQ4Nm3og+1Se2gWmCZ0hmPIi+tQikGDUVLlvOWx3Gyi9LzgRP+HTXV9w==",
"dependencies": {
"@prisma/debug": "5.16.1"
"@prisma/debug": "5.17.0"
}
},
"node_modules/@protobufjs/aspromise": {
@ -15813,12 +15813,12 @@
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
},
"node_modules/prisma": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.16.1.tgz",
"integrity": "sha512-Z1Uqodk44diztImxALgJJfNl2Uisl9xDRvqybMKEBYJLNKNhDfAHf+ZIJbZyYiBhLMbKU9cYGdDVG5IIXEnL2Q==",
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.17.0.tgz",
"integrity": "sha512-m4UWkN5lBE6yevqeOxEvmepnL5cNPEjzMw2IqDB59AcEV6w7D8vGljDLd1gPFH+W6gUxw9x7/RmN5dCS/WTPxA==",
"hasInstallScript": true,
"dependencies": {
"@prisma/engines": "5.16.1"
"@prisma/engines": "5.17.0"
},
"bin": {
"prisma": "build/index.js"

View File

@ -21,7 +21,7 @@
"@lightninglabs/lnc-web": "^0.3.1-alpha",
"@noble/curves": "^1.2.0",
"@opensearch-project/opensearch": "^2.4.0",
"@prisma/client": "^5.14.0",
"@prisma/client": "^5.17.0",
"@slack/web-api": "^6.9.0",
"@svgr/webpack": "^8.1.0",
"@yudiel/react-qr-scanner": "^1.1.10",
@ -67,7 +67,7 @@
"page-metadata-parser": "^1.1.4",
"pg-boss": "^9.0.3",
"piexifjs": "^1.0.6",
"prisma": "^5.16.1",
"prisma": "^5.17.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.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>)}
<PayerData data={fact.invoicePayerData} className='text-muted' header />
{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}>autowithdraw</Badge>}
<Satus status={fact.status} />{fact.autoWithdraw && <Badge className={styles.badge} bg={null}>{fact.type === 'p2p' ? 'p2p' : 'autowithdraw'}</Badge>}
</Link>
</div>
)

View File

@ -0,0 +1,76 @@
-- 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[]
SubAct SubAct[]
MuteSub MuteSub[]
Wallet Wallet[]
wallets Wallet[]
TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser")
TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser")
AncestorReplies Reply[] @relation("AncestorReplyUser")
@ -194,6 +194,7 @@ model Wallet {
walletCLN WalletCLN?
walletLNbits WalletLNbits?
withdrawals Withdrawl[]
InvoiceForward InvoiceForward[]
@@index([userId])
}
@ -791,7 +792,11 @@ enum InvoiceActionState {
HELD
PAID
FAILED
FORWARDING
FORWARDED
FAILED_FORWARD
RETRYING
CANCELING
}
model ItemMention {
@ -810,42 +815,71 @@ model ItemMention {
}
model Invoice {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
hash String @unique(map: "Invoice.hash_unique")
preimage String? @unique(map: "Invoice.preimage_unique")
hash String @unique(map: "Invoice.hash_unique")
preimage String? @unique(map: "Invoice.preimage_unique")
isHeld Boolean?
bolt11 String
expiresAt DateTime
confirmedAt DateTime?
confirmedIndex BigInt?
cancelled Boolean @default(false)
cancelled Boolean @default(false)
msatsRequested BigInt
msatsReceived BigInt?
desc String?
comment String?
lud18Data Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invoiceForward InvoiceForward?
actionState InvoiceActionState?
actionType InvoiceActionType?
actionId Int?
actionArgs Json? @db.JsonB
actionError String?
actionResult Json? @db.JsonB
ItemAct ItemAct[]
Item Item[]
Upload Upload[]
PollVote PollVote[]
PollBlindVote PollBlindVote[]
actionState InvoiceActionState?
actionType InvoiceActionType?
actionOptimistic Boolean?
actionId Int?
actionArgs Json? @db.JsonB
actionError String?
actionResult Json? @db.JsonB
ItemAct ItemAct[]
Item Item[]
Upload Upload[]
PollVote PollVote[]
PollBlindVote PollBlindVote[]
@@index([createdAt], map: "Invoice.created_at_index")
@@index([userId], map: "Invoice.userId_index")
@@index([confirmedIndex], map: "Invoice.confirmedIndex_index")
@@index([actionState])
@@index([isHeld])
@@index([confirmedAt])
@@index([actionType])
@@index([actionState])
}
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 {
@ -854,6 +888,7 @@ model Withdrawl {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
hash String?
preimage String?
bolt11 String?
msatsPaying BigInt
msatsPaid BigInt?
@ -864,10 +899,14 @@ model Withdrawl {
walletId Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull)
invoiceForward InvoiceForward[]
@@index([createdAt], map: "Withdrawl.created_at_index")
@@index([userId], map: "Withdrawl.userId_index")
@@index([hash])
@@index([walletId])
@@index([autoWithdraw])
@@index([status])
}
model Account {

4
sndev
View File

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

View File

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

View File

@ -1,28 +1,28 @@
import { addWalletLog, fetchLnAddrInvoice } from '@/api/resolvers/wallet'
import { lnAddrOptions } from '@/lib/lnurl'
export * from 'wallets/lightning-address'
export const testConnectServer = async (
{ 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 testConnectServer = async ({ address }) => {
return await createInvoice({ msats: 1000 }, { address })
}
export const createInvoice = async (
{ amount, maxFee },
{ address },
{ me, models, lnd, lnService }
{ msats, description },
{ address }
) => {
const res = await fetchLnAddrInvoice({ addr: address, amount, maxFee }, {
me,
models,
lnd,
lnService,
autoWithdraw: true
})
const { callback, commentAllowed } = await lnAddrOptions(address)
const callbackUrl = new URL(callback)
callbackUrl.searchParams.append('amount', msats)
if (commentAllowed >= description?.length) {
callbackUrl.searchParams.append('comment', description)
}
// 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
}

View File

@ -1,6 +1,12 @@
export * from 'wallets/lnbits'
async function _createInvoice ({ url, invoiceKey, amount, expiry }, { me }) {
export async function testConnectServer ({ url, invoiceKey }) {
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 headers = new Headers()
@ -8,8 +14,13 @@ async function _createInvoice ({ url, invoiceKey, amount, expiry }, { me }) {
headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', invoiceKey)
const memo = me.hideInvoiceDesc ? undefined : 'autowithdraw to LNbits from SN'
const body = JSON.stringify({ amount, unit: 'sat', expiry, memo, out: false })
const body = JSON.stringify({
amount: msats,
unit: 'msat',
expiry,
memo: description,
out: false
})
const res = await fetch(url + path, { method: 'POST', headers, body })
if (!res.ok) {
@ -20,11 +31,3 @@ async function _createInvoice ({ url, invoiceKey, amount, expiry }, { me }) {
const payment = await res.json()
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,46 +1,15 @@
import { ensureB64 } from '@/lib/format'
import { datePivot } from '@/lib/time'
import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-service'
import { addWalletLog } from '@/api/resolvers/wallet'
export * from 'wallets/lnd'
export const testConnectServer = async (
{ 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 testConnectServer = async ({ cert, macaroon, socket }) => {
return await createInvoice({ msats: 1, expiry: 1 }, { cert, macaroon, socket })
}
export const createInvoice = async (
{ amount },
{ cert, macaroon, socket },
{ me }
{ msats, description, descriptionHash, expiry },
{ cert, macaroon, socket }
) => {
const { lnd } = await authenticatedLndGrpc({
cert,
@ -49,10 +18,11 @@ export const createInvoice = async (
})
const invoice = await lndCreateInvoice({
description: me.hideInvoiceDesc ? undefined : 'autowithdraw to LND from SN',
lnd,
tokens: amount,
expires_at: datePivot(new Date(), { seconds: 360 })
description,
description_hash: descriptionHash,
mtokens: String(msats),
expires_at: datePivot(new Date(), { seconds: expiry })
})
return invoice.request

View File

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

View File

@ -2,5 +2,75 @@ import * as lnd from 'wallets/lnd/server'
import * as cln from 'wallets/cln/server'
import * as lnAddr from 'wallets/lightning-address/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 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')
}

164
wallets/wrap.js Normal file
View File

@ -0,0 +1,164 @@
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 { createWithdrawal, addWalletLog } from '@/api/resolvers/wallet'
import walletDefs from 'wallets/server'
import { createWithdrawal } from '@/api/resolvers/wallet'
import { createInvoice } from 'wallets/server'
export async function autoWithdraw ({ data: { id }, models, lnd }) {
const user = await models.user.findUnique({ where: { id } })
@ -12,11 +12,14 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
// excess must be greater than 10% of threshold
if (excess < Number(threshold) * 0.1) return
const maxFee = msatsToSats(Math.ceil(excess * (user.autoWithdrawMaxFeePercent / 100.0)))
const amount = msatsToSats(excess) - maxFee
const maxFeeMsats = Math.ceil(excess * (user.autoWithdrawMaxFeePercent / 100.0))
const msats = excess - maxFeeMsats
// must be >= 1 sat
if (amount < 1) return
if (msats < 1000) return
// maxFee is expected to be in sats, ie "msatsFeePaying" is always divisible by 1000
const maxFee = msatsToSats(maxFeeMsats)
// check that
// 1. the user doesn't have an autowithdraw pending
@ -36,73 +39,8 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
if (pendingOrFailed.exists) return
// get the wallets in order of priority
const wallets = await models.wallet.findMany({
where: { userId: user.id, enabled: true },
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 })
const { invoice, wallet } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models })
return await createWithdrawal(null,
{ invoice, maxFee },
{ me: { id }, models, lnd, walletId: wallet.id })
}

View File

@ -3,6 +3,7 @@ import nextEnv from '@next/env'
import createPrisma from '@/lib/create-prisma.js'
import {
autoDropBolt11s, checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
checkWithdrawal,
finalizeHodlInvoice, subscribeToWallet
} from './wallet.js'
import { repin } from './repin.js'
@ -25,7 +26,11 @@ import { ofac } from './ofac.js'
import { autoWithdraw } from './autowithdraw.js'
import { saltAndHashEmails } from './saltAndHashEmails.js'
import { remindUser } from './reminder.js'
import { holdAction, settleAction, settleActionError } from './paidAction.js'
import {
paidActionPaid, paidActionForwarding, paidActionForwarded,
paidActionFailedForward, paidActionHeld, paidActionFailed,
paidActionCanceling
} from './paidAction.js'
import { thisDay } from './thisDay.js'
import { isServiceEnabled } from '@/lib/sndev.js'
@ -90,10 +95,20 @@ async function work () {
await boss.work('checkPendingWithdrawals', jobWrapper(checkPendingWithdrawals))
await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s))
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('holdAction', jobWrapper(holdAction))
await boss.work('checkWithdrawal', jobWrapper(checkWithdrawal))
// 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')) {
await boss.work('indexItem', jobWrapper(indexItem))

View File

@ -1,31 +1,52 @@
import { getPaymentFailureStatus, hodlInvoiceCltvDetails } from '@/api/lnd'
import { paidActions } from '@/api/paidAction'
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { datePivot } from '@/lib/time'
import { toPositiveNumber } from '@/lib/validate'
import { Prisma } from '@prisma/client'
import { getInvoice, settleHodlInvoice } from 'ln-service'
import {
cancelHodlInvoice,
getInvoice, getPayment, parsePaymentRequest,
payViaPaymentRequest, settleHodlInvoice
} from 'ln-service'
import { MIN_SETTLEMENT_CLTV_DELTA } from 'wallets/wrap'
async function transitionInvoice (jobName, { invoiceId, fromState, toState, toData, onTransition }, { models, lnd, boss }) {
// aggressive finalization retry options
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}`)
let dbInvoice
try {
dbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
console.log('invoice is in state', dbInvoice.actionState)
const currentDbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
console.log('invoice is in state', currentDbInvoice.actionState)
if (['FAILED', 'PAID'].includes(dbInvoice.actionState)) {
if (['FAILED', 'PAID', 'RETRYING'].includes(currentDbInvoice.actionState)) {
console.log('invoice is already in a terminal state, skipping transition')
return
}
const lndInvoice = await getInvoice({ id: dbInvoice.hash, lnd })
const data = toData(lndInvoice)
if (!Array.isArray(fromState)) {
fromState = [fromState]
}
await models.$transaction(async tx => {
dbInvoice = await tx.invoice.update({
include: { user: true },
const lndInvoice = await getInvoice({ id: currentDbInvoice.hash, lnd })
const transitionedInvoice = await models.$transaction(async tx => {
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: {
id: invoiceId,
actionState: {
@ -33,18 +54,26 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, toDa
}
},
data: {
actionState: toState,
...data
actionState: toState
}
})
// our own optimistic concurrency check
if (!dbInvoice) {
console.log('record not found, assuming concurrent worker transitioned it')
console.log('record not found in our own concurrency check, assuming concurrent worker transitioned it')
return
}
await onTransition({ lndInvoice, dbInvoice, tx })
const data = await transition({ lndInvoice, dbInvoice, tx })
if (data) {
return await tx.invoice.update({
include,
where: { id: dbInvoice.id },
data
})
}
return dbInvoice
}, {
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
// we only need to do this because we settleHodlInvoice inside the transaction
@ -52,7 +81,10 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, toDa
timeout: 60000
})
console.log('transition succeeded')
if (transitionedInvoice) {
console.log('transition succeeded')
return transitionedInvoice
}
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
@ -66,108 +98,298 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, toDa
}
console.error('unexpected error', e)
boss.send(
await boss.send(
jobName,
{ invoiceId },
{ startAfter: datePivot(new Date(), { minutes: 1 }), priority: 1000 })
{ startAfter: datePivot(new Date(), { seconds: 30 }), priority: 1000 })
} finally {
console.groupEnd()
}
}
export async function settleAction ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('settleAction', {
async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) {
try {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const result = await paidActions[dbInvoice.actionType].perform(args,
{ models, tx, lnd, cost: BigInt(lndInvoice.received_mtokens), me: dbInvoice.user })
await tx.invoice.update({
where: { id: dbInvoice.id },
data: {
actionResult: result,
actionError: null
}
})
} catch (e) {
// store the error in the invoice, nonblocking and outside of this tx, finalizing immediately
models.invoice.update({
where: { id: dbInvoice.id },
data: {
actionError: e.message
}
}).catch(e => console.error('failed to store action error', e))
boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS)
.catch(e => console.error('failed to finalize', e))
throw e
}
}
export async function paidActionPaid ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('paidActionPaid', {
invoiceId,
fromState: ['HELD', 'PENDING'],
fromState: ['HELD', 'PENDING', 'FORWARDED'],
toState: 'PAID',
toData: invoice => {
if (!invoice.is_confirmed) {
transition: async ({ lndInvoice, dbInvoice, tx }) => {
if (!lndInvoice.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}))`
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 })
}
export async function holdAction ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('holdAction', {
// 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: '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)) {
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(invoice.received_mtokens)
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
}
}
}
}
}
},
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})`
}
}, { models, lnd, boss })
// perform the action now that we have the funds
try {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const result = await paidActions[dbInvoice.actionType].perform(args,
{ models, tx, lnd, cost: dbInvoice.msatsReceived, me: dbInvoice.user })
await tx.invoice.update({
where: { id: dbInvoice.id },
data: {
actionResult: result,
actionError: null
}
})
} catch (e) {
// store the error in the invoice, nonblocking and outside of this tx, finalizing immediately
models.invoice.update({
where: { id: dbInvoice.id },
data: {
actionError: e.message
}
}).catch(e => console.error('failed to cancel invoice', e))
boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash })
throw e
// 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: dbInvoice.preimage, lnd })
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 })
}
export async function settleActionError ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('settleActionError', {
// 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,
// any of these states can transition to FAILED
fromState: ['PENDING', 'PENDING_HELD', 'HELD'],
toState: 'FAILED',
toData: invoice => {
if (!invoice.is_canceled) {
throw new Error('invoice is not cancelled')
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 {
cancelled: true
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
await settleHodlInvoice({ secret: dbInvoice.preimage, lnd })
return {
isHeld: true,
msatsReceived: BigInt(lndInvoice.received_mtokens)
}
}
}, { models, lnd, boss })
}
export async function paidActionCanceling ({ data: { invoiceId }, models, lnd, boss }) {
return await transitionInvoice('paidActionCanceling', {
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,
// any of these states can transition to FAILED
fromState: ['PENDING', 'PENDING_HELD', 'HELD', 'FAILED_FORWARD', 'CANCELING'],
toState: 'FAILED',
transition: async ({ lndInvoice, dbInvoice, tx }) => {
if (!lndInvoice.is_canceled) {
throw new Error('invoice is not cancelled')
}
await paidActions[dbInvoice.actionType].onFail?.({ invoice: dbInvoice }, { models, tx, lnd })
return {
cancelled: true
}
},
onTransition: async ({ dbInvoice, tx }) => {
await paidActions[dbInvoice.actionType].onFail?.({ invoice: dbInvoice }, { models, tx, lnd })
}
}, { models, lnd, boss })
}

View File

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