Compare commits

...

2 Commits

Author SHA1 Message Date
ekzyis
79ada2ab58
Fix unpaid items are counted (#1595)
* Fix unpaid items are counted

* Also fix for ncomments

* Never count unpaid items
2024-11-15 20:02:15 -06:00
Riccardo Balbo
9c55f1ebe2
Implement deposit as receive paidAction (#1570)
* lnurlp paid action

* lnurlp has 10% sybil fee

* fix merge issue

* Update pages/settings/index.js

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>

* fix notifications

* fix destructure

* pass lud18Data to lnurlp action

* minor cleanup

* truncate invoice description to permitted length

* remove redundant targetUserId

* lnurlp paidAction -> receive paidAction

* remove redundant user query

* improve determining if peer is invoiceable

* fix inconsistent relative imports

* prevent paying self-proxied invoices and better held invoice cancellation

* make gun/horse streak zap specific

* unique withdrawal hash should apply to confirmed payments too

* prevent receive from exceeding wallet limits

* notifications

* fix notifications & enhance invoice/withdrawl page

* notification indicator, proxy receive based on threshold, refinements

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-15 18:38:14 -06:00
54 changed files with 583 additions and 371 deletions

View File

@ -103,6 +103,7 @@ stateDiagram-v2
| donations | x | | x | x | x | | | | donations | x | | x | x | x | | |
| update posts | x | | x | | x | | x | | update posts | x | | x | | x | | x |
| update comments | x | | x | | x | | x | | update comments | x | | x | | x | | x |
| receive | | x | | x | x | x | x |
## Not-custodial zaps (ie p2p wrapped payments) ## 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. 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.

View File

@ -1,8 +1,11 @@
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { PAID_ACTION_PAYMENT_METHODS, PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants' import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { createHmac } from '@/api/resolvers/wallet' import { createHmac } from '@/api/resolvers/wallet'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { createWrappedInvoice } from '@/wallets/server'
import { assertBelowMaxPendingInvoices } from './lib/assert'
import * as ITEM_CREATE from './itemCreate' import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate' import * as ITEM_UPDATE from './itemUpdate'
import * as ZAP from './zap' import * as ZAP from './zap'
@ -14,7 +17,7 @@ import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive' import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate' import * as DONATE from './donate'
import * as BOOST from './boost' import * as BOOST from './boost'
import { createWrappedInvoice } from 'wallets/server' import * as RECEIVE from './receive'
export const paidActions = { export const paidActions = {
ITEM_CREATE, ITEM_CREATE,
@ -27,7 +30,8 @@ export const paidActions = {
TERRITORY_UPDATE, TERRITORY_UPDATE,
TERRITORY_BILLING, TERRITORY_BILLING,
TERRITORY_UNARCHIVE, TERRITORY_UNARCHIVE,
DONATE DONATE,
RECEIVE
} }
export default async function performPaidAction (actionType, args, incomingContext) { export default async function performPaidAction (actionType, args, incomingContext) {
@ -52,8 +56,7 @@ export default async function performPaidAction (actionType, args, incomingConte
} }
const context = { const context = {
...contextWithMe, ...contextWithMe,
cost: await paidAction.getCost(args, contextWithMe), cost: await paidAction.getCost(args, contextWithMe)
sybilFeePercent: await paidAction.getSybilFeePercent?.(args, contextWithMe)
} }
// special case for zero cost actions // special case for zero cost actions
@ -183,19 +186,25 @@ async function beginPessimisticAction (actionType, args, context) {
async function performP2PAction (actionType, args, incomingContext) { async function performP2PAction (actionType, args, incomingContext) {
// if the action has an invoiceable peer, we'll create a peer invoice // if the action has an invoiceable peer, we'll create a peer invoice
// wrap it, and return the wrapped invoice // wrap it, and return the wrapped invoice
const { cost, models, lnd, sybilFeePercent, me } = incomingContext const { cost, models, lnd, me } = incomingContext
const sybilFeePercent = await paidActions[actionType].getSybilFeePercent?.(args, incomingContext)
if (!sybilFeePercent) { if (!sybilFeePercent) {
throw new Error('sybil fee percent is not set for an invoiceable peer action') throw new Error('sybil fee percent is not set for an invoiceable peer action')
} }
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext) const contextWithSybilFeePercent = {
...incomingContext,
sybilFeePercent
}
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, contextWithSybilFeePercent)
if (!userId) { if (!userId) {
throw new NonInvoiceablePeerError() throw new NonInvoiceablePeerError()
} }
await assertBelowMaxPendingInvoices(incomingContext) await assertBelowMaxPendingInvoices(contextWithSybilFeePercent)
const description = await paidActions[actionType].describe(args, incomingContext) const description = await paidActions[actionType].describe(args, contextWithSybilFeePercent)
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, { const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: cost, msats: cost,
feePercent: sybilFeePercent, feePercent: sybilFeePercent,
@ -204,7 +213,7 @@ async function performP2PAction (actionType, args, incomingContext) {
}, { models, me, lnd }) }, { models, me, lnd })
const context = { const context = {
...incomingContext, ...contextWithSybilFeePercent,
invoiceArgs: { invoiceArgs: {
bolt11: invoice, bolt11: invoice,
wrappedBolt11: wrappedInvoice, wrappedBolt11: wrappedInvoice,
@ -282,23 +291,6 @@ export async function retryPaidAction (actionType, args, incomingContext) {
} }
const INVOICE_EXPIRE_SECS = 600 const INVOICE_EXPIRE_SECS = 600
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
export async function assertBelowMaxPendingInvoices (context) {
const { models, me } = context
const pendingInvoices = await models.invoice.count({
where: {
userId: me?.id ?? USER_ID.anon,
actionState: {
notIn: PAID_ACTION_TERMINAL_STATES
}
}
})
if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) {
throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
}
}
export class NonInvoiceablePeerError extends Error { export class NonInvoiceablePeerError extends Error {
constructor () { constructor () {
@ -314,6 +306,8 @@ async function createSNInvoice (actionType, args, context) {
const action = paidActions[actionType] const action = paidActions[actionType]
const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice
await assertBelowMaxPendingInvoices(context)
if (cost < 1000n) { if (cost < 1000n) {
// sanity check // sanity check
throw new Error('The cost of the action must be at least 1 sat') throw new Error('The cost of the action must be at least 1 sat')

View File

@ -0,0 +1,65 @@
import { BALANCE_LIMIT_MSATS, PAID_ACTION_TERMINAL_STATES, USER_ID, SN_ADMIN_IDS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad]
export async function assertBelowMaxPendingInvoices (context) {
const { models, me } = context
const pendingInvoices = await models.invoice.count({
where: {
userId: me?.id ?? USER_ID.anon,
actionState: {
notIn: PAID_ACTION_TERMINAL_STATES
}
}
})
if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) {
throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
}
}
export async function assertBelowBalanceLimit (context) {
const { me, tx } = context
if (!me || USER_IDS_BALANCE_NO_LIMIT.includes(me.id)) return
// we need to prevent this invoice (and any other pending invoices and withdrawls)
// from causing the user's balance to exceed the balance limit
const pendingInvoices = await tx.invoice.aggregate({
where: {
userId: me.id,
// p2p invoices are never in state PENDING
actionState: 'PENDING',
actionType: 'RECEIVE'
},
_sum: {
msatsRequested: true
}
})
// Get pending withdrawals total
const pendingWithdrawals = await tx.withdrawl.aggregate({
where: {
userId: me.id,
status: null
},
_sum: {
msatsPaying: true,
msatsFeePaying: true
}
})
// Calculate total pending amount
const pendingMsats = (pendingInvoices._sum.msatsRequested ?? 0n) +
((pendingWithdrawals._sum.msatsPaying ?? 0n) + (pendingWithdrawals._sum.msatsFeePaying ?? 0n))
// Check balance limit
if (pendingMsats + me.msats > BALANCE_LIMIT_MSATS) {
throw new Error(
`pending invoices and withdrawals must not cause balance to exceed ${
numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))
}`
)
}
}

84
api/paidAction/receive.js Normal file
View File

@ -0,0 +1,84 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { toPositiveBigInt } from '@/lib/validate'
import { notifyDeposit } from '@/lib/webPush'
import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { getInvoiceableWallets } from '@/wallets/server'
import { assertBelowBalanceLimit } from './lib/assert'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.P2P,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]
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 })
// 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
}
return null
}
export async function getSybilFeePercent () {
return 10n
}
export async function perform ({
invoiceId,
comment,
lud18Data
}, { me, tx }) {
const invoice = await tx.invoice.update({
where: { id: invoiceId },
data: {
comment,
lud18Data
},
include: { invoiceForward: true }
})
if (!invoice.invoiceForward) {
// if the invoice is not p2p, assert that the user's balance limit is not exceeded
await assertBelowBalanceLimit({ me, tx })
}
}
export async function describe ({ description }, { me, cost, sybilFeePercent }) {
const fee = sybilFeePercent ? cost * BigInt(sybilFeePercent) / 100n : 0n
return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}`
}
export async function onPaid ({ invoice }, { tx }) {
if (!invoice) {
throw new Error('invoice is required')
}
// P2P lnurlp does not need to update the user's balance
if (invoice?.invoiceForward) return
await tx.user.update({
where: { id: invoice.userId },
data: {
msats: {
increment: invoice.msatsReceived
}
}
})
}
export async function nonCriticalSideEffects ({ invoice }, { models }) {
await notifyDeposit(invoice.userId, invoice)
await models.$executeRaw`
INSERT INTO pgboss.job (name, data)
VALUES ('nip57', jsonb_build_object('hash', ${invoice.hash}))`
}

View File

@ -1,6 +1,7 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format' import { msatsToSats, satsToMsats } from '@/lib/format'
import { notifyZapped } from '@/lib/webPush' import { notifyZapped } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
export const anonable = true export const anonable = true
@ -18,18 +19,13 @@ export async function getCost ({ sats }) {
export async function getInvoiceablePeer ({ id }, { models }) { export async function getInvoiceablePeer ({ id }, { models }) {
const item = await models.item.findUnique({ const item = await models.item.findUnique({
where: { id: parseInt(id) }, where: { id: parseInt(id) },
include: { include: { itemForwards: true }
itemForwards: true,
user: {
include: {
wallets: true
}
}
}
}) })
const wallets = await getInvoiceableWallets(item.userId, { models })
// request peer invoice if they have an attached wallet and have not forwarded the item // 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 return wallets.length > 0 && item.itemForwards.length === 0 ? item.userId : null
} }
export async function getSybilFeePercent () { export async function getSybilFeePercent () {

View File

@ -1033,6 +1033,7 @@ export default {
}, },
ItemAct: { ItemAct: {
invoice: async (itemAct, args, { models }) => { invoice: async (itemAct, args, { models }) => {
// we never want to fetch the sensitive data full monty in nested resolvers
if (itemAct.invoiceId) { if (itemAct.invoiceId) {
return { return {
id: itemAct.invoiceId, id: itemAct.invoiceId,
@ -1282,6 +1283,7 @@ export default {
return root return root
}, },
invoice: async (item, args, { models }) => { invoice: async (item, args, { models }) => {
// we never want to fetch the sensitive data full monty in nested resolvers
if (item.invoiceId) { if (item.invoiceId) {
return { return {
id: item.invoiceId, id: item.invoiceId,

View File

@ -217,14 +217,20 @@ export default {
if (meFull.noteDeposits) { if (meFull.noteDeposits) {
queries.push( queries.push(
`(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats", `(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime",
FLOOR("Invoice"."msatsReceived" / 1000) as "earnedSats",
'InvoicePaid' AS type 'InvoicePaid' AS type
FROM "Invoice" FROM "Invoice"
WHERE "Invoice"."userId" = $1 WHERE "Invoice"."userId" = $1
AND "confirmedAt" IS NOT NULL AND "Invoice"."confirmedAt" IS NOT NULL
AND "isHeld" IS NULL AND "Invoice"."created_at" < $2
AND "actionState" IS NULL AND (
AND created_at < $2 ("Invoice"."isHeld" IS NULL AND "Invoice"."actionType" IS NULL)
OR (
"Invoice"."actionType" = 'RECEIVE'
AND "Invoice"."actionState" = 'PAID'
)
)
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT})` LIMIT ${LIMIT})`
) )
@ -232,12 +238,17 @@ export default {
if (meFull.noteWithdrawals) { if (meFull.noteWithdrawals) {
queries.push( queries.push(
`(SELECT "Withdrawl".id::text, "Withdrawl".created_at AS "sortTime", FLOOR("msatsPaid" / 1000) as "earnedSats", `(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime",
FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats",
'WithdrawlPaid' AS type 'WithdrawlPaid' AS type
FROM "Withdrawl" FROM "Withdrawl"
LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id
LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
WHERE "Withdrawl"."userId" = $1 WHERE "Withdrawl"."userId" = $1
AND status = 'CONFIRMED' AND "Withdrawl".status = 'CONFIRMED'
AND created_at < $2 AND "Withdrawl".created_at < $2
AND ("InvoiceForward"."id" IS NULL OR "Invoice"."actionType" = 'ZAP')
GROUP BY "Withdrawl".id
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT})` LIMIT ${LIMIT})`
) )

View File

@ -19,6 +19,8 @@ function paidActionType (actionType) {
return 'DonatePaidAction' return 'DonatePaidAction'
case 'POLL_VOTE': case 'POLL_VOTE':
return 'PollVotePaidAction' return 'PollVotePaidAction'
case 'RECEIVE':
return 'ReceivePaidAction'
default: default:
throw new Error('Unknown action type') throw new Error('Unknown action type')
} }

View File

@ -421,8 +421,16 @@ export default {
confirmedAt: { confirmedAt: {
gt: lastChecked gt: lastChecked
}, },
isHeld: null, OR: [
actionType: null {
isHeld: null,
actionType: null
},
{
actionType: 'RECEIVE',
actionState: 'PAID'
}
]
} }
}) })
if (invoice) { if (invoice) {
@ -438,7 +446,23 @@ export default {
status: 'CONFIRMED', status: 'CONFIRMED',
updatedAt: { updatedAt: {
gt: lastChecked gt: lastChecked
} },
OR: [
{
invoiceForward: {
none: {}
}
},
{
invoiceForward: {
some: {
invoice: {
actionType: 'ZAP'
}
}
}
}
]
} }
}) })
if (wdrwl) { if (wdrwl) {
@ -922,7 +946,8 @@ export default {
createdAt: { createdAt: {
gte, gte,
lte lte
} },
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
} }
}) })
}, },
@ -939,7 +964,8 @@ export default {
createdAt: { createdAt: {
gte, gte,
lte lte
} },
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
} }
}) })
}, },
@ -956,7 +982,8 @@ export default {
createdAt: { createdAt: {
gte, gte,
lte lte
} },
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
} }
}) })
}, },

