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
- 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`
- `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
- `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
- `getSybilFeePercent` (required if `getInvoiceablePeer` is implemented): returns the action sybil fee percent as a `BigInt` (eg. 30n for 30%)
#### Function arguments
@ -179,6 +180,7 @@ All functions have the following signature: `function(args: Object, context: Obj
`context` contains the following fields:
- `me`: the user performing the action (undefined if anonymous)
- `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)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client

View File

@ -1,7 +1,7 @@
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time'
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 * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
@ -14,8 +14,7 @@ import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
import * as BOOST from './boost'
import wrapInvoice from 'wallets/wrap'
import { createInvoice as createUserInvoice } from 'wallets/server'
import { createWrappedInvoice } from 'wallets/server'
export const paidActions = {
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.cost = await paidAction.getCost(args, context)
context.sybilFeePercent = await paidAction.getSybilFeePercent?.(args, context)
if (!me) {
if (!paidAction.anonable) {
@ -229,8 +229,7 @@ const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
export async function createLightningInvoice (actionType, args, context) {
// if the action has an invoiceable peer, we'll create a peer invoice
// wrap it, and return the wrapped invoice
const { cost, models, lnd, me } = context
const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)
const { cost, models, lnd, sybilFeePercent, me } = context
// count pending invoices and bail if we're over the limit
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')
}
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, context)
if (userId) {
let logger, bolt11
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 { invoice, wallet } = await createUserInvoice(userId, {
// this is the amount the stacker will receive, the other 3/10ths is the sybil fee
msats: cost * BigInt(7) / BigInt(10),
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: cost,
feePercent: sybilFeePercent,
description,
expiry: INVOICE_EXPIRE_SECS
}, { models })
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 })
}, { models, me, lnd })
return {
bolt11,
wrappedBolt11: wrappedInvoice.request,
bolt11: invoice,
wrappedBolt11: wrappedInvoice,
wallet,
maxFee
}
} catch (e) {
logger?.error('invalid invoice: ' + e.message, { bolt11 })
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)
}
export async function invoiceablePeer ({ id }, { models }) {
export async function getInvoiceablePeer ({ id }, { models }) {
const item = await models.item.findUnique({
where: { id: parseInt(id) },
include: {
@ -27,8 +27,12 @@ export async function invoiceablePeer ({ id }, { models }) {
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 }) {
const feeMsats = 3n * (cost / BigInt(10)) // 30% fee
export async function getSybilFeePercent () {
return 30n
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) {
const feeMsats = cost * sybilFeePercent / 100n
const zapMsats = cost - feeMsats
itemId = parseInt(itemId)

View File

@ -494,26 +494,6 @@ export const lud18PayerDataSchema = (k1) => object({
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({
passphrase: string().required('required')
.test(async (value, context) => {
@ -533,3 +513,79 @@ export const deviceSyncSchema = object().shape({
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 walletDefs from 'wallets/server'
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 { withTimeout } from '@/lib/time'
import { canReceive } from './common'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format'
import wrapInvoice from './wrap'
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')
}
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 (
{
msats,

View File

@ -1,6 +1,6 @@
import { createHodlInvoice, parsePaymentRequest } from 'ln-service'
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 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
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 MAX_FEE_ESTIMATE_PERCENT = 0.025 // 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
const MAX_FEE_ESTIMATE_PERCENT = 3n // the maximum fee relative to outgoing we'll allow for the fee estimate
/*
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 options {object}
@param args {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 {
invoice: the wrapped incoming invoice,
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 {
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)
// 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
if (inv.mtokens) {
outgoingMsat = toPositiveNumber(inv.mtokens)
outgoingMsat = toPositiveBigInt(inv.mtokens)
if (outgoingMsat < MIN_OUTGOING_MSATS) {
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
if (msats) {
msats = toPositiveNumber(msats)
if (outgoingMsat * ZAP_SYBIL_FEE_MULT > msats) {
msats = toPositiveBigInt(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')
}
} else {
@ -162,7 +179,7 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip
// validate the fee budget
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) {
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 }) {
try {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const result = await paidActions[dbInvoice.actionType].perform(args,
{ models, tx, lnd, cost: BigInt(lndInvoice.received_mtokens), me: dbInvoice.user })
const context = { tx, cost: BigInt(lndInvoice.received_mtokens) }
context.sybilFeePercent = await paidActions[dbInvoice.actionType].getSybilFeePercent?.(args, context)
const result = await paidActions[dbInvoice.actionType].perform(args, context)
await tx.invoice.update({
where: { id: dbInvoice.id },
data: {