configurable sybil fee (#1577)

* configurable sybil fee

* document getSybilFeePercent

* fixes

* remove null check

* refine at the margins

---------

Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
Riccardo Balbo 2024-11-11 23:59:52 +01:00 committed by GitHub
parent fdd34b2eb3
commit 18700b4201
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 166 additions and 58 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

@ -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

@ -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: {