View File

@ -1,5 +1,5 @@
import { import {
createHodlInvoice, createInvoice, payViaPaymentRequest, payViaPaymentRequest,
getInvoice as getInvoiceFromLnd, deletePayment, getPayment, getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
parsePaymentRequest parsePaymentRequest
} from 'ln-service' } from 'ln-service'
@ -7,24 +7,23 @@ import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial' import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item' import { SELECT, itemQueryWithMeta } from './item'
import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal } from '@/lib/format' import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import { import {
ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, USER_ID, INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS
INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS
} from '@/lib/constants' } from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from 'worker/wallet' import { finalizeHodlInvoice } from '@/worker/wallet'
import walletDefs from 'wallets/server' import walletDefs from '@/wallets/server'
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { lnAddrOptions } from '@/lib/lnurl' import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets, getOurPubkey } from '../lnd' import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate' import validateWallet from '@/wallets/validate'
import { canReceive } from '@/wallets/common' import { canReceive } from '@/wallets/common'
import performPaidAction from '../paidAction'
function injectResolvers (resolvers) { function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:') console.group('injected GraphQL resolvers:')
@ -84,9 +83,6 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({ const inv = await models.invoice.findUnique({
where: { where: {
id: Number(id) id: Number(id)
},
include: {
user: true
} }
}) })
@ -94,29 +90,16 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) {
throw new GqlInputError('invoice not found') throw new GqlInputError('invoice not found')
} }
if (inv.user.id === USER_ID.anon) { if (inv.userId === USER_ID.anon) {
return inv return inv
} }
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
} }
if (inv.user.id !== me.id) { if (inv.userId !== me.id) {
throw new GqlInputError('not ur invoice') throw new GqlInputError('not ur invoice')
} }
try {
inv.nostr = JSON.parse(inv.desc)
} catch (err) {
}
try {
if (inv.confirmedAt) {
inv.confirmedPreimage = inv.preimage ?? (await getInvoiceFromLnd({ id: inv.hash, lnd })).secret
}
} catch (err) {
console.error('error fetching invoice from LND', err)
}
return inv return inv
} }
@ -128,10 +111,6 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
const wdrwl = await models.withdrawl.findUnique({ const wdrwl = await models.withdrawl.findUnique({
where: { where: {
id: Number(id) id: Number(id)
},
include: {
user: true,
invoiceForward: true
} }
}) })
@ -139,7 +118,7 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
throw new GqlInputError('withdrawal not found') throw new GqlInputError('withdrawal not found')
} }
if (wdrwl.user.id !== me.id) { if (wdrwl.userId !== me.id) {
throw new GqlInputError('not ur withdrawal') throw new GqlInputError('not ur withdrawal')
} }
@ -458,50 +437,15 @@ const resolvers = {
__resolveType: wallet => wallet.__resolveType __resolveType: wallet => wallet.__resolveType
}, },
Mutation: { Mutation: {
createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => { createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
await validateSchema(amountSchema, { amount }) await validateSchema(amountSchema, { amount })
await assertGofacYourself({ models, headers }) await assertGofacYourself({ models, headers })
let expirePivot = { seconds: expireSecs } const { invoice } = await performPaidAction('RECEIVE', {
let invLimit = INV_PENDING_LIMIT msats: satsToMsats(amount)
let balanceLimit = (hodlInvoice || USER_IDS_BALANCE_NO_LIMIT.includes(Number(me?.id))) ? 0 : BALANCE_LIMIT_MSATS }, { models, lnd, me })
let id = me?.id
if (!me) {
expirePivot = { seconds: Math.min(expireSecs, 180) }
invLimit = ANON_INV_PENDING_LIMIT
balanceLimit = ANON_BALANCE_LIMIT_MSATS
id = USER_ID.anon
}
const user = await models.user.findUnique({ where: { id } }) return invoice
const expiresAt = datePivot(new Date(), expirePivot)
const description = `Funding @${user.name} on stacker.news`
try {
const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({
description: user.hideInvoiceDesc ? undefined : description,
lnd,
tokens: amount,
expires_at: expiresAt
})
const [inv] = await serialize(
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.secret}::TEXT, ${invoice.request},
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL,
${invLimit}::INTEGER, ${balanceLimit})`,
{ models }
)
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice
// has access to the HMAC
const hmac = createHmac(inv.hash)
return { ...inv, hmac }
} catch (error) {
console.log(error)
throw error
}
}, },
createWithdrawl: createWithdrawal, createWithdrawl: createWithdrawal,
sendToLnAddr, sendToLnAddr,
@ -596,7 +540,15 @@ const resolvers = {
satsPaid: w => msatsToSats(w.msatsPaid), satsPaid: w => msatsToSats(w.msatsPaid),
satsFeePaying: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaying), satsFeePaying: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaying),
satsFeePaid: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaid), satsFeePaid: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaid),
p2p: w => !!w.invoiceForward?.length, // we never want to fetch the sensitive data full monty in nested resolvers
forwardedActionType: async (withdrawl, args, { models }) => {
return (await models.invoiceForward.findFirst({
where: { withdrawlId: Number(withdrawl.id) },
include: {
invoice: true
}
}))?.invoice?.actionType
},
preimage: async (withdrawl, args, { lnd }) => { preimage: async (withdrawl, args, { lnd }) => {
try { try {
if (withdrawl.status === 'CONFIRMED') { if (withdrawl.status === 'CONFIRMED') {
@ -611,6 +563,35 @@ const resolvers = {
Invoice: { Invoice: {
satsReceived: i => msatsToSats(i.msatsReceived), satsReceived: i => msatsToSats(i.msatsReceived),
satsRequested: i => msatsToSats(i.msatsRequested), satsRequested: i => msatsToSats(i.msatsRequested),
// we never want to fetch the sensitive data full monty in nested resolvers
forwardedSats: async (invoice, args, { models }) => {
const msats = (await models.invoiceForward.findUnique({
where: { invoiceId: Number(invoice.id) },
include: {
withdrawl: true
}
}))?.withdrawl?.msatsPaid
return msats ? msatsToSats(msats) : null
},
nostr: async (invoice, args, { models }) => {
try {
return JSON.parse(invoice.desc)
} catch (err) {
}
return null
},
confirmedPreimage: async (invoice, args, { lnd }) => {
try {
if (invoice.confirmedAt) {
return invoice.preimage ?? (await getInvoiceFromLnd({ id: invoice.hash, lnd })).secret
}
} catch (err) {
console.error('error fetching invoice from LND', err)
}
return null
},
item: async (invoice, args, { models, me }) => { item: async (invoice, args, { models, me }) => {
if (!invoice.actionId) return null if (!invoice.actionId) return null
switch (invoice.actionType) { switch (invoice.actionType) {
@ -896,6 +877,17 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
const user = await models.user.findUnique({ where: { id: me.id } }) 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
const selfPayment = await models.invoice.findUnique({
where: { hash: decoded.id },
include: { invoiceForward: true }
})
if (selfPayment?.invoiceForward) {
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
}
const autoWithdraw = !!wallet?.id const autoWithdraw = !!wallet?.id
// create withdrawl transactionally (id, bolt11, amount, fee) // create withdrawl transactionally (id, bolt11, amount, fee)
const [withdrawl] = await serialize( const [withdrawl] = await serialize(

View File

@ -107,6 +107,7 @@ export default gql`
zapUndos: Int zapUndos: Int
wildWestMode: Boolean! wildWestMode: Boolean!
withdrawMaxFeeDefault: Int! withdrawMaxFeeDefault: Int!
proxyReceive: Boolean
} }
type AuthMethods { type AuthMethods {
@ -185,6 +186,7 @@ export default gql`
autoWithdrawMaxFeeTotal: Int autoWithdrawMaxFeeTotal: Int
vaultKeyHash: String vaultKeyHash: String
walletsUpdatedAt: Date walletsUpdatedAt: Date
proxyReceive: Boolean
} }
type UserOptional { type UserOptional {

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { isServerField } from '@/wallets/common' import { isServerField } from '@/wallets/common'
import walletDefs from 'wallets/server' import walletDefs from '@/wallets/server'
function injectTypeDefs (typeDefs) { function injectTypeDefs (typeDefs) {
const injected = [rawTypeDefs(), mutationTypeDefs()] const injected = [rawTypeDefs(), mutationTypeDefs()]
@ -74,7 +74,7 @@ const typeDefs = `
} }
extend type Mutation { extend type Mutation {
createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice! createInvoice(amount: Int!): Invoice!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice! cancelInvoice(hash: String!, hmac: String!): Invoice!
@ -122,6 +122,7 @@ const typeDefs = `
actionError: String actionError: String
item: Item item: Item
itemAct: ItemAct itemAct: ItemAct
forwardedSats: Int
} }
type Withdrawl { type Withdrawl {
@ -135,8 +136,8 @@ const typeDefs = `
satsFeePaid: Int satsFeePaid: Int
status: String status: String
autoWithdraw: Boolean! autoWithdraw: Boolean!
p2p: Boolean!
preimage: String preimage: String
forwardedActionType: String
} }
type Fact { type Fact {

View File

@ -14,6 +14,8 @@ import Item from './item'
import { CommentFlat } from './comment' import { CommentFlat } from './comment'
import classNames from 'classnames' import classNames from 'classnames'
import Moon from '@/svgs/moon-fill.svg' import Moon from '@/svgs/moon-fill.svg'
import { Badge } from 'react-bootstrap'
import styles from './invoice.module.css'
export default function Invoice ({ export default function Invoice ({
id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb = 'deposited', id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb = 'deposited',
@ -54,10 +56,27 @@ export default function Invoice ({
let variant = 'default' let variant = 'default'
let status = 'waiting for you' let status = 'waiting for you'
let sats = invoice.satsRequested
if (invoice.forwardedSats) {
if (invoice.actionType === 'RECEIVE') {
successVerb = 'forwarded'
sats = invoice.forwardedSats
} else {
successVerb = 'zapped'
}
}
if (invoice.confirmedAt) { if (invoice.confirmedAt) {
variant = 'confirmed' variant = 'confirmed'
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb}` status = (
<>
{numWithUnits(sats, { abbreviate: false })}
{' '}
{successVerb}
{' '}
{invoice.forwardedSats && <Badge className={styles.badge} bg={null}>p2p</Badge>}
</>
)
useWallet = false useWallet = false
} else if (invoice.cancelled) { } else if (invoice.cancelled) {
variant = 'failed' variant = 'failed'

View File

@ -0,0 +1,6 @@
.badge {
color: var(--theme-grey) !important;
background: var(--theme-clickToContextColor) !important;
vertical-align: middle;
margin-left: 0.5rem;
}

View File

@ -326,10 +326,10 @@ function NostrZap ({ n }) {
) )
} }
function InvoicePaid ({ n }) { function getPayerSig (lud18Data) {
let payerSig let payerSig
if (n.invoice.lud18Data) { if (lud18Data) {
const { name, identifier, email, pubkey } = n.invoice.lud18Data const { name, identifier, email, pubkey } = lud18Data
const id = identifier || email || pubkey const id = identifier || email || pubkey
payerSig = '- ' payerSig = '- '
if (name) { if (name) {
@ -339,10 +339,23 @@ function InvoicePaid ({ n }) {
if (id) payerSig += id if (id) payerSig += id
} }
return payerSig
}
function InvoicePaid ({ n }) {
const payerSig = getPayerSig(n.invoice.lud18Data)
let actionString = 'desposited to your account'
let sats = n.earnedSats
if (n.invoice.forwardedSats) {
actionString = 'sent directly to your attached wallet'
sats = n.invoice.forwardedSats
}
return ( return (
<div className='fw-bold text-info'> <div className='fw-bold text-info'>
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account <Check className='fill-info me-1' />{numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} {actionString}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small> <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{n.invoice.forwardedSats && <Badge className={styles.badge} bg={null}>p2p</Badge>}
{n.invoice.comment && {n.invoice.comment &&
<small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'> <small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'>
<Text>{n.invoice.comment}</Text> <Text>{n.invoice.comment}</Text>
@ -484,13 +497,17 @@ function Invoicification ({ n: { invoice, sortTime } }) {
} }
function WithdrawlPaid ({ n }) { function WithdrawlPaid ({ n }) {
let actionString = n.withdrawl.autoWithdraw ? 'sent to your attached wallet' : 'withdrawn from your account'
if (n.withdrawl.forwardedActionType === 'ZAP') {
actionString = 'zapped directly to your attached wallet'
}
return ( return (
<div className='fw-bold text-info'> <div className='fw-bold text-info'>
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })} <Check className='fill-info me-1' />{numWithUnits(n.earnedSats + 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'} {actionString}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small> <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{(n.withdrawl.p2p && <Badge className={styles.badge} bg={null}>p2p</Badge>) || {(n.withdrawl.forwardedActionType === 'ZAP' && <Badge className={styles.badge} bg={null}>p2p</Badge>) ||
(n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>)} (n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>)}
</div> </div>
) )
} }

View File

@ -174,6 +174,8 @@ export const NOTIFICATIONS = gql`
nostr nostr
comment comment
lud18Data lud18Data
actionType
forwardedSats
} }
} }
... on Invoicification { ... on Invoicification {
@ -185,8 +187,8 @@ export const NOTIFICATIONS = gql`
earnedSats earnedSats
withdrawl { withdrawl {
autoWithdraw autoWithdraw
p2p
satsFeePaid satsFeePaid
forwardedActionType
} }
} }
... on Reminder { ... on Reminder {

View File

@ -50,6 +50,7 @@ ${STREAK_FIELDS}
disableFreebies disableFreebies
vaultKeyHash vaultKeyHash
walletsUpdatedAt walletsUpdatedAt
proxyReceive
} }
optional { optional {
isContributor isContributor
@ -111,6 +112,7 @@ export const SETTINGS_FIELDS = gql`
apiKey apiKey
} }
apiKeyEnabled apiKeyEnabled
proxyReceive
} }
}` }`

View File

@ -21,6 +21,7 @@ export const INVOICE_FIELDS = gql`
actionType actionType
actionError actionError
confirmedPreimage confirmedPreimage
forwardedSats
}` }`
export const INVOICE_FULL = gql` export const INVOICE_FULL = gql`
@ -57,6 +58,7 @@ export const WITHDRAWL = gql`
status status
autoWithdraw autoWithdraw
preimage preimage
forwardedActionType
} }
}` }`

