Implement deposit as receive paidAction (#1570)

* lnurlp paid action

* lnurlp has 10% sybil fee

* fix merge issue

* Update pages/settings/index.js

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

* fix notifications

* fix destructure

* pass lud18Data to lnurlp action

* minor cleanup

* truncate invoice description to permitted length

* remove redundant targetUserId

* lnurlp paidAction -> receive paidAction

* remove redundant user query

* improve determining if peer is invoiceable

* fix inconsistent relative imports

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

* make gun/horse streak zap specific

* unique withdrawal hash should apply to confirmed payments too

* prevent receive from exceeding wallet limits

* notifications

* fix notifications & enhance invoice/withdrawl page

* notification indicator, proxy receive based on threshold, refinements

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
Riccardo Balbo 2024-11-16 01:38:14 +01:00 committed by GitHub
parent 8c43caed80
commit 9c55f1ebe2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 577 additions and 368 deletions

View File

@ -103,6 +103,7 @@ stateDiagram-v2
| donations | x | | x | x | x | | |
| update posts | x | | x | | x | | x |
| update comments | x | | x | | x | | x |
| receive | | x | | x | x | x | x |
## Not-custodial zaps (ie p2p wrapped payments)
Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap.

View File

@ -1,8 +1,11 @@
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
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 { Prisma } from '@prisma/client'
import { createWrappedInvoice } from '@/wallets/server'
import { assertBelowMaxPendingInvoices } from './lib/assert'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
import * as ZAP from './zap'
@ -14,7 +17,7 @@ import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
import * as BOOST from './boost'
import { createWrappedInvoice } from 'wallets/server'
import * as RECEIVE from './receive'
export const paidActions = {
ITEM_CREATE,
@ -27,7 +30,8 @@ export const paidActions = {
TERRITORY_UPDATE,
TERRITORY_BILLING,
TERRITORY_UNARCHIVE,
DONATE
DONATE,
RECEIVE
}
export default async function performPaidAction (actionType, args, incomingContext) {
@ -52,8 +56,7 @@ export default async function performPaidAction (actionType, args, incomingConte
}
const context = {
...contextWithMe,
cost: await paidAction.getCost(args, contextWithMe),
sybilFeePercent: await paidAction.getSybilFeePercent?.(args, contextWithMe)
cost: await paidAction.getCost(args, contextWithMe)
}
// special case for zero cost actions
@ -183,19 +186,25 @@ async function beginPessimisticAction (actionType, args, context) {
async function performP2PAction (actionType, args, incomingContext) {
// if the action has an invoiceable peer, we'll create a peer 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) {
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) {
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, {
msats: cost,
feePercent: sybilFeePercent,
@ -204,7 +213,7 @@ async function performP2PAction (actionType, args, incomingContext) {
}, { models, me, lnd })
const context = {
...incomingContext,
...contextWithSybilFeePercent,
invoiceArgs: {
bolt11: invoice,
wrappedBolt11: wrappedInvoice,
@ -282,23 +291,6 @@ export async function retryPaidAction (actionType, args, incomingContext) {
}
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 {
constructor () {
@ -314,6 +306,8 @@ async function createSNInvoice (actionType, args, context) {
const action = paidActions[actionType]
const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice
await assertBelowMaxPendingInvoices(context)
if (cost < 1000n) {
// sanity check
throw new Error('The cost of the action must be at least 1 sat')

View File

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

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

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

View File

@ -1,6 +1,7 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { notifyZapped } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
export const anonable = true
@ -18,18 +19,13 @@ export async function getCost ({ sats }) {
export async function getInvoiceablePeer ({ id }, { models }) {
const item = await models.item.findUnique({
where: { id: parseInt(id) },
include: {
itemForwards: true,
user: {
include: {
wallets: true
}
}
}
include: { itemForwards: true }
})
const wallets = await getInvoiceableWallets(item.userId, { models })
// 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 () {

View File

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

View File

@ -217,14 +217,20 @@ export default {
if (meFull.noteDeposits) {
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
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "confirmedAt" IS NOT NULL
AND "isHeld" IS NULL
AND "actionState" IS NULL
AND created_at < $2
AND "Invoice"."confirmedAt" IS NOT NULL
AND "Invoice"."created_at" < $2
AND (
("Invoice"."isHeld" IS NULL AND "Invoice"."actionType" IS NULL)
OR (
"Invoice"."actionType" = 'RECEIVE'
AND "Invoice"."actionState" = 'PAID'
)
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
@ -232,12 +238,17 @@ export default {
if (meFull.noteWithdrawals) {
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
FROM "Withdrawl"
LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id
LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
WHERE "Withdrawl"."userId" = $1
AND status = 'CONFIRMED'
AND created_at < $2
AND "Withdrawl".status = 'CONFIRMED'
AND "Withdrawl".created_at < $2
AND ("InvoiceForward"."id" IS NULL OR "Invoice"."actionType" = 'ZAP')
GROUP BY "Withdrawl".id
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import {
createHodlInvoice, createInvoice, payViaPaymentRequest,
payViaPaymentRequest,
getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
parsePaymentRequest
} from 'ln-service'
@ -7,24 +7,23 @@ import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal } from '@/lib/format'
import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import {
ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS,
INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS
USER_ID, INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS
} from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from 'worker/wallet'
import walletDefs from 'wallets/server'
import { finalizeHodlInvoice } from '@/worker/wallet'
import walletDefs from '@/wallets/server'
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
import { canReceive } from '@/wallets/common'
import performPaidAction from '../paidAction'
function injectResolvers (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({
where: {
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')
}
if (inv.user.id === USER_ID.anon) {
if (inv.userId === USER_ID.anon) {
return inv
}
if (!me) {
throw new GqlAuthenticationError()
}
if (inv.user.id !== me.id) {
if (inv.userId !== me.id) {
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
}
@ -128,10 +111,6 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
const wdrwl = await models.withdrawl.findUnique({
where: {
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')
}
if (wdrwl.user.id !== me.id) {
if (wdrwl.userId !== me.id) {
throw new GqlInputError('not ur withdrawal')
}
@ -458,50 +437,15 @@ const resolvers = {
__resolveType: wallet => wallet.__resolveType
},
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 assertGofacYourself({ models, headers })
let expirePivot = { seconds: expireSecs }
let invLimit = INV_PENDING_LIMIT
let balanceLimit = (hodlInvoice || USER_IDS_BALANCE_NO_LIMIT.includes(Number(me?.id))) ? 0 : BALANCE_LIMIT_MSATS
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 { invoice } = await performPaidAction('RECEIVE', {
msats: satsToMsats(amount)
}, { models, lnd, me })
const user = await models.user.findUnique({ where: { id } })
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
}
return invoice
},
createWithdrawl: createWithdrawal,
sendToLnAddr,
@ -596,7 +540,15 @@ const resolvers = {
satsPaid: w => msatsToSats(w.msatsPaid),
satsFeePaying: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaying),
satsFeePaid: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaid),
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 }) => {
try {
if (withdrawl.status === 'CONFIRMED') {
@ -611,6 +563,35 @@ const resolvers = {
Invoice: {
satsReceived: i => msatsToSats(i.msatsReceived),
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 }) => {
if (!invoice.actionId) return null
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 } })
// 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
// create withdrawl transactionally (id, bolt11, amount, fee)
const [withdrawl] = await serialize(

View File

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

View File

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

View File

@ -14,6 +14,8 @@ import Item from './item'
import { CommentFlat } from './comment'
import classNames from 'classnames'
import Moon from '@/svgs/moon-fill.svg'
import { Badge } from 'react-bootstrap'
import styles from './invoice.module.css'
export default function Invoice ({
id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb = 'deposited',
@ -54,10 +56,27 @@ export default function Invoice ({
let variant = 'default'
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) {
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
} else if (invoice.cancelled) {
variant = 'failed'

View File

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

View File

@ -326,10 +326,10 @@ function NostrZap ({ n }) {
)
}
function InvoicePaid ({ n }) {
function getPayerSig (lud18Data) {
let payerSig
if (n.invoice.lud18Data) {
const { name, identifier, email, pubkey } = n.invoice.lud18Data
if (lud18Data) {
const { name, identifier, email, pubkey } = lud18Data
const id = identifier || email || pubkey
payerSig = '- '
if (name) {
@ -339,10 +339,23 @@ function InvoicePaid ({ n }) {
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 (
<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>
{n.invoice.forwardedSats && <Badge className={styles.badge} bg={null}>p2p</Badge>}
{n.invoice.comment &&
<small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'>
<Text>{n.invoice.comment}</Text>
@ -484,13 +497,17 @@ function Invoicification ({ n: { invoice, sortTime } }) {
}
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 (
<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 ' })}
{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>
{(n.withdrawl.p2p && <Badge className={styles.badge} bg={null}>p2p</Badge>) ||
(n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>)}
{(n.withdrawl.forwardedActionType === 'ZAP' && <Badge className={styles.badge} bg={null}>p2p</Badge>) ||
(n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>)}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -59,8 +59,6 @@ export const USER_ID = {
}
export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn]
export const SN_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 MIN_POLL_NUM_CHOICES = 2
export const POLL_COST = 1
@ -80,10 +78,11 @@ export const SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5
export const LND_PATHFINDING_TIMEOUT_MS = 30000
export const LNURLP_COMMENT_MAX_LENGTH = 1000
// https://github.com/lightning/bolts/issues/236
export const MAX_INVOICE_DESCRIPTION_LENGTH = 640
export const RESERVED_MAX_USER_ID = 615
export const GLOBAL_SEED = USER_ID.k00b
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
// From lawyers: north korea, cuba, iran, ukraine, syria

View File

@ -1,14 +1,12 @@
import models from '@/api/models'
import lnd from '@/api/lnd'
import { createInvoice } from 'ln-service'
import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl'
import serialize from '@/api/resolvers/serial'
import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto'
import { datePivot } from '@/lib/time'
import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants'
import { validateSchema, lud18PayerDataSchema } from '@/lib/validate'
import { LNURLP_COMMENT_MAX_LENGTH, MAX_INVOICE_DESCRIPTION_LENGTH } from '@/lib/constants'
import { validateSchema, lud18PayerDataSchema, toPositiveBigInt } from '@/lib/validate'
import assertGofacYourself from '@/api/resolvers/ofac'
import performPaidAction from '@/api/paidAction'
export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => {
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
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))) {
description = user.hideInvoiceDesc ? undefined : 'zap'
description = 'zap'
descriptionHash = createHash('sha256').update(noteStr).digest('hex')
} else {
res.status(400).json({ status: 'ERROR', reason: 'invalid NIP-57 note' })
return
}
} 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)
}
@ -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' })
}
if (comment && comment.length > LNURLP_COMMENT_MAX_LENGTH) {
return res.status(400).json({ status: 'ERROR', reason: `comment cannot exceed ${LNURLP_COMMENT_MAX_LENGTH} characters in 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`
})
}
let parsedPayerData
@ -55,7 +58,10 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
parsedPayerData = JSON.parse(payerData)
} catch (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 {
@ -71,27 +77,20 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
}
// generate invoice
const expiresAt = datePivot(new Date(), { minutes: 5 })
const invoice = await createInvoice({
const { invoice } = await performPaidAction('RECEIVE', {
msats: toPositiveBigInt(amount),
description,
description_hash: descriptionHash,
lnd,
mtokens: amount,
expires_at: expiresAt
})
descriptionHash,
comment: comment || '',
lud18Data: parsedPayerData
}, { models, lnd, me: user })
await serialize(
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 }
)
if (!invoice?.bolt11) throw new Error('could not generate invoice')
return res.status(200).json({
pr: invoice.request,
pr: invoice.bolt11,
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) {
console.log(error)

View File

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

View File

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

View File

@ -16,7 +16,8 @@ import { gql } from 'graphql-tag'
import { useShowModal } from '@/components/modal'
import { DeleteConfirm } from '@/components/delete'
import { getGetServerSideProps } from '@/api/ssrApollo'
import { Badge } from 'react-bootstrap'
import styles from '@/components/invoice.module.css'
// force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null })
@ -68,7 +69,11 @@ function LoadWithdrawl () {
let variant = 'default'
switch (data.withdrawl.status) {
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'
break
case 'INSUFFICIENT_BALANCE':

View File

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

View File

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

View File

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

View File

@ -23,13 +23,13 @@ A _server.js_ file is only required for wallets that support receiving by exposi
> Every wallet must have a client.js file (even if it does not support paying invoices) because every wallet is imported on the client. This is not the case on the server. On the client, wallets are imported via
>
> ```js
> import wallet from 'wallets/<name>/client'
> import wallet from '@/wallets/<name>/client'
> ```
>
> vs
>
> ```js
> import wallet from 'wallets/<name>/server'
> import wallet from '@/wallets/<name>/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:
>
> ```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.
@ -181,7 +181,7 @@ The first argument is the [BOLT11 payment request](https://github.com/lightning/
>
> ```js
> // wallets/<wallet>/client.js
> export * from 'wallets/<name>'
> export * from '@/wallets/<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
> // wallets/client.js
> import * as nwc from 'wallets/nwc/client'
> import * as lnbits from 'wallets/lnbits/client'
> import * as lnc from 'wallets/lnc/client'
> import * as lnAddr from 'wallets/lightning-address/client'
> import * as cln from 'wallets/cln/client'
> import * as lnd from 'wallets/lnd/client'
> + import * as newWallet from 'wallets/<name>/client'
> import * as nwc from '@/wallets/nwc/client'
> import * as lnbits from '@/wallets/lnbits/client'
> import * as lnc from '@/wallets/lnc/client'
> import * as lnAddr from '@/wallets/lightning-address/client'
> import * as cln from '@/wallets/cln/client'
> import * as lnd from '@/wallets/lnd/client'
> + import * as newWallet from '@/wallets/<name>/client'
>
> - export default [nwc, lnbits, lnc, lnAddr, cln, lnd]
> + 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
> // wallets/<wallet>/server.js
> export * from 'wallets/<name>'
> export * from '@/wallets/<name>'
> ```
>
> where `<name>` is the wallet directory name.
@ -235,10 +235,10 @@ Again, like `testSendPayment`, the first argument is the wallet configuration th
>
> ```diff
> // wallets/server.js
> import * as lnd from 'wallets/lnd/server'
> import * as cln from 'wallets/cln/server'
> import * as lnAddr from 'wallets/lightning-address/server'
> + import * as newWallet from 'wallets/<name>/client'
> import * as lnd from '@/wallets/lnd/server'
> import * as cln from '@/wallets/cln/server'
> import * as lnAddr from '@/wallets/lightning-address/server'
> + import * as newWallet from '@/wallets/<name>/client'
>
> - export default [lnd, cln, lnAddr]
> + export default [lnd, cln, lnAddr, newWallet]

View File

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

View File

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

View File

@ -1,7 +1,7 @@
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'
export * from 'wallets/blink'
export * from '@/wallets/blink'
export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) {
const scopes = await getScopes(apiKeyRecv)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,14 +11,18 @@ import {
getInvoice, getPayment, parsePaymentRequest,
payViaPaymentRequest, settleHodlInvoice
} from 'ln-service'
import { MIN_SETTLEMENT_CLTV_DELTA } from 'wallets/wrap'
import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap'
// aggressive finalization retry options
const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 }
async function transitionInvoice (jobName, { invoiceId, fromState, toState, transition, 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}`)
let dbInvoice
try {
const currentDbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
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
const dbInvoice = await tx.invoice.update({
dbInvoice = await tx.invoice.update({
include,
where: {
id: invoiceId,
@ -100,6 +104,7 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
}
console.error('unexpected error', e)
onUnexpectedError?.({ error: e, dbInvoice, models, boss })
await boss.send(
jobName,
{ invoiceId },
@ -110,35 +115,35 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
}
async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) {
try {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const context = {
tx,
cost: BigInt(lndInvoice.received_mtokens),
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 args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const context = {
tx,
cost: BigInt(lndInvoice.received_mtokens),
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
}
})
}
// 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 }) {
@ -151,9 +156,17 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln
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`
INSERT INTO pgboss.job (name, data)
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'))`
}
return {
confirmedAt: new Date(lndInvoice.confirmed_at),
confirmedIndex: lndInvoice.confirmed_index,
msatsReceived: BigInt(lndInvoice.received_mtokens)
}
return updateFields
},
...args
}, { models, lnd, boss })
@ -241,6 +250,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode
}
}
},
onUnexpectedError: onHeldInvoiceError,
...args
}, { models, lnd, boss })
@ -410,6 +420,7 @@ export async function paidActionHeld ({ data: { invoiceId, ...args }, models, ln
msatsReceived: BigInt(lndInvoice.received_mtokens)
}
},
onUnexpectedError: onHeldInvoiceError,
...args
}, { models, lnd, boss })
}

View File

@ -99,7 +99,7 @@ function getStreakQuery (type, userId) {
FROM "Invoice"
JOIN "InvoiceForward" ON "Invoice".id = "InvoiceForward"."invoiceId"
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}
GROUP BY "Invoice"."userId"
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 "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
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}
GROUP BY "Withdrawl"."userId"
HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${HORSE_STREAK_THRESHOLD}`

View File

@ -144,10 +144,8 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd
return await paidActionPaid({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss })
}
// NOTE: confirm invoice prevents double confirmations (idempotent)
// ALSO: is_confirmed and is_held are mutually exclusive
// 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
// XXX we need to keep this to allow production to migrate to new paidAction flow
// once all non-paidAction receive invoices are migrated, we can remove this
const [[{ confirm_invoice: code }]] = await serialize([
models.$queryRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`,
models.invoice.update({ where: { hash }, data: { confirmedIndex: inv.confirmed_index } })
@ -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 })
}
// 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) {