Compare commits

...

4 Commits

Author SHA1 Message Date
Riccardo Balbo
7e5a8310df
fix lnd hostname (#1580) 2024-11-11 17:52:15 -06:00
Riccardo Balbo
18700b4201
configurable sybil fee (#1577)
* configurable sybil fee

* document getSybilFeePercent

* fixes

* remove null check

* refine at the margins

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-11-11 16:59:52 -06:00
ekzyis
fdd34b2eb3
Fix edit countdown on deleted items (#1571)
* Clarify conditions to show edit countdown

* Fix edit countdown shown for deleted items

* refactor: Minimize canEdit state

I noticed that only anonEdit requires state because it needs to use useEffect to fetch from local storage.

The other conditions can simply be checked during render.

* refactor: Use datePivot for edit countdown
2024-11-11 09:23:08 -06:00
ekzyis
406ae81693
Fixes around account switching / authentication (#1575)
* Fix missing page reload after account switch on logout

* Fix missing key

* Explain why we set multi_auth cookies on login/signup

* Fix 500 if multi_auth cookie missing
2024-11-11 09:16:32 -06:00
14 changed files with 189 additions and 70 deletions

View File

@ -167,10 +167,11 @@ All functions have the following signature: `function(args: Object, context: Obj
- this function is called when an optimistic action is retried - this function is called when an optimistic action is retried
- it's passed the original `invoiceId` and the `newInvoiceId` - it's passed the original `invoiceId` and the `newInvoiceId`
- this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING` - this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING`
- `invoiceablePeer`: returns the userId of the peer that's capable of generating an invoice so they can be paid for the action - `getInvoiceablePeer`: returns the userId of the peer that's capable of generating an invoice so they can be paid for the action
- this is only used for p2p wrapped zaps currently - this is only used for p2p wrapped zaps currently
- `describe`: returns a description as a string of the action - `describe`: returns a description as a string of the action
- for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description - for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description
- `getSybilFeePercent` (required if `getInvoiceablePeer` is implemented): returns the action sybil fee percent as a `BigInt` (eg. 30n for 30%)
#### Function arguments #### Function arguments
@ -179,6 +180,7 @@ All functions have the following signature: `function(args: Object, context: Obj
`context` contains the following fields: `context` contains the following fields:
- `me`: the user performing the action (undefined if anonymous) - `me`: the user performing the action (undefined if anonymous)
- `cost`: the cost of the action in msats as a `BigInt` - `cost`: the cost of the action in msats as a `BigInt`
- `sybilFeePercent`: the sybil fee percent as a `BigInt` (eg. 30n for 30%)
- `tx`: the current transaction (for anything that needs to be done atomically with the payment) - `tx`: the current transaction (for anything that needs to be done atomically with the payment)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment) - `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client - `lnd`: the current lnd client

View File

@ -1,7 +1,7 @@
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_TERMINAL_STATES, USER_ID } from '@/lib/constants' import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
import { createHmac, walletLogger } from '@/api/resolvers/wallet' import { createHmac } from '@/api/resolvers/wallet'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import * as ITEM_CREATE from './itemCreate' import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate' import * as ITEM_UPDATE from './itemUpdate'
@ -14,8 +14,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 wrapInvoice from 'wallets/wrap' import { createWrappedInvoice } from 'wallets/server'
import { createInvoice as createUserInvoice } from 'wallets/server'
export const paidActions = { export const paidActions = {
ITEM_CREATE, ITEM_CREATE,
@ -44,6 +43,7 @@ export default async function performPaidAction (actionType, args, context) {
context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
context.cost = await paidAction.getCost(args, context) context.cost = await paidAction.getCost(args, context)
context.sybilFeePercent = await paidAction.getSybilFeePercent?.(args, context)
if (!me) { if (!me) {
if (!paidAction.anonable) { if (!paidAction.anonable) {
@ -229,8 +229,7 @@ const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
export async function createLightningInvoice (actionType, args, context) { export async function createLightningInvoice (actionType, args, context) {
// 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, me } = context const { cost, models, lnd, sybilFeePercent, me } = context
const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)
// count pending invoices and bail if we're over the limit // count pending invoices and bail if we're over the limit
const pendingInvoices = await models.invoice.count({ const pendingInvoices = await models.invoice.count({
@ -248,33 +247,29 @@ export async function createLightningInvoice (actionType, args, context) {
throw new Error('You have too many pending paid actions, cancel some or wait for them to expire') throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
} }
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, context)
if (userId) { if (userId) {
let logger, bolt11
try { try {
if (!sybilFeePercent) {
throw new Error('sybil fee percent is not set for an invoiceable peer action')
}
const description = await paidActions[actionType].describe(args, context) const description = await paidActions[actionType].describe(args, context)
const { invoice, wallet } = await createUserInvoice(userId, {
// this is the amount the stacker will receive, the other 3/10ths is the sybil fee const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: cost * BigInt(7) / BigInt(10), msats: cost,
feePercent: sybilFeePercent,
description, description,
expiry: INVOICE_EXPIRE_SECS expiry: INVOICE_EXPIRE_SECS
}, { models }) }, { models, me, lnd })
logger = walletLogger({ wallet, models })
bolt11 = invoice
// the sender (me) decides if the wrapped invoice has a description
// whereas the recipient decides if their invoice has a description
const { invoice: wrappedInvoice, maxFee } = await wrapInvoice(
bolt11, { msats: cost, description }, { me, lnd })
return { return {
bolt11, bolt11: invoice,
wrappedBolt11: wrappedInvoice.request, wrappedBolt11: wrappedInvoice,
wallet, wallet,
maxFee maxFee
} }
} catch (e) { } catch (e) {
logger?.error('invalid invoice: ' + e.message, { bolt11 })
console.error('failed to create stacker invoice, falling back to SN invoice', e) console.error('failed to create stacker invoice, falling back to SN invoice', e)
} }
} }

View File

@ -10,7 +10,7 @@ export async function getCost ({ sats }) {
return satsToMsats(sats) return satsToMsats(sats)
} }
export async function invoiceablePeer ({ 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: {
@ -27,8 +27,12 @@ export async function invoiceablePeer ({ id }, { models }) {
return item.user.wallets.length > 0 && item.itemForwards.length === 0 ? item.userId : null return item.user.wallets.length > 0 && item.itemForwards.length === 0 ? item.userId : null
} }
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) { export async function getSybilFeePercent () {
const feeMsats = 3n * (cost / BigInt(10)) // 30% fee return 30n
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) {
const feeMsats = cost * sybilFeePercent / 100n
const zapMsats = cost - feeMsats const zapMsats = cost - feeMsats
itemId = parseInt(itemId) itemId = parseInt(itemId)

View File

@ -1375,7 +1375,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes // prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
const myBio = user.bioId === old.id const myBio = user.bioId === old.id
const timer = Date.now() < new Date(old.invoicePaidAt ?? old.createdAt).getTime() + 10 * 60_000 const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { minutes: 10 })
// timer permission check // timer permission check
if (!adminEdit && !myBio && !timer && !isJob(item)) { if (!adminEdit && !myBio && !timer && !isJob(item)) {

View File

@ -6,7 +6,7 @@ import Dropdown from 'react-bootstrap/Dropdown'
import Countdown from './countdown' import Countdown from './countdown'
import { abbrNum, numWithUnits } from '@/lib/format' import { abbrNum, numWithUnits } from '@/lib/format'
import { newComments, commentsViewedAt } from '@/lib/new-comments' import { newComments, commentsViewedAt } from '@/lib/new-comments'
import { timeSince } from '@/lib/time' import { datePivot, timeSince } from '@/lib/time'
import { DeleteDropdownItem } from './delete' import { DeleteDropdownItem } from './delete'
import styles from './item.module.css' import styles from './item.module.css'
import { useMe } from './me' import { useMe } from './me'
@ -34,10 +34,9 @@ export default function ItemInfo ({
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true, onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
setDisableRetry, disableRetry setDisableRetry, disableRetry
}) { }) {
const editThreshold = new Date(item.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000 const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 })
const { me } = useMe() const { me } = useMe()
const router = useRouter() const router = useRouter()
const [canEdit, setCanEdit] = useState(item.mine && !item.bio && (Date.now() < editThreshold))
const [hasNewComments, setHasNewComments] = useState(false) const [hasNewComments, setHasNewComments] = useState(false)
const root = useRoot() const root = useRoot()
const sub = item?.sub || root?.sub const sub = item?.sub || root?.sub
@ -48,12 +47,18 @@ export default function ItemInfo ({
} }
}, [item]) }, [item])
// allow anon edits if they have the correct hmac for the item invoice
// (the server will verify the hmac)
const [anonEdit, setAnonEdit] = useState(false)
useEffect(() => { useEffect(() => {
const authorEdit = item.mine && !item.bio
const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`) const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`)
const hmacEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon setAnonEdit(!!invParams && !me && Number(item.user.id) === USER_ID.anon)
setCanEdit((authorEdit || hmacEdit) && (Date.now() < editThreshold)) }, [])
}, [me, item.id, item.mine, editThreshold])
// deleted items can never be edited and every item has a 10 minute edit window
// except bios, they can always be edited but they should never show the countdown
const noEdit = !!item.deletedAt || (Date.now() >= editThreshold) || item.bio
const canEdit = !noEdit && ((me && item.mine) || anonEdit)
// territory founders can pin any post in their territory // territory founders can pin any post in their territory
// and OPs can pin any root reply in their post // and OPs can pin any root reply in their post
@ -152,7 +157,7 @@ export default function ItemInfo ({
<> <>
<EditInfo <EditInfo
item={item} edit={edit} canEdit={canEdit} item={item} edit={edit} canEdit={canEdit}
setCanEdit={setCanEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold} setCanEdit={setAnonEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold}
/> />
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} /> <PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
<ActionDropdown> <ActionDropdown>

View File

@ -79,6 +79,7 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
case 'Email': case 'Email':
return ( return (
<OverlayTrigger <OverlayTrigger
key={provider.id}
placement='bottom' placement='bottom'
overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>} overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
trigger={['hover', 'focus']} trigger={['hover', 'focus']}

View File

@ -265,6 +265,7 @@ function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { removeLocalWallets } = useWallets() const { removeLocalWallets } = useWallets()
const { multiAuthSignout } = useAccounts() const { multiAuthSignout } = useAccounts()
const router = useRouter()
return ( return (
<div className='d-flex m-auto flex-column w-fit-content'> <div className='d-flex m-auto flex-column w-fit-content'>
@ -283,6 +284,8 @@ function LogoutObstacle ({ onClose }) {
// only signout if multiAuth did not find a next available account // only signout if multiAuth did not find a next available account
if (switchSuccess) { if (switchSuccess) {
onClose() onClose()
// reload whatever page we're on to avoid any bugs
router.reload()
return return
} }

Binary file not shown.

View File

@ -494,26 +494,6 @@ export const lud18PayerDataSchema = (k1) => object({
identifier: string() identifier: string()
}) })
// check if something is _really_ a number.
// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity]
export const isNumber = x => typeof x === 'number' && !Number.isNaN(x)
export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
if (typeof x === 'undefined') {
throw new Error('value is required')
}
const n = Number(x)
if (isNumber(n)) {
if (x < min || x > max) {
throw new Error(`value ${x} must be between ${min} and ${max}`)
}
return n
}
throw new Error(`value ${x} is not a number`)
}
export const toPositiveNumber = (x) => toNumber(x, 0)
export const deviceSyncSchema = object().shape({ export const deviceSyncSchema = object().shape({
passphrase: string().required('required') passphrase: string().required('required')
.test(async (value, context) => { .test(async (value, context) => {
@ -533,3 +513,79 @@ export const deviceSyncSchema = object().shape({
return true return true
}) })
}) })
// check if something is _really_ a number.
// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity]
export const isNumber = x => typeof x === 'number' && !Number.isNaN(x)
/**
*
* @param {any | bigint} x
* @param {number} min
* @param {number} max
* @returns {number}
*/
export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
if (typeof x === 'undefined') {
throw new Error('value is required')
}
if (typeof x === 'bigint') {
if (x < BigInt(min) || x > BigInt(max)) {
throw new Error(`value ${x} must be between ${min} and ${max}`)
}
return Number(x)
} else {
const n = Number(x)
if (isNumber(n)) {
if (x < min || x > max) {
throw new Error(`value ${x} must be between ${min} and ${max}`)
}
return n
}
}
throw new Error(`value ${x} is not a number`)
}
/**
* @param {any | bigint} x
* @returns {number}
*/
export const toPositiveNumber = (x) => toNumber(x, 0)
/**
* @param {any} x
* @param {bigint | number} [min]
* @param {bigint | number} [max]
* @returns {bigint}
*/
export const toBigInt = (x, min, max) => {
if (typeof x === 'undefined') throw new Error('value is required')
const n = BigInt(x)
if (min !== undefined && n < BigInt(min)) {
throw new Error(`value ${x} must be at least ${min}`)
}
if (max !== undefined && n > BigInt(max)) {
throw new Error(`value ${x} must be at most ${max}`)
}
return n
}
/**
* @param {number|bigint} x
* @returns {bigint}
*/
export const toPositiveBigInt = (x) => {
return toBigInt(x, 0)
}
/**
* @param {number|bigint} x
* @returns {number|bigint}
*/
export const toPositive = (x) => {
if (typeof x === 'bigint') return toPositiveBigInt(x)
return toPositiveNumber(x)
}

View File

@ -97,6 +97,8 @@ function getCallbacks (req, res) {
const secret = process.env.NEXTAUTH_SECRET const secret = process.env.NEXTAUTH_SECRET
const jwt = await encodeJWT({ token, secret }) const jwt = await encodeJWT({ token, secret })
const me = await prisma.user.findUnique({ where: { id: token.id } }) const me = await prisma.user.findUnique({ where: { id: token.id } })
// we set multi_auth cookies on login/signup with only one user so the rest of the code doesn't
// have to consider the case where they aren't set yet because account switching wasn't used yet
setMultiAuthCookies(req, res, { ...me, jwt }) setMultiAuthCookies(req, res, { ...me, jwt })
} }

View File

@ -36,9 +36,9 @@ export default (req, res) => {
cookies.push(cookie.serialize(`multi_auth.${userId}`, '', { ...cookieOptions, expires: 0, maxAge: 0 })) cookies.push(cookie.serialize(`multi_auth.${userId}`, '', { ...cookieOptions, expires: 0, maxAge: 0 }))
// update multi_auth cookie and check if there are more accounts available // update multi_auth cookie and check if there are more accounts available
const oldMultiAuth = b64Decode(req.cookies.multi_auth) const oldMultiAuth = req.cookies.multi_auth ? b64Decode(req.cookies.multi_auth) : undefined
const newMultiAuth = oldMultiAuth.filter(({ id }) => id !== Number(userId)) const newMultiAuth = oldMultiAuth?.filter(({ id }) => id !== Number(userId))
if (newMultiAuth.length === 0) { if (!oldMultiAuth || newMultiAuth?.length === 0) {
// no next account available. cleanup: remove multi_auth + pointer cookie // no next account available. cleanup: remove multi_auth + pointer cookie
cookies.push(cookie.serialize('multi_auth', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 })) cookies.push(cookie.serialize('multi_auth', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 }))
cookies.push(cookie.serialize('multi_auth.user-id', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 })) cookies.push(cookie.serialize('multi_auth.user-id', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 }))

View File

@ -14,11 +14,12 @@ 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 { 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'
import { withTimeout } from '@/lib/time' import { withTimeout } from '@/lib/time'
import { canReceive } from './common' import { canReceive } from './common'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format' import { formatMsats, formatSats, msatsToSats } from '@/lib/format'
import wrapInvoice from './wrap'
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
@ -96,6 +97,37 @@ export async function createInvoice (userId, { msats, description, descriptionHa
throw new Error('no wallet to receive available') throw new Error('no wallet to receive available')
} }
export async function createWrappedInvoice (userId,
{ msats, feePercent, description, descriptionHash, expiry = 360 },
{ models, me, lnd }) {
let logger, bolt11
try {
const { invoice, wallet } = await createInvoice(userId, {
// this is the amount the stacker will receive, the other (feePercent)% is our fee
msats: toPositiveBigInt(msats) * (100n - feePercent) / 100n,
description,
descriptionHash,
expiry
}, { models })
logger = walletLogger({ wallet, models })
bolt11 = invoice
const { invoice: wrappedInvoice, maxFee } =
await wrapInvoice({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd })
return {
invoice,
wrappedInvoice: wrappedInvoice.request,
wallet,
maxFee
}
} catch (e) {
logger?.error('invalid invoice: ' + e.message, { bolt11 })
throw e
}
}
async function walletCreateInvoice ( async function walletCreateInvoice (
{ {
msats, msats,

View File

@ -1,6 +1,6 @@
import { createHodlInvoice, parsePaymentRequest } from 'ln-service' import { createHodlInvoice, parsePaymentRequest } from 'ln-service'
import { estimateRouteFee, getBlockHeight } from '../api/lnd' import { estimateRouteFee, getBlockHeight } from '../api/lnd'
import { toPositiveNumber } from '@/lib/validate' import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/validate'
const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice
const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice
@ -9,20 +9,26 @@ const INCOMING_EXPIRATION_BUFFER_MSECS = 300_000 // the buffer enforce for the i
const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for the outgoing invoice const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for the outgoing invoice
export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
const MAX_FEE_ESTIMATE_PERCENT = 0.025 // the maximum fee relative to outgoing we'll allow for the fee estimate const MAX_FEE_ESTIMATE_PERCENT = 3n // the maximum fee relative to outgoing we'll allow for the fee estimate
const ZAP_SYBIL_FEE_MULT = 10 / 7 // the fee for the zap sybil service
/* /*
The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice. The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice.
@param bolt11 {string} the bolt11 invoice to wrap @param args {object} {
@param options {object} bolt11: {string} the bolt11 invoice to wrap
feePercent: {bigint} the fee percent to use for the incoming invoice
}
@param options {object} {
msats: {bigint} the amount in msats to use for the incoming invoice
description: {string} the description to use for the incoming invoice
descriptionHash: {string} the description hash to use for the incoming invoice
}
@returns { @returns {
invoice: the wrapped incoming invoice, invoice: the wrapped incoming invoice,
maxFee: number maxFee: number
} }
*/ */
export default async function wrapInvoice (bolt11, { msats, description, descriptionHash }, { me, lnd }) { export default async function wrapInvoice ({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) {
try { try {
console.group('wrapInvoice', description) console.group('wrapInvoice', description)
@ -38,9 +44,17 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip
console.log('invoice', inv.id, inv.mtokens, inv.expires_at, inv.cltv_delta, inv.destination) console.log('invoice', inv.id, inv.mtokens, inv.expires_at, inv.cltv_delta, inv.destination)
// validate fee percent
if (feePercent) {
// assert the fee percent is in the range 0-100
feePercent = toBigInt(feePercent, 0n, 100n)
} else {
throw new Error('Fee percent is missing')
}
// validate outgoing amount // validate outgoing amount
if (inv.mtokens) { if (inv.mtokens) {
outgoingMsat = toPositiveNumber(inv.mtokens) outgoingMsat = toPositiveBigInt(inv.mtokens)
if (outgoingMsat < MIN_OUTGOING_MSATS) { if (outgoingMsat < MIN_OUTGOING_MSATS) {
throw new Error(`Invoice amount is too low: ${outgoingMsat}`) throw new Error(`Invoice amount is too low: ${outgoingMsat}`)
} }
@ -53,8 +67,11 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip
// validate incoming amount // validate incoming amount
if (msats) { if (msats) {
msats = toPositiveNumber(msats) msats = toPositiveBigInt(msats)
if (outgoingMsat * ZAP_SYBIL_FEE_MULT > msats) { // outgoing amount should be smaller than the incoming amount
// by a factor of exactly 100n / (100n - feePercent)
const incomingMsats = outgoingMsat * 100n / (100n - feePercent)
if (incomingMsats > msats) {
throw new Error('Sybil fee is too low') throw new Error('Sybil fee is too low')
} }
} else { } else {
@ -162,7 +179,7 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip
// validate the fee budget // validate the fee budget
const minEstFees = toPositiveNumber(routingFeeMsat) const minEstFees = toPositiveNumber(routingFeeMsat)
const outgoingMaxFeeMsat = Math.ceil(msats * MAX_FEE_ESTIMATE_PERCENT) const outgoingMaxFeeMsat = Math.ceil(toPositiveNumber(msats * MAX_FEE_ESTIMATE_PERCENT) / 100)
if (minEstFees > outgoingMaxFeeMsat) { if (minEstFees > outgoingMaxFeeMsat) {
throw new Error('Estimated fees are too high') throw new Error('Estimated fees are too high')
} }

View File

@ -112,8 +112,10 @@ 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 { try {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id } const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const result = await paidActions[dbInvoice.actionType].perform(args, const context = { tx, cost: BigInt(lndInvoice.received_mtokens) }
{ models, tx, lnd, cost: BigInt(lndInvoice.received_mtokens), me: dbInvoice.user }) context.sybilFeePercent = await paidActions[dbInvoice.actionType].getSybilFeePercent?.(args, context)
const result = await paidActions[dbInvoice.actionType].perform(args, context)
await tx.invoice.update({ await tx.invoice.update({
where: { id: dbInvoice.id }, where: { id: dbInvoice.id },
data: { data: {