Compare commits

...

6 Commits

Author SHA1 Message Date
soxa 707d7bdf8b
fix: cannot add images above text (#1659)
* fix: cannot add images above text

* make sure there are always 2 newlines on either side of media

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-11-27 15:31:46 -06:00
Riccardo Balbo e05989d371
Improve LNC realiability by being nicer (#1658)
* be nicer to lnc

* decrease timeout to 4 seconds
2024-11-27 12:46:40 -06:00
Riccardo Balbo 6630899e79
use flexbox for wallet card header and make logos more consistent (#1654)
* use flexbox for wallet card header

* make wallet logo consistent

* remove extra div

* Update styles/wallet.module.css

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

* Update styles/wallet.module.css

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

* resize wallet banner

* remove unused justify-content

* remove cardMeta

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-27 12:14:00 -06:00
ekzyis a032da57b9
Wallet filters (#1627)
* Add wallet filters

* Fix grid layout shift

* Store filter state in query params

* Use auto-fill instead of auto-fit

This doesn't seem to change anything but this is closer to our intention how the grid should work with fixed column width.

* Use same order for filters as icons in card

* Use state update function

* Use user-select: none for wallet filters

* Remove unnecessary '|| false'

* Add media query to keep centered grid layout on small screens

* Decrease wallet filter margin-top to 1rem

* fix wallet support usage

* improve grid

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-27 11:39:30 -06:00
Keyan 0bff478d39
direct receives and send paid action (#1650)
* direct receives and send paid action

* remove withdrawl->invoiceForward has many relationship

* fix formatMsats implicit type expectations

* ui + dropping direct payment bolt11s

* squash migrations

* fix bolt11 dropping and improve paid action wallet logging

* remove redundant sender id

* fix redirect when funding account over threshold

* better logging
2024-11-27 07:39:05 -06:00
ekzyis 8b5e13236b
Fix inconsistent actionArgs on retry (#1651) 2024-11-26 07:39:05 -06:00
41 changed files with 1171 additions and 577 deletions

View File

@ -1,7 +1,9 @@
import { cachedFetcher } from '@/lib/fetch'
import { toPositiveNumber } from '@/lib/validate'
import { toPositiveNumber } from '@/lib/format'
import { authenticatedLndGrpc } from '@/lib/lnd'
import { getIdentity, getHeight, getWalletInfo, getNode } from 'ln-service'
import { getIdentity, getHeight, getWalletInfo, getNode, getPayment } from 'ln-service'
import { datePivot } from '@/lib/time'
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
const lnd = global.lnd || authenticatedLndGrpc({
cert: process.env.LND_CERT,
@ -88,22 +90,22 @@ export function getPaymentFailureStatus (withdrawal) {
throw new Error('withdrawal is not failed')
}
if (withdrawal?.failed.is_insufficient_balance) {
if (withdrawal?.failed?.is_insufficient_balance) {
return {
status: 'INSUFFICIENT_BALANCE',
message: 'you didn\'t have enough sats'
}
} else if (withdrawal?.failed.is_invalid_payment) {
} else if (withdrawal?.failed?.is_invalid_payment) {
return {
status: 'INVALID_PAYMENT',
message: 'invalid payment'
}
} else if (withdrawal?.failed.is_pathfinding_timeout) {
} else if (withdrawal?.failed?.is_pathfinding_timeout) {
return {
status: 'PATHFINDING_TIMEOUT',
message: 'no route found'
}
} else if (withdrawal?.failed.is_route_not_found) {
} else if (withdrawal?.failed?.is_route_not_found) {
return {
status: 'ROUTE_NOT_FOUND',
message: 'no route found'
@ -160,4 +162,18 @@ export const getNodeSockets = cachedFetcher(async function fetchNodeSockets ({ l
}
})
export async function getPaymentOrNotSent ({ id, lnd, createdAt }) {
try {
return await getPayment({ id, lnd })
} catch (err) {
if (err[1] === 'SentPaymentNotFound' &&
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
return { notSent: true, is_failed: true }
} else {
throw err
}
}
}
export default lnd

View File

@ -3,8 +3,8 @@ import { datePivot } from '@/lib/time'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { createHmac } from '@/api/resolvers/wallet'
import { Prisma } from '@prisma/client'
import { createWrappedInvoice } from '@/wallets/server'
import { assertBelowMaxPendingInvoices } from './lib/assert'
import { createWrappedInvoice, createInvoice as createUserInvoice } from '@/wallets/server'
import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
@ -106,6 +106,17 @@ export default async function performPaidAction (actionType, args, incomingConte
}
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) {
return await performOptimisticAction(actionType, args, contextWithPaymentMethod)
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT) {
try {
return await performDirectAction(actionType, args, contextWithPaymentMethod)
} catch (e) {
if (e instanceof NonInvoiceablePeerError) {
console.log('peer cannot be invoiced, skipping')
continue
}
console.error(`${paymentMethod} action failed`, e)
throw e
}
}
}
}
@ -229,6 +240,55 @@ async function performP2PAction (actionType, args, incomingContext) {
: await beginPessimisticAction(actionType, args, context)
}
// we don't need to use the module for perform-ing outside actions
// because we can't track the state of outside invoices we aren't paid/paying
async function performDirectAction (actionType, args, incomingContext) {
const { models, lnd, cost } = incomingContext
const { comment, lud18Data, noteStr, description: actionDescription } = args
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
if (!userId) {
throw new NonInvoiceablePeerError()
}
let invoiceObject
try {
await assertBelowMaxPendingDirectPayments(userId, incomingContext)
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
invoiceObject = await createUserInvoice(userId, {
msats: cost,
description,
expiry: INVOICE_EXPIRE_SECS
}, { models, lnd })
} catch (e) {
console.error('failed to create outside invoice', e)
throw new NonInvoiceablePeerError()
}
const { invoice, wallet } = invoiceObject
const hash = parsePaymentRequest({ request: invoice }).id
const payment = await models.directPayment.create({
data: {
comment,
lud18Data,
desc: noteStr,
bolt11: invoice,
msats: cost,
hash,
walletId: wallet.id,
receiverId: userId
}
})
return {
invoice: payment,
paymentMethod: 'DIRECT'
}
}
export async function retryPaidAction (actionType, args, incomingContext) {
const { models, me } = incomingContext
const { invoice: failedInvoice } = args
@ -256,7 +316,7 @@ export async function retryPaidAction (actionType, args, incomingContext) {
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
}
const { msatsRequested, actionId } = failedInvoice
const { msatsRequested, actionId, actionArgs } = failedInvoice
const retryContext = {
...incomingContext,
optimistic: true,
@ -265,7 +325,7 @@ export async function retryPaidAction (actionType, args, incomingContext) {
actionId
}
const invoiceArgs = await createSNInvoice(actionType, args, retryContext)
const invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
return await models.$transaction(async tx => {
const context = { ...retryContext, tx, invoiceArgs }
@ -282,7 +342,7 @@ export async function retryPaidAction (actionType, args, incomingContext) {
})
// create a new invoice
const invoice = await createDbInvoice(actionType, args, context)
const invoice = await createDbInvoice(actionType, actionArgs, context)
return {
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),

View File

@ -1,7 +1,10 @@
import { BALANCE_LIMIT_MSATS, PAID_ACTION_TERMINAL_STATES, USER_ID, SN_ADMIN_IDS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
import { datePivot } from '@/lib/time'
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10
const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100
const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad]
export async function assertBelowMaxPendingInvoices (context) {
@ -20,6 +23,40 @@ export async function assertBelowMaxPendingInvoices (context) {
}
}
export async function assertBelowMaxPendingDirectPayments (userId, context) {
const { models, me } = context
if (me?.id !== userId) {
const pendingSenderInvoices = await models.directPayment.count({
where: {
senderId: me?.id ?? USER_ID.anon,
createdAt: {
gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES })
}
}
})
if (pendingSenderInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) {
throw new Error('You\'ve sent too many direct payments')
}
}
if (!userId) return
const pendingReceiverInvoices = await models.directPayment.count({
where: {
receiverId: userId,
createdAt: {
gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES })
}
}
})
if (pendingReceiverInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) {
throw new Error('Receiver has too many direct payments')
}
}
export async function assertBelowBalanceLimit (context) {
const { me, tx } = context
if (!me || USER_IDS_BALANCE_NO_LIMIT.includes(me.id)) return

View File

@ -1,7 +1,6 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { toPositiveBigInt } from '@/lib/validate'
import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush'
import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { getInvoiceableWallets } from '@/wallets/server'
import { assertBelowBalanceLimit } from './lib/assert'
@ -9,6 +8,7 @@ export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.P2P,
PAID_ACTION_PAYMENT_METHODS.DIRECT,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]
@ -16,17 +16,17 @@ export async function getCost ({ msats }) {
return toPositiveBigInt(msats)
}
export async function getInvoiceablePeer (_, { me, models, cost }) {
if (!me?.proxyReceive) return null
const wallets = await getInvoiceableWallets(me.id, { models })
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null
if ((cost + me.msats) <= satsToMsats(me.autoWithdrawThreshold)) return null
// if the user has any invoiceable wallets and this action will result in their balance
// being greater than their desired threshold
if (wallets.length > 0 && (cost + me.msats) > satsToMsats(me.autoWithdrawThreshold)) {
return me.id
const wallets = await getInvoiceableWallets(me.id, { models })
if (wallets.length === 0) {
return null
}
return null
return me.id
}
export async function getSybilFeePercent () {

63
api/payingAction/index.js Normal file
View File

@ -0,0 +1,63 @@
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { msatsToSats, satsToMsats, toPositiveBigInt } from '@/lib/format'
import { Prisma } from '@prisma/client'
import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service'
// paying actions are completely distinct from paid actions
// and there's only one paying action: send
// ... still we want the api to at least be similar
export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) {
try {
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId)
if (!me) {
throw new Error('You must be logged in to perform this action')
}
const decoded = await parsePaymentRequest({ request: bolt11 })
const cost = toPositiveBigInt(toPositiveBigInt(decoded.mtokens) + satsToMsats(maxFee))
console.log('cost', cost)
const withdrawal = await models.$transaction(async tx => {
await tx.user.update({
where: {
id: me.id
},
data: { msats: { decrement: cost } }
})
return await tx.withdrawl.create({
data: {
hash: decoded.id,
bolt11,
msatsPaying: toPositiveBigInt(decoded.mtokens),
msatsFeePaying: satsToMsats(maxFee),
userId: me.id,
walletId,
autoWithdraw: !!walletId
}
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
payViaPaymentRequest({
lnd,
request: withdrawal.bolt11,
max_fee: msatsToSats(withdrawal.msatsFeePaying),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS
}).catch(console.error)
return withdrawal
} catch (e) {
if (e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
throw new Error('insufficient funds')
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new Error('you cannot withdraw to the same invoice twice')
}
console.error('performPayingAction failed', e)
throw e
} finally {
console.groupEnd()
}
}

View File

@ -440,29 +440,37 @@ export default {
}
if (user.noteWithdrawals) {
const wdrwl = await models.withdrawl.findFirst({
const p2pZap = await models.invoice.findFirst({
where: {
confirmedAt: {
gt: lastChecked
},
invoiceForward: {
withdrawl: {
userId: me.id,
status: 'CONFIRMED',
updatedAt: {
gt: lastChecked
}
}
}
}
})
if (p2pZap) {
foundNotes()
return true
}
const wdrwl = await models.withdrawl.findFirst({
where: {
userId: me.id,
status: 'CONFIRMED',
hash: {
not: null
},
OR: [
{
invoiceForward: {
none: {}
}
updatedAt: {
gt: lastChecked
},
{
invoiceForward: {
some: {
invoice: {
actionType: 'ZAP'
}
}
}
}
]
invoiceForward: { is: null }
}
})
if (wdrwl) {

View File

@ -1,15 +1,14 @@
import {
payViaPaymentRequest,
getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
parsePaymentRequest
} from 'ln-service'
import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import {
USER_ID, INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS
USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS
} from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
@ -24,6 +23,7 @@ import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
import { canReceive } from '@/wallets/common'
import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
@ -190,6 +190,18 @@ const resolvers = {
})
},
withdrawl: getWithdrawl,
direct: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.directPayment.findUnique({
where: {
id: Number(id),
receiverId: me.id
}
})
},
numBolt11s: async (parent, args, { me, models, lnd }) => {
if (!me) {
throw new GqlAuthenticationError()
@ -251,6 +263,17 @@ const resolvers = {
AND "Withdrawl".created_at <= $2
GROUP BY "Withdrawl".id)`
)
queries.push(
`(SELECT id, created_at as "createdAt", msats, 'direct' as type,
jsonb_build_object(
'bolt11', bolt11,
'description', "desc",
'invoiceComment', comment,
'invoicePayerData', "lud18Data") as other
FROM "DirectPayment"
WHERE "DirectPayment"."receiverId" = $1
AND "DirectPayment".created_at <= $2)`
)
}
if (include.has('stacked')) {
@ -436,33 +459,32 @@ const resolvers = {
WalletDetails: {
__resolveType: wallet => wallet.__resolveType
},
InvoiceOrDirect: {
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
},
Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
await validateSchema(amountSchema, { amount })
await assertGofacYourself({ models, headers })
const { invoice } = await performPaidAction('RECEIVE', {
const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
msats: satsToMsats(amount)
}, { models, lnd, me })
return invoice
return {
...invoice,
__resolveType:
paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice'
}
},
createWithdrawl: createWithdrawal,
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => {
verifyHmac(hash, hmac)
const dbInv = await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
if (dbInv?.invoiceForward) {
const { wallet, bolt11 } = dbInv.invoiceForward
const logger = walletLogger({ wallet, models })
const decoded = await parsePaymentRequest({ request: bolt11 })
logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { bolt11 })
}
await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
return await models.invoice.findFirst({ where: { hash } })
},
dropBolt11: async (parent, { id }, { me, models, lnd }) => {
dropBolt11: async (parent, { hash }, { me, models, lnd }) => {
if (!me) {
throw new GqlAuthenticationError()
}
@ -474,7 +496,7 @@ const resolvers = {
SELECT id, hash, bolt11
FROM "Withdrawl"
WHERE "userId" = ${me.id}
AND id = ${Number(id)}
AND hash = ${hash}
AND now() > created_at + ${retention}::INTERVAL
AND hash IS NOT NULL
AND status IS NOT NULL
@ -497,7 +519,16 @@ const resolvers = {
throw new GqlInputError('failed to drop bolt11 from lnd')
}
}
return { id }
await models.$queryRaw`
UPDATE "DirectPayment"
SET hash = NULL, bolt11 = NULL, preimage = NULL
WHERE "receiverId" = ${me.id}
AND hash = ${hash}
AND now() > created_at + ${retention}::INTERVAL
AND hash IS NOT NULL`
return true
},
setWalletPriority: async (parent, { id, priority }, { me, models }) => {
if (!me) {
@ -538,11 +569,11 @@ const resolvers = {
Withdrawl: {
satsPaying: w => msatsToSats(w.msatsPaying),
satsPaid: w => msatsToSats(w.msatsPaid),
satsFeePaying: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaying),
satsFeePaid: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaid),
satsFeePaying: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaying),
satsFeePaid: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaid),
// we never want to fetch the sensitive data full monty in nested resolvers
forwardedActionType: async (withdrawl, args, { models }) => {
return (await models.invoiceForward.findFirst({
return (await models.invoiceForward.findUnique({
where: { withdrawlId: Number(withdrawl.id) },
include: {
invoice: true
@ -551,7 +582,7 @@ const resolvers = {
},
preimage: async (withdrawl, args, { lnd }) => {
try {
if (withdrawl.status === 'CONFIRMED') {
if (withdrawl.status === 'CONFIRMED' && withdrawl.hash) {
return withdrawl.preimage ?? (await getPayment({ id: withdrawl.hash, lnd })).payment.secret
}
} catch (err) {
@ -559,6 +590,17 @@ const resolvers = {
}
}
},
Direct: {
nostr: async (direct, args, { models }) => {
try {
return JSON.parse(direct.desc)
} catch (err) {
}
return null
},
sats: direct => msatsToSats(direct.msats)
},
Invoice: {
satsReceived: i => msatsToSats(i.msatsReceived),
@ -876,10 +918,6 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
throw new GqlInputError('invoice amount is too large')
}
const msatsFee = Number(maxFee) * 1000
const user = await models.user.findUnique({ where: { id: me.id } })
// check if there's an invoice with same hash that has an invoiceForward
// we can't allow this because it creates two outgoing payments from our node
// with the same hash
@ -891,23 +929,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
}
const autoWithdraw = !!wallet?.id
// create withdrawl transactionally (id, bolt11, amount, fee)
const [withdrawl] = await serialize(
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw}, ${wallet?.id}::INTEGER)`,
{ models }
)
payViaPaymentRequest({
lnd,
request: invoice,
// can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141
max_fee: Number(maxFee),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS
}).catch(console.error)
return withdrawl
return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
}
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },

View File

@ -108,6 +108,7 @@ export default gql`
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
proxyReceive: Boolean
directReceive: Boolean
}
type AuthMethods {
@ -187,6 +188,7 @@ export default gql`
vaultKeyHash: String
walletsUpdatedAt: Date
proxyReceive: Boolean
directReceive: Boolean
}
type UserOptional {

View File

@ -64,6 +64,7 @@ const typeDefs = `
extend type Query {
invoice(id: ID!): Invoice!
withdrawl(id: ID!): Withdrawl!
direct(id: ID!): Direct!
numBolt11s: Int!
connectAddress: String!
walletHistory(cursor: String, inc: String): History
@ -74,16 +75,20 @@ const typeDefs = `
}
extend type Mutation {
createInvoice(amount: Int!): Invoice!
createInvoice(amount: Int!): InvoiceOrDirect!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice!
dropBolt11(id: ID): Withdrawl
dropBolt11(hash: String!): Boolean
removeWallet(id: ID!): Boolean
deleteWalletLogs(wallet: String): Boolean
setWalletPriority(id: ID!, priority: Int!): Boolean
}
interface InvoiceOrDirect {
id: ID!
}
type Wallet {
id: ID!
createdAt: Date!
@ -101,7 +106,7 @@ const typeDefs = `
autoWithdrawMaxFeeTotal: Int!
}
type Invoice {
type Invoice implements InvoiceOrDirect {
id: ID!
createdAt: Date!
hash: String!
@ -141,6 +146,18 @@ const typeDefs = `
forwardedActionType: String
}
type Direct implements InvoiceOrDirect {
id: ID!
createdAt: Date!
bolt11: String
hash: String
sats: Int
preimage: String
nostr: JSONObject
comment: String
lud18Data: JSONObject
}
type Fact {
id: ID!
createdAt: Date!

View File

@ -2,7 +2,7 @@ import { InputGroup } from 'react-bootstrap'
import { Input } from './form'
import { useMe } from './me'
import { useEffect, useState } from 'react'
import { isNumber } from '@/lib/validate'
import { isNumber } from '@/lib/format'
import Link from 'next/link'
function autoWithdrawThreshold ({ me }) {

View File

@ -360,12 +360,22 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
onUpload={file => {
const uploadMarker = `![Uploading ${file.name}…]()`
const text = innerRef.current.value
const cursorPosition = innerRef.current.selectionStart || text.length
const cursorPosition = innerRef.current.selectionStart
let preMarker = text.slice(0, cursorPosition)
const postMarker = text.slice(cursorPosition)
let postMarker = text.slice(cursorPosition)
// when uploading multiple files at once, we want to make sure the upload markers are separated by blank lines
if (preMarker && !/\n+\s*$/.test(preMarker)) {
preMarker += '\n\n'
if (preMarker) {
// Count existing newlines at the end of preMarker
const existingNewlines = preMarker.match(/[\n]+$/)?.[0].length || 0
// Add only the needed newlines to reach 2
preMarker += '\n'.repeat(Math.max(0, 2 - existingNewlines))
}
// if there's text after the cursor, we want to make sure the upload marker is separated by a blank line
if (postMarker) {
// Count existing newlines at the start of postMarker
const existingNewlines = postMarker.match(/^[\n]*/)?.[0].length || 0
// Add only the needed newlines to reach 2
postMarker = '\n'.repeat(Math.max(0, 2 - existingNewlines)) + postMarker
}
const newText = preMarker + uploadMarker + postMarker
helpers.setValue(newText)

View File

@ -103,7 +103,7 @@ export default function Invoice ({
)
}
const { nostr, comment, lud18Data, bolt11, confirmedPreimage } = invoice
const { bolt11, confirmedPreimage } = invoice
return (
<>
@ -120,6 +120,17 @@ export default function Invoice ({
{!modal &&
<>
{info && <div className='text-muted fst-italic text-center'>{info}</div>}
<InvoiceExtras {...invoice} />
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />
{invoice?.item && <ActionInfo invoice={invoice} />}
</>}
</>
)
}
export function InvoiceExtras ({ nostr, lud18Data, comment }) {
return (
<>
<div className='w-100'>
{nostr
? <AccordianItem
@ -150,9 +161,6 @@ export default function Invoice ({
className='mb-3'
/>
</div>}
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />
{invoice?.item && <ActionInfo invoice={invoice} />}
</>}
</>
)
}

View File

@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import { useWallet } from '@/wallets/index'
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { FAST_POLL_INTERVAL } from '@/lib/constants'
import { INVOICE } from '@/fragments/wallet'
import Invoice from '@/components/invoice'
import { useShowModal } from './modal'
@ -10,17 +10,6 @@ import { InvoiceCanceledError, NoAttachedWalletError, InvoiceExpiredError } from
export const useInvoice = () => {
const client = useApolloClient()
const [createInvoice] = useMutation(gql`
mutation createInvoice($amount: Int!, $expireSecs: Int!) {
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: $expireSecs) {
id
bolt11
hash
hmac
expiresAt
satsRequested
}
}`)
const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
@ -29,15 +18,6 @@ export const useInvoice = () => {
}
`)
const create = useCallback(async amount => {
const { data, error } = await createInvoice({ variables: { amount, expireSecs: JIT_INVOICE_TIMEOUT_MS / 1000 } })
if (error) {
throw error
}
const invoice = data.createInvoice
return invoice
}, [createInvoice])
const isInvoice = useCallback(async ({ id }, that) => {
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } })
if (error) {
@ -73,7 +53,7 @@ export const useInvoice = () => {
return inv
}, [cancelInvoice])
return { create, cancel, isInvoice }
return { cancel, isInvoice }
}
const invoiceController = (id, isInvoice) => {

View File

@ -25,13 +25,11 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte
onDragEnter={onDragEnter}
onDragEnd={onDragEnd}
>
<div className={styles.cardMeta}>
<div className={styles.indicators}>
{status.any !== Status.Disabled && <DraggableIcon className={styles.drag} width={16} height={16} />}
{support.recv && <RecvIcon className={`${styles.indicator} ${statusToClass(status.recv)}`} />}
{support.send && <SendIcon className={`${styles.indicator} ${statusToClass(status.send)}`} />}
</div>
</div>
<Card.Body
// we attach touch listener only to card body to not interfere with wallet link
onTouchStart={onTouchStart}
@ -42,8 +40,8 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte
>
<div className='d-flex text-center align-items-center h-100'>
{image
? <img width='100%' {...image} />
: <Card.Title className='w-100 justify-content-center align-items-center'>{wallet.def.card.title}</Card.Title>}
? <img className={styles.walletLogo} {...image} />
: <Card.Title className={styles.walletLogo}>{wallet.def.card.title}</Card.Title>}
</div>
</Card.Body>
<Link href={`/settings/wallets/${wallet.def.name}`}>

View File

@ -170,7 +170,7 @@ export function useWalletLogger (wallet, setLogs) {
const decoded = bolt11Decode(context.bolt11)
context = {
...context,
amount: formatMsats(Number(decoded.millisatoshis)),
amount: formatMsats(decoded.millisatoshis),
payment_hash: decoded.tagsObject.payment_hash,
description: decoded.tagsObject.description,
created_at: new Date(decoded.timestamp * 1000).toISOString(),

View File

@ -51,6 +51,7 @@ ${STREAK_FIELDS}
vaultKeyHash
walletsUpdatedAt
proxyReceive
directReceive
}
optional {
isContributor
@ -113,6 +114,7 @@ export const SETTINGS_FIELDS = gql`
}
apiKeyEnabled
proxyReceive
directReceive
}
}`

View File

@ -53,6 +53,7 @@ export const WITHDRAWL = gql`
id
createdAt
bolt11
hash
satsPaid
satsFeePaying
satsFeePaid
@ -63,6 +64,21 @@ export const WITHDRAWL = gql`
}
}`
export const DIRECT = gql`
query Direct($id: ID!) {
direct(id: $id) {
id
createdAt
bolt11
hash
sats
preimage
comment
lud18Data
nostr
}
}`
export const WALLET_HISTORY = gql`
${ITEM_FULL_FIELDS}

View File

@ -7,6 +7,7 @@ export const PAID_ACTION_PAYMENT_METHODS = {
FEE_CREDIT: 'FEE_CREDIT',
PESSIMISTIC: 'PESSIMISTIC',
OPTIMISTIC: 'OPTIMISTIC',
DIRECT: 'DIRECT',
P2P: 'P2P'
}
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']

View File

@ -73,7 +73,7 @@ export const msatsToSatsDecimal = msats => {
}
export const formatSats = (sats) => numWithUnits(sats, { unitSingular: 'sat', unitPlural: 'sats', abbreviate: false })
export const formatMsats = (msats) => numWithUnits(msats, { unitSingular: 'msat', unitPlural: 'msats', abbreviate: false })
export const formatMsats = (msats) => numWithUnits(toPositiveNumber(msats), { unitSingular: 'msat', unitPlural: 'msats', abbreviate: false })
export const hexToB64 = hexstring => {
return btoa(hexstring.match(/\w{2}/g).map(function (a) {
@ -128,3 +128,79 @@ export function giveOrdinalSuffix (i) {
}
return i + 'th'
}
// 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)
/**
*
* @param {any | bigint} x
* @param {number} min
* @param {number} max
* @returns {number}
*/
export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
if (typeof x === 'undefined') {
throw new Error('value is required')
}
if (typeof x === 'bigint') {
if (x < BigInt(min) || x > BigInt(max)) {
throw new Error(`value ${x} must be between ${min} and ${max}`)
}
return Number(x)
} else {
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`)
}
/**
* @param {any | bigint} x
* @returns {number}
*/
export const toPositiveNumber = (x) => toNumber(x, 0)
/**
* @param {any} x
* @param {bigint | number} [min]
* @param {bigint | number} [max]
* @returns {bigint}
*/
export const toBigInt = (x, min, max) => {
if (typeof x === 'undefined') throw new Error('value is required')
const n = BigInt(x)
if (min !== undefined && n < BigInt(min)) {
throw new Error(`value ${x} must be at least ${min}`)
}
if (max !== undefined && n > BigInt(max)) {
throw new Error(`value ${x} must be at most ${max}`)
}
return n
}
/**
* @param {number|bigint} x
* @returns {bigint}
*/
export const toPositiveBigInt = (x) => {
return toBigInt(x, 0)
}
/**
* @param {number|bigint} x
* @returns {number|bigint}
*/
export const toPositive = (x) => {
if (typeof x === 'bigint') return toPositiveBigInt(x)
return toPositiveNumber(x)
}

View File

@ -513,79 +513,3 @@ export const deviceSyncSchema = object().shape({
return true
})
})
// 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)
/**
*
* @param {any | bigint} x
* @param {number} min
* @param {number} max
* @returns {number}
*/
export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
if (typeof x === 'undefined') {
throw new Error('value is required')
}
if (typeof x === 'bigint') {
if (x < BigInt(min) || x > BigInt(max)) {
throw new Error(`value ${x} must be between ${min} and ${max}`)
}
return Number(x)
} else {
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`)
}
/**
* @param {any | bigint} x
* @returns {number}
*/
export const toPositiveNumber = (x) => toNumber(x, 0)
/**
* @param {any} x
* @param {bigint | number} [min]
* @param {bigint | number} [max]
* @returns {bigint}
*/
export const toBigInt = (x, min, max) => {
if (typeof x === 'undefined') throw new Error('value is required')
const n = BigInt(x)
if (min !== undefined && n < BigInt(min)) {
throw new Error(`value ${x} must be at least ${min}`)
}
if (max !== undefined && n > BigInt(max)) {
throw new Error(`value ${x} must be at most ${max}`)
}
return n
}
/**
* @param {number|bigint} x
* @returns {bigint}
*/
export const toPositiveBigInt = (x) => {
return toBigInt(x, 0)
}
/**
* @param {number|bigint} x
* @returns {number|bigint}
*/
export const toPositive = (x) => {
if (typeof x === 'bigint') return toPositiveBigInt(x)
return toPositiveNumber(x)
}

View File

@ -350,12 +350,12 @@ export async function notifyDeposit (userId, invoice) {
}
}
export async function notifyWithdrawal (userId, wdrwl) {
export async function notifyWithdrawal (wdrwl) {
try {
await sendUserNotification(userId, {
title: `${numWithUnits(msatsToSats(wdrwl.payment.mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`,
await sendUserNotification(wdrwl.userId, {
title: `${numWithUnits(msatsToSats(wdrwl.msatsPaid), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`,
tag: 'WITHDRAWAL',
data: { sats: msatsToSats(wdrwl.payment.mtokens) }
data: { sats: msatsToSats(wdrwl.msatsPaid) }
})
} catch (err) {
console.error(err)

View File

@ -4,7 +4,7 @@ import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescrip
import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto'
import { LNURLP_COMMENT_MAX_LENGTH, MAX_INVOICE_DESCRIPTION_LENGTH } from '@/lib/constants'
import { validateSchema, lud18PayerDataSchema, toPositiveBigInt } from '@/lib/validate'
import { validateSchema, lud18PayerDataSchema, toPositiveBigInt } from '@/lib/format'
import assertGofacYourself from '@/api/resolvers/ofac'
import performPaidAction from '@/api/paidAction'
@ -91,7 +91,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
return res.status(200).json({
pr: invoice.bolt11,
routes: [],
verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.hash}`
verify: invoice.hash ? `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.hash}` : undefined
})
} catch (error) {
console.log(error)

66
pages/directs/[id].js Normal file
View File

@ -0,0 +1,66 @@
import { useQuery } from '@apollo/client'
import { CenterLayout } from '@/components/layout'
import { useRouter } from 'next/router'
import { DIRECT } from '@/fragments/wallet'
import { SSR, FAST_POLL_INTERVAL } from '@/lib/constants'
import Bolt11Info from '@/components/bolt11-info'
import { getGetServerSideProps } from '@/api/ssrApollo'
import { PrivacyOption } from '../withdrawals/[id]'
import { InvoiceExtras } from '@/components/invoice'
import { numWithUnits } from '@/lib/format'
import Qr, { QrSkeleton } from '@/components/qr'
// force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null })
export default function Direct () {
return (
<CenterLayout>
<LoadDirect />
</CenterLayout>
)
}
export function DirectSkeleton ({ status }) {
return (
<>
<div className='w-100 form-group'>
<QrSkeleton status={status} />
</div>
<div className='w-100 mt-3'>
<Bolt11Info />
</div>
</>
)
}
function LoadDirect () {
const router = useRouter()
const { loading, error, data } = useQuery(DIRECT, SSR
? {}
: {
variables: { id: router.query.id },
pollInterval: FAST_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network'
})
if (error) return <div>error</div>
if (!data || loading) {
return <DirectSkeleton status='loading' />
}
return (
<>
<Qr
value={data.direct.bolt11}
description={numWithUnits(data.direct.sats, { abbreviate: false })}
statusVariant='pending' status='direct payment to attached wallet'
/>
<div className='w-100 mt-3'>
<InvoiceExtras {...data.direct} />
<Bolt11Info bolt11={data.direct.bolt11} preimage={data.direct.preimage} />
<div className='w-100 mt-3'>
<PrivacyOption payment={data.direct} />
</div>
</div>
</>
)
}

View File

@ -24,7 +24,7 @@ export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY,
function satusClass (status) {
if (!status) {
return ''
return 'text-reset'
}
switch (status) {

View File

@ -159,7 +159,8 @@ export default function Settings ({ ssrData }) {
diagnostics: settings?.diagnostics,
hideIsContributor: settings?.hideIsContributor,
noReferralLinks: settings?.noReferralLinks,
proxyReceive: settings?.proxyReceive
proxyReceive: settings?.proxyReceive,
directReceive: settings?.directReceive
}}
schema={settingsSchema}
onSubmit={async ({
@ -339,7 +340,7 @@ export default function Settings ({ ssrData }) {
<div className='d-flex align-items-center'>proxy deposits to attached wallets
<Info>
<ul>
<li>Forward deposits directly to your attached wallets if they will cause your balance to exceed your auto-withdraw threshold</li>
<li>Forward deposits directly to your attached wallets if they cause your balance to exceed your auto-withdraw threshold</li>
<li>Payments will be wrapped by the SN node to preserve your wallet's privacy</li>
<li>This will incur in a 10% fee</li>
</ul>
@ -349,6 +350,22 @@ export default function Settings ({ ssrData }) {
name='proxyReceive'
groupClassName='mb-0'
/>
<Checkbox
label={
<div className='d-flex align-items-center'>directly deposit to attached wallets
<Info>
<ul>
<li>Directly deposit to your attached wallets if they cause your balance to exceed your auto-withdraw threshold</li>
<li>Senders will be able to see your wallet's lightning node public key</li>
<li>If 'proxy deposits' is also checked, it will take precedence and direct deposits will only be used as a fallback</li>
<li>Because we can't determine if a payment succeeds, you won't be notified about direct deposits</li>
</ul>
</Info>
</div>
}
name='directReceive'
groupClassName='mb-0'
/>
<Checkbox
label={
<div className='d-flex align-items-center'>hide invoice descriptions

View File

@ -19,6 +19,7 @@ import validateWallet from '@/wallets/validate'
import { ValidationError } from 'yup'
import { useFormikContext } from 'formik'
import { useWalletImage } from '@/components/wallet-image'
import styles from '@/styles/wallet.module.css'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -72,7 +73,7 @@ export default function WalletSettings () {
return (
<CenterLayout>
{image
? <img {...image} className='pb-3 px-2 mw-100' />
? <img {...image} className={styles.walletBanner} />
: <h2 className='pb-2'>{wallet.def.card.title}</h2>}
<h6 className='text-muted text-center pb-3'><Text>{wallet.def.card.subtitle}</Text></h6>
<Form

View File

@ -7,6 +7,11 @@ import { useCallback, useState } from 'react'
import { useIsClient } from '@/components/use-client'
import WalletCard from '@/components/wallet-card'
import { useToast } from '@/components/toast'
import BootstrapForm from 'react-bootstrap/Form'
import RecvIcon from '@/svgs/arrow-left-down-line.svg'
import SendIcon from '@/svgs/arrow-right-up-line.svg'
import { useRouter } from 'next/router'
import { supportsReceive, supportsSend } from '@/wallets/common'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -17,6 +22,12 @@ export default function Wallet ({ ssrData }) {
const [sourceIndex, setSourceIndex] = useState(null)
const [targetIndex, setTargetIndex] = useState(null)
const router = useRouter()
const [filter, setFilter] = useState({
send: router.query.send === 'true',
receive: router.query.receive === 'true'
})
const reorder = useCallback(async (sourceIndex, targetIndex) => {
const newOrder = [...wallets.filter(w => w.config?.enabled)]
const [source] = newOrder.splice(sourceIndex, 1)
@ -65,6 +76,13 @@ export default function Wallet ({ ssrData }) {
}
}, [sourceIndex, reorder, onReorderError])
const onFilterChange = useCallback((key) => {
return e => {
setFilter(old => ({ ...old, [key]: e.target.checked }))
router.replace({ query: { ...router.query, [key]: e.target.checked } }, undefined, { shallow: true })
}
}, [router])
return (
<Layout>
<div className='py-5 w-100'>
@ -76,7 +94,26 @@ export default function Wallet ({ ssrData }) {
</Link>
</div>
<div className={styles.walletGrid} onDragEnd={onDragEnd}>
{wallets.map((w, i) => {
<div className={styles.walletFilters}>
<BootstrapForm.Check
inline
label={<span><RecvIcon width={16} height={16} /> receive</span>}
onChange={onFilterChange('receive')}
checked={filter.receive}
/>
<BootstrapForm.Check
inline
label={<span><SendIcon width={16} height={16} /> send</span>}
onChange={onFilterChange('send')}
checked={filter.send}
/>
</div>
{wallets
.filter(w => {
return (!filter.send || (filter.send && supportsSend(w))) &&
(!filter.receive || (filter.receive && supportsReceive(w)))
})
.map((w, i) => {
const draggable = isClient && w.config?.enabled
return (

View File

@ -114,6 +114,7 @@ export function FundForm () {
const [createInvoice, { called, error }] = useMutation(gql`
mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount) {
__typename
id
}
}`)
@ -147,7 +148,11 @@ export function FundForm () {
schema={amountSchema}
onSubmit={async ({ amount }) => {
const { data } = await createInvoice({ variables: { amount: Number(amount) } })
if (data.createInvoice.__typename === 'Direct') {
router.push(`/directs/${data.createInvoice.id}`)
} else {
router.push(`/invoices/${data.createInvoice.id}`)
}
}}
>
<Input

View File

@ -118,18 +118,18 @@ function LoadWithdrawl () {
<InvoiceStatus variant={variant} status={status} />
<div className='w-100 mt-3'>
<Bolt11Info bolt11={data.withdrawl.bolt11} preimage={data.withdrawl.preimage}>
<PrivacyOption wd={data.withdrawl} />
<PrivacyOption payment={data.withdrawl} />
</Bolt11Info>
</div>
</>
)
}
function PrivacyOption ({ wd }) {
if (!wd.bolt11) return
export function PrivacyOption ({ payment }) {
if (!payment.bolt11) return
const { me } = useMe()
const keepUntil = datePivot(new Date(wd.createdAt), { days: INVOICE_RETENTION_DAYS })
const keepUntil = datePivot(new Date(payment.createdAt), { days: INVOICE_RETENTION_DAYS })
const oldEnough = new Date() >= keepUntil
if (!oldEnough) {
return (
@ -143,20 +143,20 @@ function PrivacyOption ({ wd }) {
const toaster = useToast()
const [dropBolt11] = useMutation(
gql`
mutation dropBolt11($id: ID!) {
dropBolt11(id: $id) {
id
}
mutation dropBolt11($hash: String!) {
dropBolt11(hash: $hash)
}`, {
update (cache) {
update (cache, { data }) {
if (data.dropBolt11) {
cache.modify({
id: `Withdrawl:${wd.id}`,
id: `${payment.__typename}:${payment.id}`,
fields: {
bolt11: () => null,
hash: () => null
}
})
}
}
})
return (
@ -169,7 +169,7 @@ function PrivacyOption ({ wd }) {
onConfirm={async () => {
if (me) {
try {
await dropBolt11({ variables: { id: wd.id } })
await dropBolt11({ variables: { hash: payment.hash } })
} catch (err) {
toaster.danger('unable to delete invoice: ' + err.message || err.toString?.())
throw err

View File

@ -0,0 +1,53 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "directReceive" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "DirectPayment" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"senderId" INTEGER,
"receiverId" INTEGER,
"preimage" TEXT,
"bolt11" TEXT,
"walletId" INTEGER,
"comment" TEXT,
"desc" TEXT,
"lud18Data" JSONB,
"msats" BIGINT NOT NULL,
"hash" TEXT,
CONSTRAINT "DirectPayment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DirectPayment_preimage_key" ON "DirectPayment"("preimage");
-- CreateIndex
CREATE INDEX "DirectPayment_created_at_idx" ON "DirectPayment"("created_at");
-- CreateIndex
CREATE INDEX "DirectPayment_senderId_idx" ON "DirectPayment"("senderId");
-- CreateIndex
CREATE INDEX "DirectPayment_receiverId_idx" ON "DirectPayment"("receiverId");
-- AddForeignKey
ALTER TABLE "DirectPayment" ADD CONSTRAINT "DirectPayment_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectPayment" ADD CONSTRAINT "DirectPayment_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectPayment" ADD CONSTRAINT "DirectPayment_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- drop dead functions replaced by paid/paying action state machines
DROP FUNCTION IF EXISTS confirm_invoice;
DROP FUNCTION IF EXISTS create_withdrawl;
DROP FUNCTION IF EXISTS confirm_withdrawl;
DROP FUNCTION IF EXISTS reverse_withdrawl;
-- CreateIndex
CREATE UNIQUE INDEX "InvoiceForward_withdrawlId_key" ON "InvoiceForward"("withdrawlId");
-- CreateIndex
CREATE UNIQUE INDEX "DirectPayment_hash_key" ON "DirectPayment"("hash");

View File

@ -141,6 +141,9 @@ model User {
walletsUpdatedAt DateTime?
vaultEntries VaultEntry[] @relation("VaultEntries")
proxyReceive Boolean @default(false)
directReceive Boolean @default(false)
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@ -217,6 +220,7 @@ model Wallet {
vaultEntries VaultEntry[] @relation("VaultEntries")
withdrawals Withdrawl[]
InvoiceForward InvoiceForward[]
DirectPayment DirectPayment[]
@@index([userId])
@@index([priority])
@ -940,6 +944,29 @@ model Invoice {
@@index([actionState])
}
model DirectPayment {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
senderId Int?
receiverId Int?
preimage String? @unique
bolt11 String?
hash String? @unique
desc String?
comment String?
lud18Data Json?
msats BigInt
walletId Int?
sender User? @relation("DirectPaymentSent", fields: [senderId], references: [id], onDelete: Cascade)
receiver User? @relation("DirectPaymentReceived", fields: [receiverId], references: [id], onDelete: Cascade)
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@index([senderId])
@@index([receiverId])
}
model InvoiceForward {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
@ -954,7 +981,7 @@ model InvoiceForward {
// we get these values when the outgoing invoice is settled
invoiceId Int @unique
withdrawlId Int?
withdrawlId Int? @unique
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
@ -982,7 +1009,7 @@ model Withdrawl {
walletId Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull)
invoiceForward InvoiceForward[]
invoiceForward InvoiceForward?
@@index([createdAt], map: "Withdrawl.created_at_index")
@@index([userId], map: "Withdrawl.userId_index")

2
sndev
View File

@ -261,7 +261,7 @@ sndev__withdraw() {
if [ "$1" = "--cln" ]; then
shift
label=$(date +%s)
sndev__cli -t cln invoice "$1" "$label" sndev | jq -j '.bolt11'; echo
sndev__cli -t cln invoice "$1"sats "$label" sndev | jq -j '.bolt11'; echo
else
sndev__cli lnd addinvoice --amt "$@" | jq -j '.payment_request'; echo
fi

View File

@ -1,10 +1,34 @@
.walletGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fill, 160px);
grid-gap: 20px;
justify-items: center;
align-items: center;
margin-top: 3rem;
padding: 20px 0;
justify-content: center;
}
@media (max-width: 440px) {
.walletGrid {
grid-template-columns: repeat(auto-fill, 140px);
grid-gap: 15px;
}
.card {
width: 140px;
}
}
@media (max-width: 330px) {
.walletGrid {
grid-template-columns: 100%;
}
.walletGrid > * {
justify-self: center;
}
}
.walletFilters {
grid-column: 1 / -1;
margin-left: 0.2rem;
user-select: none;
}
.drag {
@ -17,18 +41,34 @@
.card {
width: 160px;
height: 180px;
max-width: 100%;
aspect-ratio: 160 / 180;
}
.indicators {
position: absolute;
width: 100%;
display: grid;
display: flex;
align-items: center;
justify-content: end;
padding: 10px 10px 0 10px;
grid-gap: 0.2rem;
grid-auto-flow: column;
column-gap: 0.2rem;
margin-left: auto;
padding: 10px;
position: absolute;
top: 0;
right: 0;
}
.walletLogo {
max-width: 100%;
max-height: 40%;
margin: auto;
text-align: center;
font-size: 1.5rem;
line-height: 1;
}
.walletBanner {
max-width: min(256px, 100vw);
max-height: 100px;
padding: 0 15px 1rem 15px;
}
.badge {

View File

@ -3,18 +3,47 @@ import { bolt11Tags } from '@/lib/bolt11'
import { Mutex } from 'async-mutex'
export * from '@/wallets/lnc'
async function disconnect (lnc, logger) {
if (lnc) {
const mutex = new Mutex()
const serverHost = 'mailbox.terminal.lightning.today:443'
export async function testSendPayment (credentials, { logger }) {
const lnc = await getLNC(credentials, { logger })
logger?.info('validating permissions ...')
await validateNarrowPerms(lnc)
logger?.info('permissions ok')
return lnc.credentials.credentials
}
export async function sendPayment (bolt11, credentials, { logger }) {
const hash = bolt11Tags(bolt11).payment_hash
return await mutex.runExclusive(async () => {
try {
const lnc = await getLNC(credentials, { logger })
const { paymentError, paymentPreimage: preimage } = await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
if (paymentError) throw new Error(paymentError)
if (!preimage) throw new Error('No preimage in response')
return preimage
} catch (err) {
const msg = err.message || err.toString?.()
if (msg.includes('invoice expired')) throw new InvoiceExpiredError(hash)
if (msg.includes('canceled')) throw new InvoiceCanceledError(hash)
throw err
}
})
}
async function disconnectLNC (lnc, { logger } = {}) {
try {
if (!lnc?.isConnected) return
lnc.disconnect()
logger.info('disconnecting...')
// wait for lnc to disconnect before releasing the mutex
logger?.info('disconnecting...')
// wait for lnc to disconnect
await new Promise((resolve, reject) => {
let counter = 0
const interval = setInterval(() => {
if (lnc?.isConnected) {
if (counter++ > 100) {
logger.error('failed to disconnect from lnc')
logger?.error('failed to disconnect from lnc')
clearInterval(interval)
reject(new Error('failed to disconnect from lnc'))
}
@ -24,85 +53,59 @@ async function disconnect (lnc, logger) {
resolve()
})
}, 50)
logger.info('disconnected')
logger?.info('disconnected')
} catch (err) {
logger.error('failed to disconnect from lnc: ' + err)
}
logger?.error('failed to disconnect from lnc: ' + err)
}
}
export async function testSendPayment (credentials, { logger }) {
let lnc
try {
lnc = await getLNC(credentials)
async function getLNC (credentials = {}, { logger } = {}) {
if (window.snLncKillerTimeout) clearTimeout(window.snLncKillerTimeout)
logger.info('connecting ...')
await lnc.connect()
logger.info('connected')
logger.info('validating permissions ...')
await validateNarrowPerms(lnc)
logger.info('permissions ok')
return lnc.credentials.credentials
} finally {
await disconnect(lnc, logger)
}
}
const mutex = new Mutex()
export async function sendPayment (bolt11, credentials, { logger }) {
const hash = bolt11Tags(bolt11).payment_hash
return await mutex.runExclusive(async () => {
let lnc
try {
lnc = await getLNC(credentials)
await lnc.connect()
const { paymentError, paymentPreimage: preimage } =
await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
if (paymentError) throw new Error(paymentError)
if (!preimage) throw new Error('No preimage in response')
return preimage
} catch (err) {
const msg = err.message || err.toString?.()
if (msg.includes('invoice expired')) {
throw new InvoiceExpiredError(hash)
}
if (msg.includes('canceled')) {
throw new InvoiceCanceledError(hash)
}
throw err
} finally {
await disconnect(lnc, logger)
}
})
}
async function getLNC (credentials = {}) {
const serverHost = 'mailbox.terminal.lightning.today:443'
// XXX we MUST reuse the same instance of LNC because it references a global Go object
// that holds closures to the first LNC instance it's created with
if (window.lnc) {
window.lnc.credentials.credentials = {
...window.lnc.credentials.credentials,
...credentials,
serverHost
}
return window.lnc
}
if (!window.snLnc) { // create new instance
const { default: LNC } = await import('@lightninglabs/lnc-web')
window.lnc = new LNC({
window.snLnc = new LNC({
credentialStore: new LncCredentialStore({
...credentials,
serverHost
})
})
return window.lnc
window.addEventListener('beforeunload', () => {
// try to disconnect gracefully when the page is closed
disconnectLNC(window.snLnc, { logger })
})
} else if (JSON.stringify(window.snLncCredentials ?? {}) !== JSON.stringify(credentials)) {
console.log('LNC instance has new credentials')
// disconnect and update credentials if they've changed
await disconnectLNC(window.snLnc, { logger })
// XXX we MUST reuse the same instance of LNC because it references a global Go object
// that holds closures to the first LNC instance it's created with
window.snLnc.credentials.credentials = {
...window.snLnc.credentials.credentials,
...credentials,
serverHost
}
}
if (!window.snLnc.isConnected) {
logger?.info('connecting ...')
await window.snLnc.connect()
logger?.info('connected')
}
window.snLncCredentials = {
...credentials
}
window.snLncKillerTimeout = setTimeout(() => {
logger?.info('disconnecting from lnc due to inactivity ...')
mutex.runExclusive(async () => {
await disconnectLNC(window.snLnc, { logger })
})
}, 4000)
return window.snLnc
}
function validateNarrowPerms (lnc) {

View File

@ -14,11 +14,10 @@ import * as webln from '@/wallets/webln'
import { walletLogger } from '@/api/resolvers/wallet'
import walletDefs from '@/wallets/server'
import { parsePaymentRequest } from 'ln-service'
import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate'
import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format'
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { withTimeout } from '@/lib/time'
import { canReceive } from './common'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format'
import wrapInvoice from './wrap'
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]

View File

@ -1,6 +1,6 @@
import { createHodlInvoice, parsePaymentRequest } from 'ln-service'
import { estimateRouteFee, getBlockHeight } from '../api/lnd'
import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/validate'
import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/format'
const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice
const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice

43
worker/autoDropBolt11.js Normal file
View File

@ -0,0 +1,43 @@
import { deletePayment } from 'ln-service'
import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
export async function autoDropBolt11s ({ models, lnd }) {
const retention = `${INVOICE_RETENTION_DAYS} days`
// This query will update the withdrawls and return what the hash and bol11 values were before the update
const invoices = await models.$queryRaw`
WITH to_be_updated AS (
SELECT id, hash, bolt11
FROM "Withdrawl"
WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s")
AND now() > created_at + ${retention}::INTERVAL
AND hash IS NOT NULL
AND status IS NOT NULL
), updated_rows AS (
UPDATE "Withdrawl"
SET hash = NULL, bolt11 = NULL, preimage = NULL
FROM to_be_updated
WHERE "Withdrawl".id = to_be_updated.id)
SELECT * FROM to_be_updated;`
if (invoices.length > 0) {
for (const invoice of invoices) {
try {
await deletePayment({ id: invoice.hash, lnd })
} catch (error) {
console.error(`Error removing invoice with hash ${invoice.hash}:`, error)
await models.withdrawl.update({
where: { id: invoice.id },
data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage }
})
}
}
}
await models.$queryRaw`
UPDATE "DirectPayment"
SET hash = NULL, bolt11 = NULL, preimage = NULL
WHERE "receiverId" IN (SELECT id FROM users WHERE "autoDropBolt11s")
AND now() > created_at + ${retention}::INTERVAL
AND hash IS NOT NULL`
}

View File

@ -3,7 +3,7 @@ import './loadenv'
import PgBoss from 'pg-boss'
import createPrisma from '@/lib/create-prisma'
import {
autoDropBolt11s, checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
checkWithdrawal,
finalizeHodlInvoice, subscribeToWallet
} from './wallet'
@ -35,6 +35,8 @@ import { thisDay } from './thisDay'
import { isServiceEnabled } from '@/lib/sndev'
import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts'
import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11'
async function work () {
const boss = new PgBoss(process.env.DATABASE_URL)
@ -102,6 +104,9 @@ async function work () {
await boss.work('paidActionCanceling', jobWrapper(paidActionCanceling))
await boss.work('paidActionFailed', jobWrapper(paidActionFailed))
await boss.work('paidActionPaid', jobWrapper(paidActionPaid))
// payingAction jobs
await boss.work('payingActionFailed', jobWrapper(payingActionFailed))
await boss.work('payingActionConfirmed', jobWrapper(payingActionConfirmed))
}
if (isServiceEnabled('search')) {
await boss.work('indexItem', jobWrapper(indexItem))

View File

@ -1,14 +1,13 @@
import { getPaymentFailureStatus, hodlInvoiceCltvDetails } from '@/api/lnd'
import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent } from '@/api/lnd'
import { paidActions } from '@/api/paidAction'
import { walletLogger } from '@/api/resolvers/wallet'
import { LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format'
import { formatMsats, formatSats, msatsToSats, toPositiveNumber } from '@/lib/format'
import { datePivot } from '@/lib/time'
import { toPositiveNumber } from '@/lib/validate'
import { Prisma } from '@prisma/client'
import {
cancelHodlInvoice,
getInvoice, getPayment, parsePaymentRequest,
getInvoice, parsePaymentRequest,
payViaPaymentRequest, settleHodlInvoice
} from 'ln-service'
import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap'
@ -114,7 +113,7 @@ async function transitionInvoice (jobName,
}
}
async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) {
async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, lnd, boss }) {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const context = {
tx,
@ -278,7 +277,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode
// this finalizes the forward by settling the incoming invoice after the outgoing payment is confirmed
export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...args }, models, lnd, boss }) {
return await transitionInvoice('paidActionForwarded', {
const transitionedInvoice = await transitionInvoice('paidActionForwarded', {
invoiceId,
fromState: 'FORWARDING',
toState: 'FORWARDED',
@ -287,8 +286,9 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
throw new Error('invoice is not held')
}
const { bolt11, hash, msatsPaying } = dbInvoice.invoiceForward.withdrawl
const { payment, is_confirmed: isConfirmed } = withdrawal ?? await getPayment({ id: hash, lnd })
const { hash, msatsPaying, createdAt } = dbInvoice.invoiceForward.withdrawl
const { payment, is_confirmed: isConfirmed } = withdrawal ??
await getPaymentOrNotSent({ id: hash, lnd, createdAt })
if (!isConfirmed) {
throw new Error('payment is not confirmed')
}
@ -296,20 +296,6 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
// settle the invoice, allowing us to transition to PAID
await settleHodlInvoice({ secret: payment.secret, lnd })
// the amount we paid includes the fee so we need to subtract it to get the amount received
const received = Number(payment.mtokens) - Number(payment.fee_mtokens)
const logger = walletLogger({ wallet: dbInvoice.invoiceForward.wallet, models })
logger.ok(
`↙ payment received: ${formatSats(msatsToSats(received))}`,
{
bolt11,
preimage: payment.secret
// we could show the outgoing fee that we paid from the incoming amount to the receiver
// but we don't since it might look like the receiver paid the fee but that's not the case.
// fee: formatMsats(Number(payment.fee_mtokens))
})
return {
preimage: payment.secret,
invoiceForward: {
@ -328,11 +314,31 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
},
...args
}, { models, lnd, boss })
if (transitionedInvoice) {
const { bolt11, msatsPaid, msatsFeePaid } = transitionedInvoice.invoiceForward.withdrawl
// the amount we paid includes the fee so we need to subtract it to get the amount received
const received = Number(msatsPaid) - Number(msatsFeePaid)
const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models })
logger.ok(
`↙ payment received: ${formatSats(msatsToSats(received))}`,
{
bolt11,
preimage: transitionedInvoice.preimage
// we could show the outgoing fee that we paid from the incoming amount to the receiver
// but we don't since it might look like the receiver paid the fee but that's not the case.
// fee: formatMsats(msatsFeePaid)
})
}
return transitionedInvoice
}
// when the pending forward fails, we need to cancel the incoming invoice
export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: pWithdrawal, ...args }, models, lnd, boss }) {
return await transitionInvoice('paidActionFailedForward', {
let message
const transitionedInvoice = await transitionInvoice('paidActionFailedForward', {
invoiceId,
fromState: 'FORWARDING',
toState: 'FAILED_FORWARD',
@ -341,21 +347,10 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal:
throw new Error('invoice is not held')
}
let withdrawal
let notSent = false
try {
withdrawal = pWithdrawal ?? 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
}
}
const { hash, createdAt } = dbInvoice.invoiceForward.withdrawl
const withdrawal = pWithdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt })
if (!(withdrawal?.is_failed || notSent)) {
if (!(withdrawal?.is_failed || withdrawal?.notSent)) {
throw new Error('payment has not failed')
}
@ -363,14 +358,8 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal:
// 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)
const { status, message } = getPaymentFailureStatus(withdrawal)
const { bolt11, msatsFeePaying } = dbInvoice.invoiceForward.withdrawl
const logger = walletLogger({ wallet: dbInvoice.invoiceForward.wallet, models })
logger.warn(
`incoming payment failed: ${message}`, {
bolt11,
max_fee: formatMsats(Number(msatsFeePaying))
})
const { status, message: failureMessage } = getPaymentFailureStatus(withdrawal)
message = failureMessage
return {
invoiceForward: {
@ -386,6 +375,18 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal:
},
...args
}, { models, lnd, boss })
if (transitionedInvoice) {
const { bolt11, msatsFeePaying } = transitionedInvoice.invoiceForward.withdrawl
const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models })
logger.warn(
`incoming payment failed: ${message}`, {
bolt11,
max_fee: formatMsats(msatsFeePaying)
})
}
return transitionedInvoice
}
export async function paidActionHeld ({ data: { invoiceId, ...args }, models, lnd, boss }) {
@ -427,7 +428,7 @@ export async function paidActionHeld ({ data: { invoiceId, ...args }, models, ln
}
export async function paidActionCanceling ({ data: { invoiceId, ...args }, models, lnd, boss }) {
return await transitionInvoice('paidActionCanceling', {
const transitionedInvoice = await transitionInvoice('paidActionCanceling', {
invoiceId,
fromState: ['HELD', 'PENDING', 'PENDING_HELD', 'FAILED_FORWARD'],
toState: 'CANCELING',
@ -440,6 +441,17 @@ export async function paidActionCanceling ({ data: { invoiceId, ...args }, model
},
...args
}, { models, lnd, boss })
if (transitionedInvoice) {
if (transitionedInvoice.invoiceForward) {
const { wallet, bolt11 } = transitionedInvoice.invoiceForward
const logger = walletLogger({ wallet, models })
const decoded = await parsePaymentRequest({ request: bolt11 })
logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { bolt11 })
}
}
return transitionedInvoice
}
export async function paidActionFailed ({ data: { invoiceId, ...args }, models, lnd, boss }) {

176
worker/payingAction.js Normal file
View File

@ -0,0 +1,176 @@
import { getPaymentFailureStatus, getPaymentOrNotSent } from '@/api/lnd'
import { walletLogger } from '@/api/resolvers/wallet'
import { formatMsats, formatSats, msatsToSats, toPositiveBigInt } from '@/lib/format'
import { datePivot } from '@/lib/time'
import { notifyWithdrawal } from '@/lib/webPush'
import { Prisma } from '@prisma/client'
async function transitionWithdrawal (jobName,
{ withdrawalId, toStatus, transition, withdrawal, onUnexpectedError },
{ models, lnd, boss }
) {
console.group(`${jobName}: transitioning withdrawal ${withdrawalId} from null to ${toStatus}`)
let dbWithdrawal
try {
const currentDbWithdrawal = await models.withdrawl.findUnique({ where: { id: withdrawalId } })
console.log('withdrawal has status', currentDbWithdrawal.status)
if (currentDbWithdrawal.status) {
console.log('withdrawal is already has a terminal status, skipping transition')
return
}
const { hash, createdAt } = currentDbWithdrawal
const lndWithdrawal = withdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt })
const transitionedWithdrawal = await models.$transaction(async tx => {
// grab optimistic concurrency lock and the withdrawal
dbWithdrawal = await tx.withdrawl.update({
include: {
wallet: true
},
where: {
id: withdrawalId,
status: null
},
data: {
status: toStatus
}
})
// our own optimistic concurrency check
if (!dbWithdrawal) {
console.log('record not found in our own concurrency check, assuming concurrent worker transitioned it')
return
}
const data = await transition({ lndWithdrawal, dbWithdrawal, tx })
if (data) {
return await tx.withdrawl.update({
include: {
wallet: true
},
where: { id: dbWithdrawal.id },
data
})
}
return dbWithdrawal
}, {
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
// we only need to do this because we settleHodlInvoice inside the transaction
// ... and it's prone to timing out
timeout: 60000
})
if (transitionedWithdrawal) {
console.log('transition succeeded')
return transitionedWithdrawal
}
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
console.log('record not found, assuming concurrent worker transitioned it')
return
}
if (e.code === 'P2034') {
console.log('write conflict, assuming concurrent worker is transitioning it')
return
}
}
console.error('unexpected error', e)
onUnexpectedError?.({ error: e, dbWithdrawal, models, boss })
await boss.send(
jobName,
{ withdrawalId },
{ startAfter: datePivot(new Date(), { seconds: 30 }), priority: 1000 })
} finally {
console.groupEnd()
}
}
export async function payingActionConfirmed ({ data: args, models, lnd, boss }) {
const transitionedWithdrawal = await transitionWithdrawal('payingActionConfirmed', {
toStatus: 'CONFIRMED',
...args,
transition: async ({ dbWithdrawal, lndWithdrawal, tx }) => {
if (!lndWithdrawal?.is_confirmed) {
throw new Error('withdrawal is not confirmed')
}
const msatsFeePaid = toPositiveBigInt(lndWithdrawal.payment.fee_mtokens)
const msatsPaid = toPositiveBigInt(lndWithdrawal.payment.mtokens) - msatsFeePaid
console.log(`withdrawal confirmed paying ${msatsToSats(msatsPaid)} sats with ${msatsToSats(msatsFeePaid)} fee`)
await tx.user.update({
where: { id: dbWithdrawal.userId },
data: { msats: { increment: dbWithdrawal.msatsFeePaying - msatsFeePaid } }
})
console.log(`user refunded ${msatsToSats(dbWithdrawal.msatsFeePaying - msatsFeePaid)} sats`)
return {
msatsFeePaid,
msatsPaid,
preimage: lndWithdrawal.payment.secret
}
}
}, { models, lnd, boss })
if (transitionedWithdrawal) {
await notifyWithdrawal(transitionedWithdrawal)
const logger = walletLogger({ models, wallet: transitionedWithdrawal.wallet })
logger?.ok(
`↙ payment received: ${formatSats(msatsToSats(transitionedWithdrawal.msatsPaid))}`,
{
bolt11: transitionedWithdrawal.bolt11,
preimage: transitionedWithdrawal.preimage,
fee: formatMsats(transitionedWithdrawal.msatsFeePaid)
})
}
}
export async function payingActionFailed ({ data: args, models, lnd, boss }) {
let message
const transitionedWithdrawal = await transitionWithdrawal('payingActionFailed', {
toStatus: 'UNKNOWN_FAILURE',
...args,
transition: async ({ dbWithdrawal, lndWithdrawal, tx }) => {
if (!lndWithdrawal?.is_failed) {
throw new Error('withdrawal is not failed')
}
console.log(`withdrawal failed paying ${msatsToSats(dbWithdrawal.msatsPaying)} sats with ${msatsToSats(dbWithdrawal.msatsFeePaying)} fee`)
await tx.user.update({
where: { id: dbWithdrawal.userId },
data: { msats: { increment: dbWithdrawal.msatsFeePaying + dbWithdrawal.msatsPaying } }
})
console.log(`user refunded ${msatsToSats(dbWithdrawal.msatsFeePaying + dbWithdrawal.msatsPaying)} sats`)
// update to particular status
const { status, message: failureMessage } = getPaymentFailureStatus(lndWithdrawal)
message = failureMessage
console.log('withdrawal failed with status', status)
return {
status
}
}
}, { models, lnd, boss })
if (transitionedWithdrawal) {
const logger = walletLogger({ models, wallet: transitionedWithdrawal.wallet })
logger?.error(
`incoming payment failed: ${message}`,
{
bolt11: transitionedWithdrawal.bolt11,
max_fee: formatMsats(transitionedWithdrawal.msatsFeePaying)
})
}
}

View File

@ -1,11 +1,9 @@
import serialize from '@/api/resolvers/serial'
import {
getInvoice, getPayment, cancelHodlInvoice, deletePayment,
getInvoice,
subscribeToInvoices, subscribeToPayments, subscribeToInvoice
} from 'ln-service'
import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush'
import { INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { datePivot, sleep } from '@/lib/time'
import { getPaymentOrNotSent } from '@/api/lnd'
import { sleep } from '@/lib/time'
import retry from 'async-retry'
import {
paidActionPaid, paidActionForwarded,
@ -13,9 +11,7 @@ import {
paidActionForwarding,
paidActionCanceling
} from './paidAction'
import { getPaymentFailureStatus } from '@/api/lnd/index.js'
import { walletLogger } from '@/api/resolvers/wallet.js'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format.js'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
export async function subscribeToWallet (args) {
await subscribeToDeposits(args)
@ -143,19 +139,6 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd
if (dbInv.actionType) {
return await paidActionPaid({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss })
}
// XXX we need to keep this to allow production to migrate to new paidAction flow
// once all non-paidAction receive invoices are migrated, we can remove this
const [[{ confirm_invoice: code }]] = await serialize([
models.$queryRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`,
models.invoice.update({ where: { hash }, data: { confirmedIndex: inv.confirmed_index } })
], { models })
if (code === 0) {
notifyDeposit(dbInv.userId, { comment: dbInv.comment, ...inv })
}
return await boss.send('nip57', { hash })
}
if (inv.is_held) {
@ -175,18 +158,6 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd
if (dbInv.actionType) {
return await paidActionFailed({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss })
}
return await serialize(
models.invoice.update({
where: {
hash: inv.id
},
data: {
cancelled: true,
cancelledAt: new Date()
}
}), { models }
)
}
}
@ -235,13 +206,12 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo
hash,
OR: [
{ status: null },
{ invoiceForward: { some: { } } }
{ invoiceForward: { isNot: null } }
]
},
include: {
wallet: true,
invoiceForward: {
orderBy: { createdAt: 'desc' },
include: {
invoice: true
}
@ -252,103 +222,20 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo
// nothing to do if the withdrawl is already recorded and it isn't an invoiceForward
if (!dbWdrwl) return
let wdrwl
let notSent = false
try {
wdrwl = withdrawal ?? await getPayment({ id: hash, lnd })
} catch (err) {
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 {
throw err
}
}
const logger = walletLogger({ models, wallet: dbWdrwl.wallet })
const wdrwl = withdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt: dbWdrwl.createdAt })
if (wdrwl?.is_confirmed) {
if (dbWdrwl.invoiceForward.length > 0) {
return await paidActionForwarded({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss })
if (dbWdrwl.invoiceForward) {
return await paidActionForwarded({ data: { invoiceId: dbWdrwl.invoiceForward.invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss })
}
const fee = Number(wdrwl.payment.fee_mtokens)
const paid = Number(wdrwl.payment.mtokens) - fee
const [[{ confirm_withdrawl: code }]] = await serialize([
models.$queryRaw`SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`,
models.withdrawl.update({
where: { id: dbWdrwl.id },
data: {
preimage: wdrwl.payment.secret
}
})
], { models })
if (code === 0) {
notifyWithdrawal(dbWdrwl.userId, wdrwl)
const { request: bolt11, secret: preimage } = wdrwl.payment
logger?.ok(
`↙ payment received: ${formatSats(msatsToSats(paid))}`,
{
bolt11,
preimage,
fee: formatMsats(fee)
})
}
} else if (wdrwl?.is_failed || notSent) {
if (dbWdrwl.invoiceForward.length > 0) {
return await paidActionFailedForward({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss })
return await payingActionConfirmed({ data: { withdrawalId: dbWdrwl.id, withdrawal: wdrwl }, models, lnd, boss })
} else if (wdrwl?.is_failed || wdrwl?.notSent) {
if (dbWdrwl.invoiceForward) {
return await paidActionFailedForward({ data: { invoiceId: dbWdrwl.invoiceForward.invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss })
}
const { message, status } = getPaymentFailureStatus(wdrwl)
await serialize(
models.$queryRaw`
SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`,
{ models }
)
logger?.error(
`incoming payment failed: ${message}`,
{
bolt11: wdrwl.payment.request,
max_fee: formatMsats(dbWdrwl.msatsFeePaying)
})
}
}
export async function autoDropBolt11s ({ models, lnd }) {
const retention = `${INVOICE_RETENTION_DAYS} days`
// This query will update the withdrawls and return what the hash and bol11 values were before the update
const invoices = await models.$queryRaw`
WITH to_be_updated AS (
SELECT id, hash, bolt11
FROM "Withdrawl"
WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s")
AND now() > created_at + ${retention}::INTERVAL
AND hash IS NOT NULL
AND status IS NOT NULL
), updated_rows AS (
UPDATE "Withdrawl"
SET hash = NULL, bolt11 = NULL, preimage = NULL
FROM to_be_updated
WHERE "Withdrawl".id = to_be_updated.id)
SELECT * FROM to_be_updated;`
if (invoices.length > 0) {
for (const invoice of invoices) {
try {
await deletePayment({ id: invoice.hash, lnd })
} catch (error) {
console.error(`Error removing invoice with hash ${invoice.hash}:`, error)
await models.withdrawl.update({
where: { id: invoice.id },
data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage }
})
}
}
return await payingActionFailed({ data: { withdrawalId: dbWdrwl.id, withdrawal: wdrwl }, models, lnd, boss })
}
}
@ -360,33 +247,16 @@ export async function finalizeHodlInvoice ({ data: { hash }, models, lnd, boss,
return
}
const dbInv = await models.invoice.findUnique({
where: { hash },
include: {
invoiceForward: {
include: {
withdrawl: true,
wallet: true
}
}
}
})
const dbInv = await models.invoice.findUnique({ where: { hash } })
if (!dbInv) {
console.log('invoice not found in database', hash)
return
}
// if this is an actionType we need to cancel conditionally
if (dbInv.actionType) {
await paidActionCanceling({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss })
} else {
await cancelHodlInvoice({ id: hash, lnd })
}
// sync LND invoice status with invoice status in database
await checkInvoice({ data: { hash }, models, lnd, boss })
return dbInv
}
export async function checkPendingDeposits (args) {