cleanup fee design

This commit is contained in:
shocknet-justin 2025-12-18 01:05:48 -05:00
parent 5cb1cd509d
commit ad8cd91aad
3 changed files with 93 additions and 21 deletions

View file

@ -175,10 +175,8 @@ export default class {
case Types.UserOperationType.INCOMING_TX: case Types.UserOperationType.INCOMING_TX:
return Math.ceil(this.settings.getSettings().serviceFeeSettings.incomingTxFee * amount) return Math.ceil(this.settings.getSettings().serviceFeeSettings.incomingTxFee * amount)
case Types.UserOperationType.INCOMING_INVOICE: case Types.UserOperationType.INCOMING_INVOICE:
if (appUser) { // Incoming invoice fees are always 0 (not configurable)
return Math.ceil(this.settings.getSettings().serviceFeeSettings.incomingAppUserInvoiceFee * amount) return 0
}
return Math.ceil(this.settings.getSettings().serviceFeeSettings.incomingAppInvoiceFee * amount)
case Types.UserOperationType.INCOMING_USER_TO_USER: case Types.UserOperationType.INCOMING_USER_TO_USER:
if (appUser) { if (appUser) {
return Math.ceil(this.settings.getSettings().serviceFeeSettings.userToUserFee * amount) return Math.ceil(this.settings.getSettings().serviceFeeSettings.userToUserFee * amount)
@ -202,7 +200,7 @@ export default class {
throw new Error("Sending a transaction is not supported") throw new Error("Sending a transaction is not supported")
case Types.UserOperationType.OUTGOING_INVOICE: case Types.UserOperationType.OUTGOING_INVOICE:
const fee = this.getInvoicePaymentServiceFee(amount, appUser) const fee = this.getInvoicePaymentServiceFee(amount, appUser)
return Math.max(fee, this.settings.getSettings().lndSettings.outboundFeeFloor) return Math.max(fee, this.settings.getSettings().lndSettings.serviceFeeFloor)
case Types.UserOperationType.OUTGOING_USER_TO_USER: case Types.UserOperationType.OUTGOING_USER_TO_USER:
if (appUser) { if (appUser) {
return Math.ceil(this.settings.getSettings().serviceFeeSettings.userToUserFee * amount) return Math.ceil(this.settings.getSettings().serviceFeeSettings.userToUserFee * amount)
@ -213,6 +211,12 @@ export default class {
} }
} }
getRoutingFeeLimit = (amount: number): number => {
const { routingFeeLimitBps, routingFeeFloor } = this.settings.getSettings().lndSettings
const limit = Math.floor(amount * routingFeeLimitBps / 10000)
return Math.max(limit, routingFeeFloor)
}
async SetMockInvoiceAsPaid(req: Types.SetMockInvoiceAsPaidRequest) { async SetMockInvoiceAsPaid(req: Types.SetMockInvoiceAsPaidRequest) {
if (!this.settings.getSettings().lndSettings.mockLnd) { if (!this.settings.getSettings().lndSettings.mockLnd) {
throw new Error("mock disabled, cannot set invoice as paid") throw new Error("mock disabled, cannot set invoice as paid")
@ -261,8 +265,8 @@ export default class {
GetFees = (): Types.CumulativeFees => { GetFees = (): Types.CumulativeFees => {
const { outgoingAppUserInvoiceFeeBps } = this.settings.getSettings().serviceFeeSettings const { outgoingAppUserInvoiceFeeBps } = this.settings.getSettings().serviceFeeSettings
const { outboundFeeFloor } = this.settings.getSettings().lndSettings const { serviceFeeFloor } = this.settings.getSettings().lndSettings
return { outboundFeeFloor, serviceFeeBps: outgoingAppUserInvoiceFeeBps } return { outboundFeeFloor: serviceFeeFloor, serviceFeeBps: outgoingAppUserInvoiceFeeBps }
} }
GetMaxPayableInvoice(balance: number): Types.CumulativeFees & { max: number } { GetMaxPayableInvoice(balance: number): Types.CumulativeFees & { max: number } {
@ -288,7 +292,7 @@ export default class {
} }
if (req.expected_fees) { if (req.expected_fees) {
const { outboundFeeFloor, serviceFeeBps } = req.expected_fees const { outboundFeeFloor, serviceFeeBps } = req.expected_fees
const serviceFixed = this.settings.getSettings().lndSettings.outboundFeeFloor const serviceFixed = this.settings.getSettings().lndSettings.serviceFeeFloor
const serviceBps = this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFeeBps const serviceBps = this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFeeBps
if (serviceFixed !== outboundFeeFloor || serviceBps !== serviceFeeBps) { if (serviceFixed !== outboundFeeFloor || serviceBps !== serviceFeeBps) {
throw new Error("fees do not match the expected fees") throw new Error("fees do not match the expected fees")
@ -364,6 +368,7 @@ export default class {
const { amountForLnd, payAmount, serviceFee } = amounts const { amountForLnd, payAmount, serviceFee } = amounts
const totalAmountToDecrement = payAmount + serviceFee const totalAmountToDecrement = payAmount + serviceFee
const routingFeeLimit = this.getRoutingFeeLimit(payAmount)
const use = await this.liquidityManager.beforeOutInvoicePayment(payAmount, serviceFee) const use = await this.liquidityManager.beforeOutInvoicePayment(payAmount, serviceFee)
const provider = use === 'provider' ? this.lnd.liquidProvider.GetProviderDestination() : undefined const provider = use === 'provider' ? this.lnd.liquidProvider.GetProviderDestination() : undefined
const pendingPayment = await this.storage.StartTransaction(async tx => { const pendingPayment = await this.storage.StartTransaction(async tx => {
@ -375,7 +380,7 @@ export default class {
const op = this.newInvoicePaymentOperation({ invoice, opId, amount: payAmount, networkFee: 0, serviceFee: serviceFee, confirmed: false }) const op = this.newInvoicePaymentOperation({ invoice, opId, amount: payAmount, networkFee: 0, serviceFee: serviceFee, confirmed: false })
optionals.ack?.(op) optionals.ack?.(op)
try { try {
const payment = await this.lnd.PayInvoice(invoice, amountForLnd, serviceFee, payAmount, { useProvider: use === 'provider', from: 'user' }, index => { const payment = await this.lnd.PayInvoice(invoice, amountForLnd, routingFeeLimit, payAmount, { useProvider: use === 'provider', from: 'user' }, index => {
this.storage.paymentStorage.SetExternalPaymentIndex(pendingPayment.serial_id, index) this.storage.paymentStorage.SetExternalPaymentIndex(pendingPayment.serial_id, index)
}) })
await this.storage.paymentStorage.UpdateExternalPayment(pendingPayment.serial_id, payment.feeSat, serviceFee, true, payment.providerDst) await this.storage.paymentStorage.UpdateExternalPayment(pendingPayment.serial_id, payment.feeSat, serviceFee, true, payment.providerDst)

View file

@ -5,8 +5,6 @@ import path from 'path'
export type ServiceFeeSettings = { export type ServiceFeeSettings = {
incomingTxFee: number incomingTxFee: number
outgoingTxFee: number outgoingTxFee: number
incomingAppInvoiceFee: number
incomingAppUserInvoiceFee: number
outgoingAppInvoiceFee: number outgoingAppInvoiceFee: number
outgoingAppUserInvoiceFee: number outgoingAppUserInvoiceFee: number
outgoingAppUserInvoiceFeeBps: number outgoingAppUserInvoiceFeeBps: number
@ -15,15 +13,32 @@ export type ServiceFeeSettings = {
} }
export const LoadServiceFeeSettingsFromEnv = (dbEnv: Record<string, string | undefined>, addToDb?: EnvCacher): ServiceFeeSettings => { export const LoadServiceFeeSettingsFromEnv = (dbEnv: Record<string, string | undefined>, addToDb?: EnvCacher): ServiceFeeSettings => {
const outgoingAppUserInvoiceFeeBps = chooseEnvInt("OUTGOING_INVOICE_FEE_USER_BPS", dbEnv, 0, addToDb) // Support both old and new env var names for backward compatibility (new name takes precedence)
// Check if new name exists first (in process.env or dbEnv)
const newExists = process.env["SERVICE_FEE_BPS"] !== undefined || dbEnv["SERVICE_FEE_BPS"] !== undefined
let serviceFeeBps: number
if (newExists) {
// New name exists, use it
serviceFeeBps = chooseEnvInt("SERVICE_FEE_BPS", dbEnv, 60, addToDb)
} else {
// New name doesn't exist, check old name for backward compatibility
const oldExists = process.env["OUTGOING_INVOICE_FEE_USER_BPS"] !== undefined || dbEnv["OUTGOING_INVOICE_FEE_USER_BPS"] !== undefined
if (oldExists) {
// Old name exists, use it and migrate to new name in DB
const oldValue = chooseEnvInt("OUTGOING_INVOICE_FEE_USER_BPS", dbEnv, 60) // Don't add old name to DB
serviceFeeBps = oldValue
if (addToDb) addToDb("SERVICE_FEE_BPS", oldValue.toString()) // Migrate to new name
} else {
// Neither exists, use default with new name
serviceFeeBps = chooseEnvInt("SERVICE_FEE_BPS", dbEnv, 60, addToDb)
}
}
return { return {
incomingTxFee: chooseEnvInt("INCOMING_CHAIN_FEE_ROOT_BPS", dbEnv, 0, addToDb) / 10000, incomingTxFee: 0, // Not configurable, always 0
outgoingTxFee: chooseEnvInt("OUTGOING_CHAIN_FEE_ROOT_BPS", dbEnv, 60, addToDb) / 10000, outgoingTxFee: chooseEnvInt("OUTGOING_CHAIN_FEE_ROOT_BPS", dbEnv, 60, addToDb) / 10000,
incomingAppInvoiceFee: chooseEnvInt("INCOMING_INVOICE_FEE_ROOT_BPS", dbEnv, 0, addToDb) / 10000,
outgoingAppInvoiceFee: chooseEnvInt("OUTGOING_INVOICE_FEE_ROOT_BPS", dbEnv, 60, addToDb) / 10000, outgoingAppInvoiceFee: chooseEnvInt("OUTGOING_INVOICE_FEE_ROOT_BPS", dbEnv, 60, addToDb) / 10000,
incomingAppUserInvoiceFee: chooseEnvInt("INCOMING_INVOICE_FEE_USER_BPS", dbEnv, 0, addToDb) / 10000, outgoingAppUserInvoiceFeeBps: serviceFeeBps,
outgoingAppUserInvoiceFeeBps, outgoingAppUserInvoiceFee: serviceFeeBps / 10000,
outgoingAppUserInvoiceFee: outgoingAppUserInvoiceFeeBps / 10000,
userToUserFee: chooseEnvInt("TX_FEE_INTERNAL_USER_BPS", dbEnv, 0, addToDb) / 10000, userToUserFee: chooseEnvInt("TX_FEE_INTERNAL_USER_BPS", dbEnv, 0, addToDb) / 10000,
appToUserFee: chooseEnvInt("TX_FEE_INTERNAL_ROOT_BPS", dbEnv, 0, addToDb) / 10000, appToUserFee: chooseEnvInt("TX_FEE_INTERNAL_ROOT_BPS", dbEnv, 0, addToDb) / 10000,
} }
@ -80,7 +95,9 @@ const networks = ['mainnet', 'testnet', 'regtest'] as const
export type BTCNetwork = (typeof networks)[number] export type BTCNetwork = (typeof networks)[number]
export type LndSettings = { export type LndSettings = {
lndLogDir: string lndLogDir: string
outboundFeeFloor: number serviceFeeFloor: number
routingFeeLimitBps: number
routingFeeFloor: number
mockLnd: boolean mockLnd: boolean
network: BTCNetwork network: BTCNetwork
} }
@ -112,11 +129,44 @@ export const LoadLndNodeSettingsFromEnv = (dbEnv: Record<string, string | undefi
export const LoadLndSettingsFromEnv = (dbEnv: Record<string, string | undefined>, addToDb?: EnvCacher): LndSettings => { export const LoadLndSettingsFromEnv = (dbEnv: Record<string, string | undefined>, addToDb?: EnvCacher): LndSettings => {
const network = chooseEnv('BTC_NETWORK', dbEnv, 'mainnet', addToDb) as BTCNetwork const network = chooseEnv('BTC_NETWORK', dbEnv, 'mainnet', addToDb) as BTCNetwork
const limitOld = chooseEnvInt('OUTBOUND_MAX_FEE_EXTRA_SATS', dbEnv, 10, addToDb)
const outboundFeeFloor = chooseEnvInt('OUTBOUND_FEE_FLOOR_SATS', dbEnv, limitOld, addToDb) // Routing fee floor: new name takes precedence, fall back to old name for backward compatibility
const routingFeeFloorNewExists = process.env['ROUTING_FEE_FLOOR_SATS'] !== undefined || dbEnv['ROUTING_FEE_FLOOR_SATS'] !== undefined
let routingFeeFloor: number
if (routingFeeFloorNewExists) {
routingFeeFloor = chooseEnvInt('ROUTING_FEE_FLOOR_SATS', dbEnv, 5, addToDb)
} else {
const routingFeeFloorOldExists = process.env['OUTBOUND_MAX_FEE_EXTRA_SATS'] !== undefined || dbEnv['OUTBOUND_MAX_FEE_EXTRA_SATS'] !== undefined
if (routingFeeFloorOldExists) {
const oldValue = chooseEnvInt('OUTBOUND_MAX_FEE_EXTRA_SATS', dbEnv, 5) // Don't add old name to DB
routingFeeFloor = oldValue
if (addToDb) addToDb('ROUTING_FEE_FLOOR_SATS', oldValue.toString()) // Migrate to new name
} else {
routingFeeFloor = chooseEnvInt('ROUTING_FEE_FLOOR_SATS', dbEnv, 5, addToDb)
}
}
// Service fee floor: new name takes precedence, fall back to old name for backward compatibility
const serviceFeeFloorNewExists = process.env['SERVICE_FEE_FLOOR_SATS'] !== undefined || dbEnv['SERVICE_FEE_FLOOR_SATS'] !== undefined
let serviceFeeFloor: number
if (serviceFeeFloorNewExists) {
serviceFeeFloor = chooseEnvInt('SERVICE_FEE_FLOOR_SATS', dbEnv, 10, addToDb)
} else {
const serviceFeeFloorOldExists = process.env['OUTBOUND_MAX_FEE_EXTRA_SATS'] !== undefined || dbEnv['OUTBOUND_MAX_FEE_EXTRA_SATS'] !== undefined
if (serviceFeeFloorOldExists) {
const oldValue = chooseEnvInt('OUTBOUND_MAX_FEE_EXTRA_SATS', dbEnv, 10) // Don't add old name to DB
serviceFeeFloor = oldValue
if (addToDb) addToDb('SERVICE_FEE_FLOOR_SATS', oldValue.toString()) // Migrate to new name
} else {
serviceFeeFloor = chooseEnvInt('SERVICE_FEE_FLOOR_SATS', dbEnv, 10, addToDb)
}
}
const routingFeeLimitBps = chooseEnvInt('ROUTING_FEE_LIMIT_BPS', dbEnv, 50, addToDb)
return { return {
lndLogDir: chooseEnv('LND_LOG_DIR', dbEnv, resolveHome("/.lnd/logs/bitcoin/mainnet/lnd.log"), addToDb), lndLogDir: chooseEnv('LND_LOG_DIR', dbEnv, resolveHome("/.lnd/logs/bitcoin/mainnet/lnd.log"), addToDb),
outboundFeeFloor, serviceFeeFloor,
routingFeeLimitBps,
routingFeeFloor,
mockLnd: false, mockLnd: false,
network: networks.includes(network) ? network : 'mainnet' network: networks.includes(network) ? network : 'mainnet'
} }

View file

@ -50,9 +50,26 @@ export default class SettingsManager {
for (const key in toAdd) { for (const key in toAdd) {
await this.storage.settingsStorage.setDbEnvIFNeeded(key, toAdd[key]) await this.storage.settingsStorage.setDbEnvIFNeeded(key, toAdd[key])
} }
// Validate fee configuration: routing fee limit must be <= service fee
this.validateFeeSettings(this.settings)
return this.settings return this.settings
} }
private validateFeeSettings(settings: FullSettings): void {
const { serviceFeeSettings, lndSettings } = settings
const serviceFeeBps = serviceFeeSettings.outgoingAppUserInvoiceFeeBps
const routingFeeLimitBps = lndSettings.routingFeeLimitBps
const serviceFeeFloor = lndSettings.serviceFeeFloor
const routingFeeFloor = lndSettings.routingFeeFloor
if (routingFeeLimitBps > serviceFeeBps) {
throw new Error(`ROUTING_FEE_LIMIT_BPS (${routingFeeLimitBps}) must be <= SERVICE_FEE_BPS (${serviceFeeBps}) to ensure Pub keeps a spread`)
}
if (routingFeeFloor > serviceFeeFloor) {
throw new Error(`ROUTING_FEE_FLOOR_SATS (${routingFeeFloor}) must be <= SERVICE_FEE_FLOOR_SATS (${serviceFeeFloor}) to ensure Pub keeps a spread`)
}
}
getStorageSettings(): StorageSettings { getStorageSettings(): StorageSettings {
return this.storage.getStorageSettings() return this.storage.getStorageSettings()
} }