Compare commits
2 Commits
8c43caed80
...
79ada2ab58
Author | SHA1 | Date | |
---|---|---|---|
|
79ada2ab58 | ||
|
9c55f1ebe2 |
@ -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.
|
||||||
|
@ -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')
|
||||||
|
65
api/paidAction/lib/assert.js
Normal file
65
api/paidAction/lib/assert.js
Normal 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
84
api/paidAction/receive.js
Normal 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}))`
|
||||||
|
}
|
@ -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 () {
|
||||||
|
@ -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,
|
||||||
|
@ -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})`
|
||||||
)
|
)
|
||||||
|
@ -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')
|
||||||
}
|
}
|
||||||
|
@ -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 }]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -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(
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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'
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
.badge {
|
||||||
|
color: var(--theme-grey) !important;
|
||||||
|
background: var(--theme-clickToContextColor) !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
@ -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/*"
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -49,7 +49,8 @@ function getClient (uri) {
|
|||||||
'ItemActPaidAction',
|
'ItemActPaidAction',
|
||||||
'PollVotePaidAction',
|
'PollVotePaidAction',
|
||||||
'SubPaidAction',
|
'SubPaidAction',
|
||||||
'DonatePaidAction'
|
'DonatePaidAction',
|
||||||
|
'ReceivePaidAction'
|
||||||
],
|
],
|
||||||
Notification: [
|
Notification: [
|
||||||
'Reply',
|
'Reply',
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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' })
|
||||||
|
@ -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'
|
||||||
|
@ -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':
|
||||||
|
@ -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';
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
@ -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]
|
||||||
|
@ -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')
|
||||||
|
@ -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'
|
||||||
|
@ -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)
|
||||||
|
@ -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]
|
||||||
|
@ -1 +1 @@
|
|||||||
export * from 'wallets/cln'
|
export * from '@/wallets/cln'
|
||||||
|
@ -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 })
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import walletDefs from 'wallets/client'
|
import walletDefs from '@/wallets/client'
|
||||||
|
|
||||||
export const Status = {
|
export const Status = {
|
||||||
Enabled: 'Enabled',
|
Enabled: 'Enabled',
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -1 +1 @@
|
|||||||
export * from 'wallets/lightning-address'
|
export * from '@/wallets/lightning-address'
|
||||||
|
@ -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 })
|
||||||
|
@ -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')
|
||||||
|
@ -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 })
|
||||||
|
@ -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) {
|
||||||
|
@ -1 +1 @@
|
|||||||
export * from 'wallets/lnd'
|
export * from '@/wallets/lnd'
|
||||||
|
@ -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 })
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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') {
|
||||||
|
@ -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 } })
|
||||||
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
@ -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}`
|
||||||
|
@ -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) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user