diff --git a/lib/api/Errors.ts b/lib/api/Errors.ts index 8f9a168e..7e027ad2 100644 --- a/lib/api/Errors.ts +++ b/lib/api/Errors.ts @@ -9,4 +9,6 @@ export default { `${symbol} does not support ${argName}`, INVALID_SWAP_STATUS: (status: string): string => `invalid swap status: ${status}`, + INVALID_EXTRA_FEES_PERCENTAGE: (percentage: number): string => + `invalid extra fees percentage: ${percentage}`, }; diff --git a/lib/api/v2/routers/ReferralRouter.ts b/lib/api/v2/routers/ReferralRouter.ts index cd61b24b..fef3ff98 100644 --- a/lib/api/v2/routers/ReferralRouter.ts +++ b/lib/api/v2/routers/ReferralRouter.ts @@ -3,6 +3,7 @@ import Logger from '../../../Logger'; import ReferralStats from '../../../data/ReferralStats'; import Stats from '../../../data/Stats'; import Referral from '../../../db/models/Referral'; +import ExtraFeeRepository from '../../../db/repositories/ExtraFeeRepository'; import Bouncer from '../../Bouncer'; import { errorResponse, successResponse } from '../../Utils'; import RouterBase from './RouterBase'; @@ -203,6 +204,8 @@ class ReferralRouter extends RouterBase { */ router.get('/stats', this.handleError(this.getStats)); + router.get('/stats/extra', this.handleError(this.getExtraFees)); + return router; }; @@ -233,6 +236,15 @@ class ReferralRouter extends RouterBase { successResponse(res, await Stats.generate(0, 0, referral.id)); }; + private getExtraFees = async (req: Request, res: Response) => { + const referral = await this.checkAuthentication(req, res); + if (referral === undefined) { + return; + } + + successResponse(res, await ExtraFeeRepository.getStats(referral.id)); + }; + private checkAuthentication = async ( req: Request, res: Response, diff --git a/lib/api/v2/routers/SwapRouter.ts b/lib/api/v2/routers/SwapRouter.ts index 5c7e76b6..681a49cb 100644 --- a/lib/api/v2/routers/SwapRouter.ts +++ b/lib/api/v2/routers/SwapRouter.ts @@ -7,7 +7,7 @@ import ReferralRepository from '../../../db/repositories/ReferralRepository'; import SwapRepository from '../../../db/repositories/SwapRepository'; import RateProviderTaproot from '../../../rates/providers/RateProviderTaproot'; import Errors from '../../../service/Errors'; -import Service, { WebHookData } from '../../../service/Service'; +import Service, { ExtraFees, WebHookData } from '../../../service/Service'; import ChainSwapSigner from '../../../service/cooperative/ChainSwapSigner'; import MusigSigner, { PartialSignature, @@ -1669,19 +1669,21 @@ class SwapRouter extends RouterBase { }; private createSubmarine = async (req: Request, res: Response) => { - const { to, from, invoice, webhook, pairHash, refundPublicKey } = + const { to, from, invoice, webhook, pairHash, refundPublicKey, extraFees } = validateRequest(req.body, [ { name: 'to', type: 'string' }, { name: 'from', type: 'string' }, { name: 'webhook', type: 'object', optional: true }, { name: 'invoice', type: 'string', optional: true }, { name: 'pairHash', type: 'string', optional: true }, + { name: 'extraFees', type: 'object', optional: true }, { name: 'refundPublicKey', type: 'string', hex: true, optional: true }, ]); const referralId = parseReferralId(req); const { pairId, orderSide } = this.service.convertToPairAndSide(from, to); const webHookData = this.parseWebHook(webhook); + const extraFeesData = this.parseExtraFees(extraFees); let response: { id: string }; @@ -1696,6 +1698,7 @@ class SwapRouter extends RouterBase { undefined, SwapVersion.Taproot, webHookData, + extraFeesData, ); } else { const { preimageHash } = validateRequest(req.body, [ @@ -1726,15 +1729,19 @@ class SwapRouter extends RouterBase { const { id } = validateRequest(req.params, [ { name: 'id', type: 'string' }, ]); - const { invoice, pairHash } = validateRequest(req.body, [ + const { invoice, extraFees, pairHash } = validateRequest(req.body, [ { name: 'invoice', type: 'string' }, { name: 'pairHash', type: 'string', optional: true }, + { name: 'extraFees', type: 'object', optional: true }, ]); + const extraFeesData = this.parseExtraFees(extraFees); + const response = await this.service.setInvoice( id, invoice.toLowerCase(), pairHash, + extraFeesData, ); successResponse(res, response); }; @@ -1841,6 +1848,7 @@ class SwapRouter extends RouterBase { webhook, address, pairHash, + extraFees, description, routingNode, preimageHash, @@ -1859,6 +1867,7 @@ class SwapRouter extends RouterBase { { name: 'address', type: 'string', optional: true }, { name: 'webhook', type: 'object', optional: true }, { name: 'pairHash', type: 'string', optional: true }, + { name: 'extraFees', type: 'object', optional: true }, { name: 'description', type: 'string', optional: true }, { name: 'routingNode', type: 'string', optional: true }, { name: 'claimAddress', type: 'string', optional: true }, @@ -1876,6 +1885,7 @@ class SwapRouter extends RouterBase { const { pairId, orderSide } = this.service.convertToPairAndSide(from, to); const webHookData = this.parseWebHook(webhook); + const extraFeesData = this.parseExtraFees(extraFees); const response = await this.service.createReverseSwap({ pairId, @@ -1895,6 +1905,7 @@ class SwapRouter extends RouterBase { userAddress: address, webHook: webHookData, prepayMinerFee: false, + extraFees: extraFeesData, version: SwapVersion.Taproot, userAddressSignature: addressSignature, }); @@ -2006,6 +2017,7 @@ class SwapRouter extends RouterBase { from, webhook, pairHash, + extraFees, referralId, preimageHash, claimAddress, @@ -2019,6 +2031,7 @@ class SwapRouter extends RouterBase { { name: 'webhook', type: 'object', optional: true }, { name: 'preimageHash', type: 'string', hex: true }, { name: 'pairHash', type: 'string', optional: true }, + { name: 'extraFees', type: 'object', optional: true }, { name: 'referralId', type: 'string', optional: true }, { name: 'claimAddress', type: 'string', optional: true }, { name: 'userLockAmount', type: 'number', optional: true }, @@ -2029,6 +2042,7 @@ class SwapRouter extends RouterBase { checkPreimageHashLength(preimageHash); const webHookData = this.parseWebHook(webhook); + const extraFeesData = this.parseExtraFees(extraFees); const { pairId, orderSide } = this.service.convertToPairAndSide(from, to); const response = await this.service.createChainSwap({ @@ -2043,6 +2057,7 @@ class SwapRouter extends RouterBase { refundPublicKey, serverLockAmount, webHook: webHookData, + extraFees: extraFeesData, }); await markSwap(this.service.sidecar, req.ip, response.id); @@ -2279,6 +2294,28 @@ class SwapRouter extends RouterBase { return res; }; + private parseExtraFees = ( + data: Record, + ): ExtraFees | undefined => { + if (data === undefined) { + return undefined; + } + + const res = validateRequest(data, [ + { name: 'id', type: 'string' }, + { name: 'percentage', type: 'number' }, + ]); + + if (res.percentage <= 0 || res.percentage > 10) { + throw ApiErrors.INVALID_EXTRA_FEES_PERCENTAGE(res.percentage); + } + + return { + id: res.id, + percentage: res.percentage, + }; + }; + private getReferralFromHeader = async (req: Request) => { const referral = req.header('referral'); if (referral === undefined) { diff --git a/lib/db/Database.ts b/lib/db/Database.ts index 11698784..bac84a16 100644 --- a/lib/db/Database.ts +++ b/lib/db/Database.ts @@ -9,6 +9,7 @@ import ChainSwapData from './models/ChainSwapData'; import ChainTip from './models/ChainTip'; import ChannelCreation from './models/ChannelCreation'; import DatabaseVersion from './models/DatabaseVersion'; +import ExtraFee from './models/ExtraFee'; import KeyProvider from './models/KeyProvider'; import LightningPayment from './models/LightningPayment'; import MarkedSwap from './models/MarkedSwap'; @@ -96,6 +97,7 @@ class Database { Pair.sync(), ChainTip.sync(), Referral.sync(), + ExtraFee.sync(), KeyProvider.sync(), Rebroadcast.sync(), DatabaseVersion.sync(), @@ -130,6 +132,7 @@ class Database { private loadModels = () => { Pair.load(Database.sequelize); + ExtraFee.load(Database.sequelize); Referral.load(Database.sequelize); Swap.load(Database.sequelize); TransactionLabel.load(Database.sequelize); diff --git a/lib/db/models/ExtraFee.ts b/lib/db/models/ExtraFee.ts new file mode 100644 index 00000000..31c5d819 --- /dev/null +++ b/lib/db/models/ExtraFee.ts @@ -0,0 +1,55 @@ +import { DataTypes, Model, Sequelize } from 'sequelize'; + +type ExtraFeeType = { + swapId: string; + id: string; + fee: number; + percentage: number; +}; + +class ExtraFee extends Model implements ExtraFeeType { + public swapId!: string; + public id!: string; + public fee!: number; + public percentage!: number; + + public createdAt!: Date; + public updatedAt!: Date; + + public static load = (sequelize: Sequelize): void => { + ExtraFee.init( + { + swapId: { + type: new DataTypes.STRING(255), + primaryKey: true, + allowNull: false, + }, + id: { + type: new DataTypes.STRING(255), + allowNull: false, + }, + fee: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + percentage: { + type: new DataTypes.DECIMAL(), + allowNull: false, + }, + }, + { + sequelize, + tableName: 'extra_fees', + indexes: [ + { + unique: false, + fields: ['id'], + }, + ], + }, + ); + }; +} + +export default ExtraFee; +export { ExtraFeeType }; diff --git a/lib/db/repositories/ExtraFeeRepository.ts b/lib/db/repositories/ExtraFeeRepository.ts new file mode 100644 index 00000000..5d95e0a9 --- /dev/null +++ b/lib/db/repositories/ExtraFeeRepository.ts @@ -0,0 +1,76 @@ +import { QueryTypes } from 'sequelize'; +import { SwapUpdateEvent } from '../../consts/Enums'; +import { getNestedObject } from '../../data/Utils'; +import Database from '../Database'; +import ExtraFee, { ExtraFeeType } from '../models/ExtraFee'; + +class ExtraFeeRepository { + private static readonly statsQuery = ` +WITH successful AS (SELECT id, status, referral, "createdAt" + FROm swaps + WHERE status = ? + UNION ALL + SELECT id, status, referral, "createdAt" + FROM "reverseSwaps" + WHERE status = ? + UNION ALL + SELECT id, status, referral, "createdAt" + FROM "chainSwaps" + WHERE status = ?), + successful_extra AS (SELECT e.id AS id, e.fee AS fee, e."createdAt" + FROM successful s + RIGHT JOIN extra_fees e on s.id = e."swapId" + WHERE referral = ?) +SELECT EXTRACT(YEAR FROM "createdAt") AS year, EXTRACT(MONTH FROM "createdAt") AS month, id, SUM(fee) AS fee +FROM successful_extra +GROUP BY year, month, id +ORDER BY year, month, id; +`; + + public static create = async ( + extraFee: Omit & { fee?: number }, + ): Promise => { + await ExtraFee.create(extraFee); + }; + + public static get = async (id: string): Promise => { + return await ExtraFee.findByPk(id); + }; + + public static setFee = async (id: string, fee: number): Promise => { + await ExtraFee.update({ fee }, { where: { swapId: id } }); + }; + + public static getStats = async ( + id: string, + ): Promise>> => { + const stats = (await Database.sequelize.query( + { + query: ExtraFeeRepository.statsQuery, + values: [ + SwapUpdateEvent.TransactionClaimed, + SwapUpdateEvent.InvoiceSettled, + SwapUpdateEvent.TransactionClaimed, + id, + ], + }, + { + type: QueryTypes.SELECT, + }, + )) as { year: number; month: number; id: string; fee: number }[]; + + const res = {}; + + stats.forEach((stat) => { + const monthObj = getNestedObject( + getNestedObject(res, stat.year), + stat.month, + ); + monthObj[stat.id] = stat.fee; + }); + + return res; + }; +} + +export default ExtraFeeRepository; diff --git a/lib/rates/FeeProvider.ts b/lib/rates/FeeProvider.ts index d5ea9fd6..c3d949e3 100644 --- a/lib/rates/FeeProvider.ts +++ b/lib/rates/FeeProvider.ts @@ -21,6 +21,7 @@ import { } from '../consts/Enums'; import { PairConfig } from '../consts/Types'; import Referral from '../db/models/Referral'; +import { ExtraFees } from '../service/Service'; import WalletLiquid from '../wallet/WalletLiquid'; import WalletManager from '../wallet/WalletManager'; import { Ethereum, Rsk } from '../wallet/ethereum/EvmNetworks'; @@ -159,6 +160,14 @@ class FeeProvider { ); }; + public static calculateExtraFee = ( + percentage: number, + amount: number, + rate: number, + ): number => { + return Math.ceil((percentage / 100) * amount * rate); + }; + public init = (pairs: PairConfig[]): void => { pairs.forEach((pair) => { const pairId = getPairId(pair); @@ -245,9 +254,11 @@ class FeeProvider { type: SwapType, feeType: BaseFeeType, referral: Referral | null, + extraFees: ExtraFees | undefined, ): { baseFee: number; percentageFee: number; + extraFee?: number; } => { let percentageFee = this.getPercentageFee( pair, @@ -269,7 +280,18 @@ class FeeProvider { type === SwapType.ReverseSubmarine, ); + let extraFee: number | undefined = undefined; + + if (extraFees !== undefined) { + extraFee = FeeProvider.calculateExtraFee( + extraFees.percentage, + amount, + rate, + ); + } + return { + extraFee, percentageFee: Math.ceil(percentageFee), baseFee: this.getBaseFee(chainCurrency, swapVersion, feeType), }; diff --git a/lib/service/Renegotiator.ts b/lib/service/Renegotiator.ts index 280a40a6..91ee9509 100644 --- a/lib/service/Renegotiator.ts +++ b/lib/service/Renegotiator.ts @@ -14,8 +14,9 @@ import Referral from '../db/models/Referral'; import ChainSwapRepository, { ChainSwapInfo, } from '../db/repositories/ChainSwapRepository'; +import ExtraFeeRepository from '../db/repositories/ExtraFeeRepository'; import ReferralRepository from '../db/repositories/ReferralRepository'; -import { ChainSwapMinerFees } from '../rates/FeeProvider'; +import FeeProvider, { ChainSwapMinerFees } from '../rates/FeeProvider'; import RateProvider from '../rates/RateProvider'; import ErrorsSwap from '../swap/Errors'; import SwapNursery from '../swap/SwapNursery'; @@ -60,7 +61,7 @@ class Renegotiator { const { swap, receivingCurrency } = await this.getSwap(swapId); await this.validateEligibility(swap, receivingCurrency); - const { serverLockAmount, percentageFee } = + const { serverLockAmount, percentageFee, extraFee } = await this.calculateNewQuote(swap); if (newQuote !== serverLockAmount) { throw Errors.INVALID_QUOTE(); @@ -75,6 +76,10 @@ class Renegotiator { `Accepted new quote for ${swapTypeToPrettyString(swap.type)} Swap ${swap.id}: ${newQuote}`, ); + if (extraFee !== undefined) { + await ExtraFeeRepository.setFee(swap.id, extraFee); + } + if (receivingCurrency.chainClient !== undefined) { const txInfo = await receivingCurrency.chainClient.getRawTransactionVerbose( @@ -205,7 +210,13 @@ class Renegotiator { ), }); - private calculateNewQuote = async (swap: ChainSwapInfo) => { + private calculateNewQuote = async ( + swap: ChainSwapInfo, + ): Promise< + ReturnType & { + extraFee?: number; + } + > => { const referral = swap.chainSwap.referral === null || swap.chainSwap.referral === undefined ? null @@ -237,12 +248,31 @@ class Renegotiator { referral, ); - return this.calculateServerLockAmount( + const serverLockAmount = this.calculateServerLockAmount( pair.rate, swap.receivingData.amount!, feePercent, baseFee, ); + + let extraFee: number | undefined = undefined; + + const extraFees = await ExtraFeeRepository.get(swap.id); + if (extraFees !== undefined && extraFees !== null) { + extraFee = FeeProvider.calculateExtraFee( + extraFees.percentage, + swap.receivingData.amount!, + pair.rate, + ); + serverLockAmount.percentageFee = Math.floor( + serverLockAmount.percentageFee - extraFee, + ); + } + + return { + ...serverLockAmount, + extraFee, + }; }; private validateEligibility = async ( diff --git a/lib/service/Service.ts b/lib/service/Service.ts index c6dafc28..b516f691 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -53,6 +53,7 @@ import ChainSwapRepository, { ChainSwapInfo, } from '../db/repositories/ChainSwapRepository'; import ChannelCreationRepository from '../db/repositories/ChannelCreationRepository'; +import ExtraFeeRepository from '../db/repositories/ExtraFeeRepository'; import PairRepository from '../db/repositories/PairRepository'; import ReferralRepository from '../db/repositories/ReferralRepository'; import ReverseRoutingHintRepository from '../db/repositories/ReverseRoutingHintRepository'; @@ -73,6 +74,7 @@ import { GetInfoResponse, LightningInfo, } from '../proto/boltzrpc_pb'; +import FeeProvider from '../rates/FeeProvider'; import LockupTransactionTracker from '../rates/LockupTransactionTracker'; import RateProvider from '../rates/RateProvider'; import { PairTypeLegacy } from '../rates/providers/RateProviderLegacy'; @@ -124,6 +126,11 @@ type WebHookData = { status?: string[]; }; +type ExtraFees = { + id: string; + percentage: number; +}; + type SomePair = | PairTypeLegacy | SubmarinePairTypeTaproot @@ -1298,6 +1305,7 @@ class Service { id: string, invoice: string, pairHash?: string, + extraFees?: ExtraFees, ) => { const swap = await SwapRepository.getSwap({ id, @@ -1345,7 +1353,7 @@ class Service { throw SwapErrors.NO_ROUTE_FOUND(); } - return this.setSwapInvoice(swap, invoice, true, pairHash); + return this.setSwapInvoice(swap, invoice, true, pairHash, extraFees); }; /** @@ -1356,6 +1364,7 @@ class Service { invoice: string, canBeRouted: boolean, pairHash?: string, + extraFees?: ExtraFees, ): Promise<{ bip21: string; expectedAmount: number; @@ -1432,19 +1441,24 @@ class Service { swap.referral, ); - const { baseFee, percentageFee } = this.rateProvider.feeProvider.getFees( - swap.pair, - swap.version, - rate, - swap.orderSide, - swap.invoiceAmount, - SwapType.Submarine, - BaseFeeType.NormalClaim, - referral, - ); + const { baseFee, percentageFee, extraFee } = + this.rateProvider.feeProvider.getFees( + swap.pair, + swap.version, + rate, + swap.orderSide, + swap.invoiceAmount, + SwapType.Submarine, + BaseFeeType.NormalClaim, + referral, + extraFees, + ); const expectedAmount = - Math.floor(swap.invoiceAmount * rate) + baseFee + percentageFee; + Math.floor(swap.invoiceAmount * rate) + + baseFee + + percentageFee + + (extraFee || 0); if (swap.onchainAmount && expectedAmount > swap.onchainAmount) { const maxInvoiceAmount = this.calculateInvoiceAmount( @@ -1497,6 +1511,8 @@ class Service { this.eventHandler.emitSwapInvoiceSet, ); + await this.createExtraFees(swap.id, extraFee, extraFees); + return { expectedAmount, acceptZeroConf, @@ -1524,6 +1540,7 @@ class Service { channel?: ChannelCreationInfo, version: SwapVersion = SwapVersion.Legacy, webHook?: WebHookData, + extraFees?: ExtraFees, ): Promise<{ id: string; bip21: string; @@ -1582,6 +1599,7 @@ class Service { invoice, createdSwap.canBeRouted, pairHash, + extraFees, ); return { @@ -1653,6 +1671,7 @@ class Service { descriptionHash?: Buffer; webHook?: WebHookData; + extraFees?: ExtraFees; invoiceExpiry?: number; }): Promise<{ @@ -1813,6 +1832,22 @@ class Service { throw Errors.NO_AMOUNT_SPECIFIED(); } + let extraFee: number | undefined = undefined; + + if (args.extraFees !== undefined) { + extraFee = FeeProvider.calculateExtraFee( + args.extraFees.percentage, + holdInvoiceAmount, + rate, + ); + + if (invoiceAmountDefined) { + onchainAmount = Math.floor(onchainAmount - extraFee); + } else { + holdInvoiceAmount = Math.ceil(holdInvoiceAmount + extraFee); + } + } + await this.verifyAmount( args.pairId, rate, @@ -1930,6 +1965,8 @@ class Service { }); } + await this.createExtraFees(id, extraFee, args.extraFees); + const response: any = { id, invoice, @@ -1974,6 +2011,7 @@ class Service { serverLockAmount?: number; webHook?: WebHookData; + extraFees?: ExtraFees; }) => { await this.checkSwapWithPreimageExists(args.preimageHash); @@ -2069,6 +2107,7 @@ class Service { const isZeroAmount = args.userLockAmount === undefined && args.serverLockAmount === undefined; + let userLockAmountDefined: boolean = false; if ( args.userLockAmount !== undefined && @@ -2076,6 +2115,8 @@ class Service { ) { throw Errors.USER_AND_SERVER_AMOUNT_SPECIFIED(); } else if (args.userLockAmount !== undefined) { + userLockAmountDefined = true; + this.checkWholeNumber(args.userLockAmount); const calcRes = this.swapManager.renegotiator.calculateServerLockAmount( @@ -2088,6 +2129,8 @@ class Service { percentageFee = calcRes.percentageFee; args.serverLockAmount = calcRes.serverLockAmount; } else if (args.serverLockAmount !== undefined) { + userLockAmountDefined = false; + this.checkWholeNumber(args.serverLockAmount); args.userLockAmount = (args.serverLockAmount + baseFee) / rate; @@ -2103,7 +2146,23 @@ class Service { throw Errors.NO_AMOUNT_SPECIFIED(); } + let extraFee: number | undefined = undefined; + if (!isZeroAmount) { + if (args.extraFees !== undefined) { + extraFee = FeeProvider.calculateExtraFee( + args.extraFees.percentage, + args.userLockAmount, + rate, + ); + + if (userLockAmountDefined) { + args.serverLockAmount = Math.floor(args.serverLockAmount - extraFee); + } else { + args.userLockAmount = Math.ceil(args.userLockAmount + extraFee); + } + } + await this.verifyAmount( args.pairId, rate, @@ -2151,6 +2210,8 @@ class Service { }); } + await this.createExtraFees(res.id, extraFee, args.extraFees); + return { referralId, id: res.id, @@ -2293,6 +2354,24 @@ class Service { } }; + private createExtraFees = async ( + id: string, + fee?: number, + extraFees?: ExtraFees, + ) => { + if (extraFees !== undefined) { + this.logger.debug( + `Adding extra fee for swap ${id}: ${fee || '0 amount swap'} (${extraFees.percentage}%)`, + ); + await ExtraFeeRepository.create({ + fee, + swapId: id, + id: extraFees.id, + percentage: extraFees.percentage, + }); + } + }; + /** * Calculates the amount of an invoice for a Submarine Swap */ @@ -2397,4 +2476,4 @@ class Service { export const cancelledViaCliFailureReason = 'payment has been cancelled'; export default Service; -export { WebHookData, Contracts, NetworkContracts }; +export { WebHookData, Contracts, NetworkContracts, ExtraFees };