View File

@ -2,30 +2,9 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/api/*": [ "@/*": [
"api/*" "./*"
], ]
"@/lib/*": [
"lib/*"
],
"@/fragments/*": [
"fragments/*"
],
"@/pages/*": [
"pages/*"
],
"@/components/*": [
"components/*"
],
"@/wallets/*": [
"wallets/*"
],
"@/styles/*": [
"styles/*"
],
"@/svgs/*": [
"svgs/*"
],
} }
} }
} }

View File

@ -49,7 +49,8 @@ function getClient (uri) {
'ItemActPaidAction', 'ItemActPaidAction',
'PollVotePaidAction', 'PollVotePaidAction',
'SubPaidAction', 'SubPaidAction',
'DonatePaidAction' 'DonatePaidAction',
'ReceivePaidAction'
], ],
Notification: [ Notification: [
'Reply', 'Reply',

View File

@ -59,8 +59,6 @@ export const USER_ID = {
} }
export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn] export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn]
export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn, USER_ID.saloon] export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn, USER_ID.saloon]
export const ANON_INV_PENDING_LIMIT = 1000
export const ANON_BALANCE_LIMIT_MSATS = 0 // disable
export const MAX_POLL_NUM_CHOICES = 10 export const MAX_POLL_NUM_CHOICES = 10
export const MIN_POLL_NUM_CHOICES = 2 export const MIN_POLL_NUM_CHOICES = 2
export const POLL_COST = 1 export const POLL_COST = 1
@ -80,10 +78,11 @@ export const SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5 export const MAX_FORWARDS = 5
export const LND_PATHFINDING_TIMEOUT_MS = 30000 export const LND_PATHFINDING_TIMEOUT_MS = 30000
export const LNURLP_COMMENT_MAX_LENGTH = 1000 export const LNURLP_COMMENT_MAX_LENGTH = 1000
// https://github.com/lightning/bolts/issues/236
export const MAX_INVOICE_DESCRIPTION_LENGTH = 640
export const RESERVED_MAX_USER_ID = 615 export const RESERVED_MAX_USER_ID = 615
export const GLOBAL_SEED = USER_ID.k00b export const GLOBAL_SEED = USER_ID.k00b
export const FREEBIE_BASE_COST_THRESHOLD = 10 export const FREEBIE_BASE_COST_THRESHOLD = 10
export const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad]
// WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information // WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information
// From lawyers: north korea, cuba, iran, ukraine, syria // From lawyers: north korea, cuba, iran, ukraine, syria

View File

@ -1,14 +1,12 @@
import models from '@/api/models' import models from '@/api/models'
import lnd from '@/api/lnd' import lnd from '@/api/lnd'
import { createInvoice } from 'ln-service'
import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl' import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl'
import serialize from '@/api/resolvers/serial'
import { schnorr } from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { datePivot } from '@/lib/time' import { LNURLP_COMMENT_MAX_LENGTH, MAX_INVOICE_DESCRIPTION_LENGTH } from '@/lib/constants'
import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants' import { validateSchema, lud18PayerDataSchema, toPositiveBigInt } from '@/lib/validate'
import { validateSchema, lud18PayerDataSchema } from '@/lib/validate'
import assertGofacYourself from '@/api/resolvers/ofac' import assertGofacYourself from '@/api/resolvers/ofac'
import performPaidAction from '@/api/paidAction'
export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => { export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => {
const user = await models.user.findUnique({ where: { name: username } }) const user = await models.user.findUnique({ where: { name: username } })
@ -30,14 +28,16 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
// If there is an amount tag, it MUST be equal to the amount query parameter // If there is an amount tag, it MUST be equal to the amount query parameter
const eventAmount = note.tags?.find(t => t[0] === 'amount')?.[1] const eventAmount = note.tags?.find(t => t[0] === 'amount')?.[1]
if (schnorr.verify(note.sig, note.id, note.pubkey) && hasPTag && hasETag && (!eventAmount || Number(eventAmount) === Number(amount))) { if (schnorr.verify(note.sig, note.id, note.pubkey) && hasPTag && hasETag && (!eventAmount || Number(eventAmount) === Number(amount))) {
description = user.hideInvoiceDesc ? undefined : 'zap' description = 'zap'
descriptionHash = createHash('sha256').update(noteStr).digest('hex') descriptionHash = createHash('sha256').update(noteStr).digest('hex')
} else { } else {
res.status(400).json({ status: 'ERROR', reason: 'invalid NIP-57 note' }) res.status(400).json({ status: 'ERROR', reason: 'invalid NIP-57 note' })
return return
} }
} else { } else {
description = user.hideInvoiceDesc ? undefined : `Funding @${username} on stacker.news` description = `Paying @${username} on stacker.news`
description += comment ? `: ${comment}` : '.'
description = description.slice(0, MAX_INVOICE_DESCRIPTION_LENGTH)
descriptionHash = lnurlPayDescriptionHashForUser(username) descriptionHash = lnurlPayDescriptionHashForUser(username)
} }
@ -45,8 +45,11 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' }) return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' })
} }
if (comment && comment.length > LNURLP_COMMENT_MAX_LENGTH) { if (comment?.length > LNURLP_COMMENT_MAX_LENGTH) {
return res.status(400).json({ status: 'ERROR', reason: `comment cannot exceed ${LNURLP_COMMENT_MAX_LENGTH} characters in length` }) return res.status(400).json({
status: 'ERROR',
reason: `comment cannot exceed ${LNURLP_COMMENT_MAX_LENGTH} characters in length`
})
} }
let parsedPayerData let parsedPayerData
@ -55,7 +58,10 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
parsedPayerData = JSON.parse(payerData) parsedPayerData = JSON.parse(payerData)
} catch (err) { } catch (err) {
console.error('failed to parse payerdata', err) console.error('failed to parse payerdata', err)
return res.status(400).json({ status: 'ERROR', reason: 'Invalid JSON supplied for payerdata parameter' }) return res.status(400).json({
status: 'ERROR',
reason: 'Invalid JSON supplied for payerdata parameter'
})
} }
try { try {
@ -71,27 +77,20 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
} }
// generate invoice // generate invoice
const expiresAt = datePivot(new Date(), { minutes: 5 }) const { invoice } = await performPaidAction('RECEIVE', {
const invoice = await createInvoice({ msats: toPositiveBigInt(amount),
description, description,
description_hash: descriptionHash, descriptionHash,
lnd, comment: comment || '',
mtokens: amount, lud18Data: parsedPayerData
expires_at: expiresAt }, { models, lnd, me: user })
})
await serialize( if (!invoice?.bolt11) throw new Error('could not generate invoice')
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.secret}::TEXT, ${invoice.request},
${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description},
${comment || null}, ${parsedPayerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER,
${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`,
{ models }
)
return res.status(200).json({ return res.status(200).json({
pr: invoice.request, pr: invoice.bolt11,
routes: [], routes: [],
verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.id}` verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.hash}`
}) })
} catch (error) { } catch (error) {
console.log(error) console.log(error)

View File

@ -6,8 +6,13 @@ export default async ({ query: { hash } }, res) => {
if (!inv) { if (!inv) {
return res.status(404).json({ status: 'ERROR', reason: 'not found' }) return res.status(404).json({ status: 'ERROR', reason: 'not found' })
} }
const settled = inv.confirmedAt const settled = !!inv.confirmedAt
return res.status(200).json({ status: 'OK', settled: !!settled, preimage: settled ? inv.preimage : null, pr: inv.bolt11 }) return res.status(200).json({
status: 'OK',
settled,
preimage: settled ? inv.preimage : null,
pr: inv.bolt11
})
} catch (err) { } catch (err) {
console.log('error', err) console.log('error', err)
return res.status(500).json({ status: 'ERROR', reason: 'internal server error' }) return res.status(500).json({ status: 'ERROR', reason: 'internal server error' })

View File

@ -158,7 +158,8 @@ export default function Settings ({ ssrData }) {
hideWalletBalance: settings?.hideWalletBalance, hideWalletBalance: settings?.hideWalletBalance,
diagnostics: settings?.diagnostics, diagnostics: settings?.diagnostics,
hideIsContributor: settings?.hideIsContributor, hideIsContributor: settings?.hideIsContributor,
noReferralLinks: settings?.noReferralLinks noReferralLinks: settings?.noReferralLinks,
proxyReceive: settings?.proxyReceive
}} }}
schema={settingsSchema} schema={settingsSchema}
onSubmit={async ({ onSubmit={async ({
@ -332,7 +333,22 @@ export default function Settings ({ ssrData }) {
label='I find or lose cowboy essentials (e.g. cowboy hat)' label='I find or lose cowboy essentials (e.g. cowboy hat)'
name='noteCowboyHat' name='noteCowboyHat'
/> />
<div className='form-label'>privacy</div> <div className='form-label'>wallet</div>
<Checkbox
label={
<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>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>
</Info>
</div>
}
name='proxyReceive'
groupClassName='mb-0'
/>
<Checkbox <Checkbox
label={ label={
<div className='d-flex align-items-center'>hide invoice descriptions <div className='d-flex align-items-center'>hide invoice descriptions
@ -353,6 +369,7 @@ export default function Settings ({ ssrData }) {
groupClassName='mb-0' groupClassName='mb-0'
/> />
<DropBolt11sCheckbox <DropBolt11sCheckbox
groupClassName='mb-0'
ssrData={ssrData} ssrData={ssrData}
label={ label={
<div className='d-flex align-items-center'>autodelete withdrawal invoices <div className='d-flex align-items-center'>autodelete withdrawal invoices
@ -367,8 +384,12 @@ export default function Settings ({ ssrData }) {
</div> </div>
} }
name='autoDropBolt11s' name='autoDropBolt11s'
groupClassName='mb-0'
/> />
<Checkbox
label={<>hide my wallet balance</>}
name='hideWalletBalance'
/>
<div className='form-label'>privacy</div>
<Checkbox <Checkbox
label={<>hide me from <Link href='/top/stackers/day'>top stackers</Link></>} label={<>hide me from <Link href='/top/stackers/day'>top stackers</Link></>}
name='hideFromTopUsers' name='hideFromTopUsers'
@ -379,11 +400,6 @@ export default function Settings ({ ssrData }) {
name='hideCowboyHat' name='hideCowboyHat'
groupClassName='mb-0' groupClassName='mb-0'
/> />
<Checkbox
label={<>hide my wallet balance</>}
name='hideWalletBalance'
groupClassName='mb-0'
/>
<Checkbox <Checkbox
label={<>hide my bookmarks from other stackers</>} label={<>hide my bookmarks from other stackers</>}
name='hideBookmarks' name='hideBookmarks'

View File

@ -16,7 +16,8 @@ import { gql } from 'graphql-tag'
import { useShowModal } from '@/components/modal' import { useShowModal } from '@/components/modal'
import { DeleteConfirm } from '@/components/delete' import { DeleteConfirm } from '@/components/delete'
import { getGetServerSideProps } from '@/api/ssrApollo' import { getGetServerSideProps } from '@/api/ssrApollo'
import { Badge } from 'react-bootstrap'
import styles from '@/components/invoice.module.css'
// force SSR to include CSP nonces // force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null }) export const getServerSideProps = getGetServerSideProps({ query: null })
@ -68,7 +69,11 @@ function LoadWithdrawl () {
let variant = 'default' let variant = 'default'
switch (data.withdrawl.status) { switch (data.withdrawl.status) {
case 'CONFIRMED': case 'CONFIRMED':
status = `sent ${numWithUnits(data.withdrawl.satsPaid, { abbreviate: false })} with ${numWithUnits(data.withdrawl.satsFeePaid, { abbreviate: false })} in routing fees` if (data.withdrawl.forwardedActionType) {
status = <>{`forwarded ${numWithUnits(data.withdrawl.satsPaid, { abbreviate: false })}`} <Badge className={styles.badge} bg={null}>p2p</Badge></>
} else {
status = `sent ${numWithUnits(data.withdrawl.satsPaid, { abbreviate: false })} with ${numWithUnits(data.withdrawl.satsFeePaid, { abbreviate: false })} in routing fees`
}
variant = 'confirmed' variant = 'confirmed'
break break
case 'INSUFFICIENT_BALANCE': case 'INSUFFICIENT_BALANCE':

View File

@ -0,0 +1,13 @@
-- AlterEnum
ALTER TYPE "InvoiceActionType" ADD VALUE 'RECEIVE';
-- AlterTable
ALTER TABLE "users" ADD COLUMN "proxyReceive" BOOLEAN NOT NULL DEFAULT false;
DROP FUNCTION IF EXISTS create_invoice;
-- Add unique index for Withdrawl table
-- to prevent multiple pending withdrawls with the same hash
CREATE UNIQUE INDEX "Withdrawl_hash_key_null_status"
ON "Withdrawl" (hash)
WHERE status IS NULL OR status = 'CONFIRMED';

View File

@ -140,6 +140,7 @@ model User {
vaultKeyHash String @default("") vaultKeyHash String @default("")
walletsUpdatedAt DateTime? walletsUpdatedAt DateTime?
vaultEntries VaultEntry[] @relation("VaultEntries") vaultEntries VaultEntry[] @relation("VaultEntries")
proxyReceive Boolean @default(false)
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -864,6 +865,7 @@ enum InvoiceActionType {
TERRITORY_UPDATE TERRITORY_UPDATE
TERRITORY_BILLING TERRITORY_BILLING
TERRITORY_UNARCHIVE TERRITORY_UNARCHIVE
RECEIVE
} }
enum InvoiceActionState { enum InvoiceActionState {

View File

@ -73,7 +73,7 @@ EOF
# create client.js # create client.js
cat > wallets/$wallet/client.js <<EOF cat > wallets/$wallet/client.js <<EOF
export * from 'wallets/$wallet' export * from '@/wallets/$wallet'
export async function testSendPayment (config, { logger }) { export async function testSendPayment (config, { logger }) {
$(todo) $(todo)
@ -87,7 +87,7 @@ EOF
# create server.js # create server.js
cat > wallets/$wallet/server.js <<EOF cat > wallets/$wallet/server.js <<EOF
export * from 'wallets/$wallet' export * from '@/wallets/$wallet'
export async function testCreateInvoice (config) { export async function testCreateInvoice (config) {
$(todo) $(todo)

View File

@ -23,13 +23,13 @@ A _server.js_ file is only required for wallets that support receiving by exposi
> Every wallet must have a client.js file (even if it does not support paying invoices) because every wallet is imported on the client. This is not the case on the server. On the client, wallets are imported via > Every wallet must have a client.js file (even if it does not support paying invoices) because every wallet is imported on the client. This is not the case on the server. On the client, wallets are imported via
> >
> ```js > ```js
> import wallet from 'wallets/<name>/client' > import wallet from '@/wallets/<name>/client'
> ``` > ```
> >
> vs > vs
> >
> ```js > ```js
> import wallet from 'wallets/<name>/server' > import wallet from '@/wallets/<name>/server'
> ``` > ```
> >
> on the server. > on the server.
@ -37,7 +37,7 @@ A _server.js_ file is only required for wallets that support receiving by exposi
> To have access to the properties that can be shared between client and server, server.js and client.js always reexport everything in index.js with a line like this: > To have access to the properties that can be shared between client and server, server.js and client.js always reexport everything in index.js with a line like this:
> >
> ```js > ```js
> export * from 'wallets/<name>' > export * from '@/wallets/<name>'
> ``` > ```
> >
> If a wallet does not support paying invoices, this is all that client.js of this wallet does. The reason for this structure is to make sure the client does not import dependencies that can only be imported on the server and would thus break the build. > If a wallet does not support paying invoices, this is all that client.js of this wallet does. The reason for this structure is to make sure the client does not import dependencies that can only be imported on the server and would thus break the build.
@ -181,7 +181,7 @@ The first argument is the [BOLT11 payment request](https://github.com/lightning/
> >
> ```js > ```js
> // wallets/<wallet>/client.js > // wallets/<wallet>/client.js
> export * from 'wallets/<name>' > export * from '@/wallets/<name>'
> ``` > ```
> >
> where `<name>` is the wallet directory name. > where `<name>` is the wallet directory name.
@ -191,13 +191,13 @@ The first argument is the [BOLT11 payment request](https://github.com/lightning/
> >
> ```diff > ```diff
> // wallets/client.js > // wallets/client.js
> import * as nwc from 'wallets/nwc/client' > import * as nwc from '@/wallets/nwc/client'
> import * as lnbits from 'wallets/lnbits/client' > import * as lnbits from '@/wallets/lnbits/client'
> import * as lnc from 'wallets/lnc/client' > import * as lnc from '@/wallets/lnc/client'
> import * as lnAddr from 'wallets/lightning-address/client' > import * as lnAddr from '@/wallets/lightning-address/client'
> import * as cln from 'wallets/cln/client' > import * as cln from '@/wallets/cln/client'
> import * as lnd from 'wallets/lnd/client' > import * as lnd from '@/wallets/lnd/client'
> + import * as newWallet from 'wallets/<name>/client' > + import * as newWallet from '@/wallets/<name>/client'
> >
> - export default [nwc, lnbits, lnc, lnAddr, cln, lnd] > - export default [nwc, lnbits, lnc, lnAddr, cln, lnd]
> + export default [nwc, lnbits, lnc, lnAddr, cln, lnd, newWallet] > + export default [nwc, lnbits, lnc, lnAddr, cln, lnd, newWallet]
@ -225,7 +225,7 @@ Again, like `testSendPayment`, the first argument is the wallet configuration th
> >
> ```js > ```js
> // wallets/<wallet>/server.js > // wallets/<wallet>/server.js
> export * from 'wallets/<name>' > export * from '@/wallets/<name>'
> ``` > ```
> >
> where `<name>` is the wallet directory name. > where `<name>` is the wallet directory name.
@ -235,10 +235,10 @@ Again, like `testSendPayment`, the first argument is the wallet configuration th
> >
> ```diff > ```diff
> // wallets/server.js > // wallets/server.js
> import * as lnd from 'wallets/lnd/server' > import * as lnd from '@/wallets/lnd/server'
> import * as cln from 'wallets/cln/server' > import * as cln from '@/wallets/cln/server'
> import * as lnAddr from 'wallets/lightning-address/server' > import * as lnAddr from '@/wallets/lightning-address/server'
> + import * as newWallet from 'wallets/<name>/client' > + import * as newWallet from '@/wallets/<name>/client'
> >
> - export default [lnd, cln, lnAddr] > - export default [lnd, cln, lnAddr]
> + export default [lnd, cln, lnAddr, newWallet] > + export default [lnd, cln, lnAddr, newWallet]

View File

@ -1,5 +1,5 @@
import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from 'wallets/blink/common' import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common'
export * from 'wallets/blink' export * from '@/wallets/blink'
export async function testSendPayment ({ apiKey, currency }, { logger }) { export async function testSendPayment ({ apiKey, currency }, { logger }) {
logger.info('trying to fetch ' + currency + ' wallet') logger.info('trying to fetch ' + currency + ' wallet')

View File

@ -1,5 +1,5 @@
import { string } from '@/lib/yup' import { string } from '@/lib/yup'
import { galoyBlinkDashboardUrl } from 'wallets/blink/common' import { galoyBlinkDashboardUrl } from '@/wallets/blink/common'
export const name = 'blink' export const name = 'blink'
export const walletType = 'BLINK' export const walletType = 'BLINK'

View File

@ -1,7 +1,7 @@
import { withTimeout } from '@/lib/time' import { withTimeout } from '@/lib/time'
import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from 'wallets/blink/common' import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
export * from 'wallets/blink' export * from '@/wallets/blink'
export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) { export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) {
const scopes = await getScopes(apiKeyRecv) const scopes = await getScopes(apiKeyRecv)

View File

@ -1,11 +1,11 @@
import * as nwc from 'wallets/nwc/client' import * as nwc from '@/wallets/nwc/client'
import * as lnbits from 'wallets/lnbits/client' import * as lnbits from '@/wallets/lnbits/client'
import * as lnc from 'wallets/lnc/client' import * as lnc from '@/wallets/lnc/client'
import * as lnAddr from 'wallets/lightning-address/client' import * as lnAddr from '@/wallets/lightning-address/client'
import * as cln from 'wallets/cln/client' import * as cln from '@/wallets/cln/client'
import * as lnd from 'wallets/lnd/client' import * as lnd from '@/wallets/lnd/client'
import * as webln from 'wallets/webln/client' import * as webln from '@/wallets/webln/client'
import * as blink from 'wallets/blink/client' import * as blink from '@/wallets/blink/client'
import * as phoenixd from 'wallets/phoenixd/client' import * as phoenixd from '@/wallets/phoenixd/client'
export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd] export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd]

View File

@ -1 +1 @@
export * from 'wallets/cln' export * from '@/wallets/cln'

View File

@ -1,6 +1,6 @@
import { createInvoice as clnCreateInvoice } from '@/lib/cln' import { createInvoice as clnCreateInvoice } from '@/lib/cln'
export * from 'wallets/cln' export * from '@/wallets/cln'
export const testCreateInvoice = async ({ socket, rune, cert }) => { export const testCreateInvoice = async ({ socket, rune, cert }) => {
return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }) return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert })

View File

@ -1,4 +1,4 @@
import walletDefs from 'wallets/client' import walletDefs from '@/wallets/client'
export const Status = { export const Status = {
Enabled: 'Enabled', Enabled: 'Enabled',

View File

@ -7,7 +7,7 @@ import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend, is
import useVault from '@/components/vault/use-vault' import useVault from '@/components/vault/use-vault'
import { useWalletLogger } from '@/components/wallet-logger' import { useWalletLogger } from '@/components/wallet-logger'
import { decode as bolt11Decode } from 'bolt11' import { decode as bolt11Decode } from 'bolt11'
import walletDefs from 'wallets/client' import walletDefs from '@/wallets/client'
import { generateMutation } from './graphql' import { generateMutation } from './graphql'
import { formatSats } from '@/lib/format' import { formatSats } from '@/lib/format'

View File

@ -1 +1 @@
export * from 'wallets/lightning-address' export * from '@/wallets/lightning-address'

View File

@ -1,7 +1,7 @@
import { msatsSatsFloor } from '@/lib/format' import { msatsSatsFloor } from '@/lib/format'
import { lnAddrOptions } from '@/lib/lnurl' import { lnAddrOptions } from '@/lib/lnurl'
export * from 'wallets/lightning-address' export * from '@/wallets/lightning-address'
export const testCreateInvoice = async ({ address }) => { export const testCreateInvoice = async ({ address }) => {
return await createInvoice({ msats: 1000 }, { address }) return await createInvoice({ msats: 1000 }, { address })

View File

@ -1,6 +1,6 @@
import { assertContentTypeJson } from '@/lib/url' import { assertContentTypeJson } from '@/lib/url'
export * from 'wallets/lnbits' export * from '@/wallets/lnbits'
export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) { export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) {
logger.info('trying to fetch wallet') logger.info('trying to fetch wallet')

View File

@ -3,7 +3,7 @@ import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson } from '@/lib/url' import { assertContentTypeJson } from '@/lib/url'
import fetch from 'cross-fetch' import fetch from 'cross-fetch'
export * from 'wallets/lnbits' export * from '@/wallets/lnbits'
export async function testCreateInvoice ({ url, invoiceKey }) { export async function testCreateInvoice ({ url, invoiceKey }) {
return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }) return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey })

View File

@ -1,7 +1,7 @@
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { Mutex } from 'async-mutex' import { Mutex } from 'async-mutex'
export * from 'wallets/lnc' export * from '@/wallets/lnc'
async function disconnect (lnc, logger) { async function disconnect (lnc, logger) {
if (lnc) { if (lnc) {

View File

@ -1 +1 @@
export * from 'wallets/lnd' export * from '@/wallets/lnd'

View File

@ -3,7 +3,7 @@ import { authenticatedLndGrpc } from '@/lib/lnd'
import { createInvoice as lndCreateInvoice } from 'ln-service' import { createInvoice as lndCreateInvoice } from 'ln-service'
import { TOR_REGEXP } from '@/lib/url' import { TOR_REGEXP } from '@/lib/url'
export * from 'wallets/lnd' export * from '@/wallets/lnd'
export const testCreateInvoice = async ({ cert, macaroon, socket }) => { export const testCreateInvoice = async ({ cert, macaroon, socket }) => {
return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket }) return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket })

View File

@ -1,5 +1,5 @@
import { nwcCall, supportedMethods } from 'wallets/nwc' import { nwcCall, supportedMethods } from '@/wallets/nwc'
export * from 'wallets/nwc' export * from '@/wallets/nwc'
export async function testSendPayment ({ nwcUrl }, { logger }) { export async function testSendPayment ({ nwcUrl }, { logger }) {
const timeout = 15_000 const timeout = 15_000

View File

@ -1,6 +1,6 @@
import { withTimeout } from '@/lib/time' import { withTimeout } from '@/lib/time'
import { nwcCall, supportedMethods } from 'wallets/nwc' import { nwcCall, supportedMethods } from '@/wallets/nwc'
export * from 'wallets/nwc' export * from '@/wallets/nwc'
export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) {
const timeout = 15_000 const timeout = 15_000

View File

@ -1,4 +1,4 @@
export * from 'wallets/phoenixd' export * from '@/wallets/phoenixd'
export async function testSendPayment (config, { logger }) { export async function testSendPayment (config, { logger }) {
// TODO: // TODO:

View File

@ -1,6 +1,6 @@
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
export * from 'wallets/phoenixd' export * from '@/wallets/phoenixd'
export async function testCreateInvoice ({ url, secondaryPassword }) { export async function testCreateInvoice ({ url, secondaryPassword }) {
return await createInvoice( return await createInvoice(

View File

@ -1,18 +1,18 @@
// import server side wallets // import server side wallets
import * as lnd from 'wallets/lnd/server' import * as lnd from '@/wallets/lnd/server'
import * as cln from 'wallets/cln/server' import * as cln from '@/wallets/cln/server'
import * as lnAddr from 'wallets/lightning-address/server' import * as lnAddr from '@/wallets/lightning-address/server'
import * as lnbits from 'wallets/lnbits/server' import * as lnbits from '@/wallets/lnbits/server'
import * as nwc from 'wallets/nwc/server' import * as nwc from '@/wallets/nwc/server'
import * as phoenixd from 'wallets/phoenixd/server' import * as phoenixd from '@/wallets/phoenixd/server'
import * as blink from 'wallets/blink/server' import * as blink from '@/wallets/blink/server'
// we import only the metadata of client side wallets // we import only the metadata of client side wallets
import * as lnc from 'wallets/lnc' import * as lnc from '@/wallets/lnc'
import * as webln from 'wallets/webln' import * as webln from '@/wallets/webln'
import { walletLogger } from '@/api/resolvers/wallet' import { walletLogger } from '@/api/resolvers/wallet'
import walletDefs from 'wallets/server' import walletDefs from '@/wallets/server'
import { parsePaymentRequest } from 'ln-service' import { parsePaymentRequest } from 'ln-service'
import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate' import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate'
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
@ -27,28 +27,11 @@ const MAX_PENDING_INVOICES_PER_WALLET = 25
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) { export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
// get the wallets in order of priority // get the wallets in order of priority
const wallets = await models.wallet.findMany({ const wallets = await getInvoiceableWallets(userId, { models })
where: { userId, enabled: true },
include: {
user: true
},
orderBy: [
{ priority: 'asc' },
// use id as tie breaker (older wallet first)
{ id: 'asc' }
]
})
msats = toPositiveNumber(msats) msats = toPositiveNumber(msats)
for (const wallet of wallets) { for (const { def, wallet } of wallets) {
const w = walletDefs.find(w => w.walletType === wallet.type)
const config = wallet.wallet
if (!canReceive({ def: w, config })) {
continue
}
const logger = walletLogger({ wallet, models }) const logger = walletLogger({ wallet, models })
try { try {
@ -61,8 +44,8 @@ export async function createInvoice (userId, { msats, description, descriptionHa
let invoice let invoice
try { try {
invoice = await walletCreateInvoice( invoice = await walletCreateInvoice(
{ wallet, def },
{ msats, description, descriptionHash, expiry }, { msats, description, descriptionHash, expiry },
{ ...w, userId, createInvoice: w.createInvoice },
{ logger, models }) { logger, models })
} catch (err) { } catch (err) {
throw new Error('failed to create invoice: ' + err.message) throw new Error('failed to create invoice: ' + err.message)
@ -128,37 +111,33 @@ export async function createWrappedInvoice (userId,
} }
} }
async function walletCreateInvoice ( export async function getInvoiceableWallets (userId, { models }) {
{ const wallets = await models.wallet.findMany({
msats, where: { userId, enabled: true },
description,
descriptionHash,
expiry = 360
},
{
userId,
walletType,
walletField,
createInvoice
},
{ logger, models }) {
const wallet = await models.wallet.findFirst({
where: {
userId,
type: walletType
},
include: { include: {
[walletField]: true,
user: true user: true
} },
orderBy: [
{ priority: 'asc' },
// use id as tie breaker (older wallet first)
{ id: 'asc' }
]
}) })
const config = wallet[walletField] const walletsWithDefs = wallets.map(wallet => {
const w = walletDefs.find(w => w.walletType === wallet.type)
return { wallet, def: w }
})
if (!wallet || !config) { return walletsWithDefs.filter(({ def, wallet }) => canReceive({ def, config: wallet.wallet }))
throw new Error('wallet not found') }
}
async function walletCreateInvoice ({ wallet, def }, {
msats,
description,
descriptionHash,
expiry = 360
}, { logger, models }) {
// check for pending withdrawals // check for pending withdrawals
const pendingWithdrawals = await models.withdrawl.count({ const pendingWithdrawals = await models.withdrawl.count({
where: { where: {
@ -185,14 +164,14 @@ async function walletCreateInvoice (
} }
return await withTimeout( return await withTimeout(
createInvoice( def.createInvoice(
{ {
msats, msats,
description: wallet.user.hideInvoiceDesc ? undefined : description, description: wallet.user.hideInvoiceDesc ? undefined : description,
descriptionHash, descriptionHash,
expiry expiry
}, },
config, wallet.wallet,
{ logger } { logger }
), 10_000) ), 10_000)
} }

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { SSR } from '@/lib/constants' import { SSR } from '@/lib/constants'
export * from 'wallets/webln' export * from '@/wallets/webln'
export const sendPayment = async (bolt11) => { export const sendPayment = async (bolt11) => {
if (typeof window.webln === 'undefined') { if (typeof window.webln === 'undefined') {

View File

@ -1,6 +1,6 @@
import { msatsSatsFloor, msatsToSats, satsToMsats } from '@/lib/format' import { msatsSatsFloor, msatsToSats, satsToMsats } from '@/lib/format'
import { createWithdrawal } from '@/api/resolvers/wallet' import { createWithdrawal } from '@/api/resolvers/wallet'
import { createInvoice } from 'wallets/server' import { createInvoice } from '@/wallets/server'
export async function autoWithdraw ({ data: { id }, models, lnd }) { export async function autoWithdraw ({ data: { id }, models, lnd }) {
const user = await models.user.findUnique({ where: { id } }) const user = await models.user.findUnique({ where: { id } })

View File

@ -11,14 +11,18 @@ import {
getInvoice, getPayment, parsePaymentRequest, getInvoice, getPayment, parsePaymentRequest,
payViaPaymentRequest, settleHodlInvoice payViaPaymentRequest, settleHodlInvoice
} from 'ln-service' } from 'ln-service'
import { MIN_SETTLEMENT_CLTV_DELTA } from 'wallets/wrap' import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap'
// aggressive finalization retry options // aggressive finalization retry options
const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 } const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 }
async function transitionInvoice (jobName, { invoiceId, fromState, toState, transition, invoice }, { models, lnd, boss }) { async function transitionInvoice (jobName,
{ invoiceId, fromState, toState, transition, invoice, onUnexpectedError },
{ models, lnd, boss }
) {
console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`) console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`)
let dbInvoice
try { try {
const currentDbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } }) const currentDbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
console.log('invoice is in state', currentDbInvoice.actionState) console.log('invoice is in state', currentDbInvoice.actionState)
@ -47,7 +51,7 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
} }
// grab optimistic concurrency lock and the invoice // grab optimistic concurrency lock and the invoice
const dbInvoice = await tx.invoice.update({ dbInvoice = await tx.invoice.update({
include, include,
where: { where: {
id: invoiceId, id: invoiceId,
@ -100,6 +104,7 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
} }
console.error('unexpected error', e) console.error('unexpected error', e)
onUnexpectedError?.({ error: e, dbInvoice, models, boss })
await boss.send( await boss.send(
jobName, jobName,
{ invoiceId }, { invoiceId },
@ -110,35 +115,35 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
} }
async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) { async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) {
try { const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id } const context = {
const context = { tx,
tx, cost: BigInt(lndInvoice.received_mtokens),
cost: BigInt(lndInvoice.received_mtokens), me: dbInvoice.user
me: dbInvoice.user
}
const sybilFeePercent = await paidActions[dbInvoice.actionType].getSybilFeePercent?.(args, context)
const result = await paidActions[dbInvoice.actionType].perform(args, { ...context, sybilFeePercent })
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
} }
const sybilFeePercent = await paidActions[dbInvoice.actionType].getSybilFeePercent?.(args, context)
const result = await paidActions[dbInvoice.actionType].perform(args, { ...context, sybilFeePercent })
await tx.invoice.update({
where: { id: dbInvoice.id },
data: {
actionResult: result,
actionError: null
}
})
}
// if we experience an unexpected error when holding an invoice, we need aggressively attempt to cancel it
// store the error in the invoice, nonblocking and outside of this tx, finalizing immediately
function onHeldInvoiceError ({ error, dbInvoice, models, boss }) {
models.invoice.update({
where: { id: dbInvoice.id },
data: {
actionError: error.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))
} }
export async function paidActionPaid ({ data: { invoiceId, ...args }, models, lnd, boss }) { export async function paidActionPaid ({ data: { invoiceId, ...args }, models, lnd, boss }) {
@ -151,9 +156,17 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln
throw new Error('invoice is not confirmed') throw new Error('invoice is not confirmed')
} }
await paidActions[dbInvoice.actionType].onPaid?.({ invoice: dbInvoice }, { models, tx, lnd }) const updateFields = {
confirmedAt: new Date(lndInvoice.confirmed_at),
confirmedIndex: lndInvoice.confirmed_index,
msatsReceived: BigInt(lndInvoice.received_mtokens)
}
// any paid action is eligible for a cowboy hat streak await paidActions[dbInvoice.actionType].onPaid?.({
invoice: { ...dbInvoice, ...updateFields }
}, { models, tx, lnd })
// most paid actions are eligible for a cowboy hat streak
await tx.$executeRaw` await tx.$executeRaw`
INSERT INTO pgboss.job (name, data) INSERT INTO pgboss.job (name, data)
VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))` VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))`
@ -166,11 +179,7 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln
('checkStreak', jsonb_build_object('id', ${dbInvoice.invoiceForward.withdrawl.userId}, 'type', 'HORSE'))` ('checkStreak', jsonb_build_object('id', ${dbInvoice.invoiceForward.withdrawl.userId}, 'type', 'HORSE'))`
} }
return { return updateFields
confirmedAt: new Date(lndInvoice.confirmed_at),
confirmedIndex: lndInvoice.confirmed_index,
msatsReceived: BigInt(lndInvoice.received_mtokens)
}
}, },
...args ...args
}, { models, lnd, boss }) }, { models, lnd, boss })
@ -241,6 +250,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode
} }
} }
}, },
onUnexpectedError: onHeldInvoiceError,
...args ...args
}, { models, lnd, boss }) }, { models, lnd, boss })
@ -410,6 +420,7 @@ export async function paidActionHeld ({ data: { invoiceId, ...args }, models, ln
msatsReceived: BigInt(lndInvoice.received_mtokens) msatsReceived: BigInt(lndInvoice.received_mtokens)
} }
}, },
onUnexpectedError: onHeldInvoiceError,
...args ...args
}, { models, lnd, boss }) }, { models, lnd, boss })
} }

View File

@ -99,7 +99,7 @@ function getStreakQuery (type, userId) {
FROM "Invoice" FROM "Invoice"
JOIN "InvoiceForward" ON "Invoice".id = "InvoiceForward"."invoiceId" JOIN "InvoiceForward" ON "Invoice".id = "InvoiceForward"."invoiceId"
WHERE ("Invoice"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} WHERE ("Invoice"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
AND "Invoice"."actionState" = 'PAID' AND "Invoice"."actionState" = 'PAID' AND "Invoice"."actionType" = 'ZAP'
${userId ? Prisma.sql`AND "Invoice"."userId" = ${userId}` : Prisma.empty} ${userId ? Prisma.sql`AND "Invoice"."userId" = ${userId}` : Prisma.empty}
GROUP BY "Invoice"."userId" GROUP BY "Invoice"."userId"
HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${GUN_STREAK_THRESHOLD}` HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${GUN_STREAK_THRESHOLD}`
@ -112,7 +112,7 @@ function getStreakQuery (type, userId) {
JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId" JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId"
JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
WHERE ("Withdrawl"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment} WHERE ("Withdrawl"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
AND "Invoice"."actionState" = 'PAID' AND "Invoice"."actionState" = 'PAID' AND "Invoice"."actionType" = 'ZAP'
${userId ? Prisma.sql`AND "Withdrawl"."userId" = ${userId}` : Prisma.empty} ${userId ? Prisma.sql`AND "Withdrawl"."userId" = ${userId}` : Prisma.empty}
GROUP BY "Withdrawl"."userId" GROUP BY "Withdrawl"."userId"
HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${HORSE_STREAK_THRESHOLD}` HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${HORSE_STREAK_THRESHOLD}`

View File

@ -144,10 +144,8 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd
return await paidActionPaid({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) return await paidActionPaid({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss })
} }
// NOTE: confirm invoice prevents double confirmations (idempotent) // XXX we need to keep this to allow production to migrate to new paidAction flow
// ALSO: is_confirmed and is_held are mutually exclusive // once all non-paidAction receive invoices are migrated, we can remove this
// that is, a hold invoice will first be is_held but not is_confirmed
// and once it's settled it will be is_confirmed but not is_held
const [[{ confirm_invoice: code }]] = await serialize([ const [[{ confirm_invoice: code }]] = await serialize([
models.$queryRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`, models.$queryRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`,
models.invoice.update({ where: { hash }, data: { confirmedIndex: inv.confirmed_index } }) models.invoice.update({ where: { hash }, data: { confirmedIndex: inv.confirmed_index } })
@ -171,26 +169,6 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd
} }
return await paidActionHeld({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) return await paidActionHeld({ data: { invoiceId: dbInv.id, invoice: inv }, 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
// force closures or wallets banning us.
// Second query is basically confirm_invoice without setting confirmed_at
// and without setting the user balance
// those will be set when the invoice is settled by user action
const expiresAt = new Date(Math.min(dbInv.expiresAt, datePivot(new Date(), { seconds: 60 })))
return await serialize([
models.$queryRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${hash}), 21, true, ${expiresAt})`,
models.invoice.update({
where: { hash },
data: {
msatsReceived: Number(inv.received_mtokens),
expiresAt,
isHeld: true
}
})
], { models })
} }
if (inv.is_canceled) { if (inv.is_canceled) {