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>
This commit is contained in:
parent
454ad26bd7
commit
cc289089cf
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)`
|
||||
|
||||
|
|
|
@ -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"`
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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: {
|
||||
|
@ -690,7 +703,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
|
||||
|
|
|
@ -125,6 +125,7 @@ const typeDefs = `
|
|||
satsFeePaid: Int
|
||||
status: String
|
||||
autoWithdraw: Boolean!
|
||||
p2p: Boolean!
|
||||
preimage: String
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -184,6 +184,8 @@ export const NOTIFICATIONS = gql`
|
|||
earnedSats
|
||||
withdrawl {
|
||||
autoWithdraw
|
||||
p2p
|
||||
satsFeePaid
|
||||
}
|
||||
}
|
||||
... on Reminder {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}, { me: wallet.user, models })
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('no wallet available')
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
119
worker/wallet.js
119
worker/wallet.js
|
@ -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`
|
||||
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 } })
|
||||
}
|
||||
}
|
||||
} 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'
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue