This commit is contained in:
boufni95 2025-12-15 17:13:50 +00:00
parent fd7a5eb1d6
commit e2dec7d9b3
14 changed files with 138 additions and 49 deletions

View file

@ -9,6 +9,7 @@
#LND_CERT_PATH=~/.lnd/tls.cert #LND_CERT_PATH=~/.lnd/tls.cert
#LND_MACAROON_PATH=~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon #LND_MACAROON_PATH=~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon
#LND_LOG_DIR=~/.lnd/logs/bitcoin/mainnet/lnd.log #LND_LOG_DIR=~/.lnd/logs/bitcoin/mainnet/lnd.log
#BTC_NETWORK=mainnet
#BOOTSTRAP_PEER #BOOTSTRAP_PEER
# A trusted peer that will hold a node-level account until channel automation becomes affordable # A trusted peer that will hold a node-level account until channel automation becomes affordable
@ -49,8 +50,9 @@
#LIGHTNING #LIGHTNING
# Maximum amount in network fees passed to LND when it pays an external invoice # Maximum amount in network fees passed to LND when it pays an external invoice
# BPS are basis points, 100 BPS = 1% # BPS are basis points, 100 BPS = 1%
#OUTBOUND_MAX_FEE_BPS=60 #OUTBOUND_MAX_FEE_BPS=60 // deprecated
#OUTBOUND_MAX_FEE_EXTRA_SATS=100 #OUTBOUND_MAX_FEE_EXTRA_SATS=100 // deprecated use OUTBOUND_FEE_FLOOR_SATS instead
#OUTBOUND_FEE_FLOOR_SATS=10
# If the back-end doesn't have adequate channel capacity, buy one from an LSP # If the back-end doesn't have adequate channel capacity, buy one from an LSP
# Will execute when it costs less than 1% of balance and uses a trusted peer # Will execute when it costs less than 1% of balance and uses a trusted peer
#BOOTSTRAP=1 #BOOTSTRAP=1

View file

@ -1169,7 +1169,7 @@ The nostr server will send back a message response, and inside the body there wi
- __invitation_link__: _string_ - __invitation_link__: _string_
### CumulativeFees ### CumulativeFees
- __networkFeeFixed__: _number_ - __outboundFeeFloor__: _number_
- __serviceFeeBps__: _number_ - __serviceFeeBps__: _number_
### DebitAuthorization ### DebitAuthorization
@ -1601,6 +1601,7 @@ The nostr server will send back a message response, and inside the body there wi
- __swap_operation_id__: _string_ - __swap_operation_id__: _string_
### SwapsList ### SwapsList
- __quotes__: ARRAY of: _[TransactionSwapQuote](#TransactionSwapQuote)_
- __swaps__: ARRAY of: _[SwapOperation](#SwapOperation)_ - __swaps__: ARRAY of: _[SwapOperation](#SwapOperation)_
### TransactionSwapQuote ### TransactionSwapQuote

View file

@ -230,8 +230,8 @@ type CreateOneTimeInviteLinkResponse struct {
Invitation_link string `json:"invitation_link"` Invitation_link string `json:"invitation_link"`
} }
type CumulativeFees struct { type CumulativeFees struct {
Networkfeefixed int64 `json:"networkFeeFixed"` Outboundfeefloor int64 `json:"outboundFeeFloor"`
Servicefeebps int64 `json:"serviceFeeBps"` Servicefeebps int64 `json:"serviceFeeBps"`
} }
type DebitAuthorization struct { type DebitAuthorization struct {
Authorized bool `json:"authorized"` Authorized bool `json:"authorized"`
@ -662,7 +662,8 @@ type SwapOperation struct {
Swap_operation_id string `json:"swap_operation_id"` Swap_operation_id string `json:"swap_operation_id"`
} }
type SwapsList struct { type SwapsList struct {
Swaps []SwapOperation `json:"swaps"` Quotes []TransactionSwapQuote `json:"quotes"`
Swaps []SwapOperation `json:"swaps"`
} }
type TransactionSwapQuote struct { type TransactionSwapQuote struct {
Chain_fee_sats int64 `json:"chain_fee_sats"` Chain_fee_sats int64 `json:"chain_fee_sats"`

View file

@ -1303,21 +1303,21 @@ export const CreateOneTimeInviteLinkResponseValidate = (o?: CreateOneTimeInviteL
} }
export type CumulativeFees = { export type CumulativeFees = {
networkFeeFixed: number outboundFeeFloor: number
serviceFeeBps: number serviceFeeBps: number
} }
export const CumulativeFeesOptionalFields: [] = [] export const CumulativeFeesOptionalFields: [] = []
export type CumulativeFeesOptions = OptionsBaseMessage & { export type CumulativeFeesOptions = OptionsBaseMessage & {
checkOptionalsAreSet?: [] checkOptionalsAreSet?: []
networkFeeFixed_CustomCheck?: (v: number) => boolean outboundFeeFloor_CustomCheck?: (v: number) => boolean
serviceFeeBps_CustomCheck?: (v: number) => boolean serviceFeeBps_CustomCheck?: (v: number) => boolean
} }
export const CumulativeFeesValidate = (o?: CumulativeFees, opts: CumulativeFeesOptions = {}, path: string = 'CumulativeFees::root.'): Error | null => { export const CumulativeFeesValidate = (o?: CumulativeFees, opts: CumulativeFeesOptions = {}, path: string = 'CumulativeFees::root.'): Error | null => {
if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message')
if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null')
if (typeof o.networkFeeFixed !== 'number') return new Error(`${path}.networkFeeFixed: is not a number`) if (typeof o.outboundFeeFloor !== 'number') return new Error(`${path}.outboundFeeFloor: is not a number`)
if (opts.networkFeeFixed_CustomCheck && !opts.networkFeeFixed_CustomCheck(o.networkFeeFixed)) return new Error(`${path}.networkFeeFixed: custom check failed`) if (opts.outboundFeeFloor_CustomCheck && !opts.outboundFeeFloor_CustomCheck(o.outboundFeeFloor)) return new Error(`${path}.outboundFeeFloor: custom check failed`)
if (typeof o.serviceFeeBps !== 'number') return new Error(`${path}.serviceFeeBps: is not a number`) if (typeof o.serviceFeeBps !== 'number') return new Error(`${path}.serviceFeeBps: is not a number`)
if (opts.serviceFeeBps_CustomCheck && !opts.serviceFeeBps_CustomCheck(o.serviceFeeBps)) return new Error(`${path}.serviceFeeBps: custom check failed`) if (opts.serviceFeeBps_CustomCheck && !opts.serviceFeeBps_CustomCheck(o.serviceFeeBps)) return new Error(`${path}.serviceFeeBps: custom check failed`)
@ -3887,11 +3887,14 @@ export const SwapOperationValidate = (o?: SwapOperation, opts: SwapOperationOpti
} }
export type SwapsList = { export type SwapsList = {
quotes: TransactionSwapQuote[]
swaps: SwapOperation[] swaps: SwapOperation[]
} }
export const SwapsListOptionalFields: [] = [] export const SwapsListOptionalFields: [] = []
export type SwapsListOptions = OptionsBaseMessage & { export type SwapsListOptions = OptionsBaseMessage & {
checkOptionalsAreSet?: [] checkOptionalsAreSet?: []
quotes_ItemOptions?: TransactionSwapQuoteOptions
quotes_CustomCheck?: (v: TransactionSwapQuote[]) => boolean
swaps_ItemOptions?: SwapOperationOptions swaps_ItemOptions?: SwapOperationOptions
swaps_CustomCheck?: (v: SwapOperation[]) => boolean swaps_CustomCheck?: (v: SwapOperation[]) => boolean
} }
@ -3899,6 +3902,13 @@ export const SwapsListValidate = (o?: SwapsList, opts: SwapsListOptions = {}, pa
if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message')
if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null')
if (!Array.isArray(o.quotes)) return new Error(`${path}.quotes: is not an array`)
for (let index = 0; index < o.quotes.length; index++) {
const quotesErr = TransactionSwapQuoteValidate(o.quotes[index], opts.quotes_ItemOptions, `${path}.quotes[${index}]`)
if (quotesErr !== null) return quotesErr
}
if (opts.quotes_CustomCheck && !opts.quotes_CustomCheck(o.quotes)) return new Error(`${path}.quotes: custom check failed`)
if (!Array.isArray(o.swaps)) return new Error(`${path}.swaps: is not an array`) if (!Array.isArray(o.swaps)) return new Error(`${path}.swaps: is not an array`)
for (let index = 0; index < o.swaps.length; index++) { for (let index = 0; index < o.swaps.length; index++) {
const swapsErr = SwapOperationValidate(o.swaps[index], opts.swaps_ItemOptions, `${path}.swaps[${index}]`) const swapsErr = SwapOperationValidate(o.swaps[index], opts.swaps_ItemOptions, `${path}.swaps[${index}]`)

View file

@ -853,10 +853,11 @@ message SwapOperation {
message SwapsList { message SwapsList {
repeated SwapOperation swaps = 1; repeated SwapOperation swaps = 1;
repeated TransactionSwapQuote quotes = 2;
} }
message CumulativeFees { message CumulativeFees {
int64 networkFeeFixed = 2; int64 outboundFeeFloor = 2;
int64 serviceFeeBps = 3; int64 serviceFeeBps = 3;
} }

View file

@ -14,6 +14,7 @@ import ws from 'ws';
import { getLogger, PubLogger, ERROR } from '../helpers/logger.js'; import { getLogger, PubLogger, ERROR } from '../helpers/logger.js';
import SettingsManager from '../main/settingsManager.js'; import SettingsManager from '../main/settingsManager.js';
import * as Types from '../../../proto/autogenerated/ts/types.js'; import * as Types from '../../../proto/autogenerated/ts/types.js';
import { BTCNetwork } from '../main/settings.js';
type InvoiceSwapResponse = { id: string, claimPublicKey: string, swapTree: string } type InvoiceSwapResponse = { id: string, claimPublicKey: string, swapTree: string }
type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface } type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface }
@ -43,7 +44,6 @@ type TransactionSwapResponse = {
} }
type TransactionSwapInfo = { destinationAddress: string, preimage: Buffer, keys: ECPairInterface, chainFee: number } type TransactionSwapInfo = { destinationAddress: string, preimage: Buffer, keys: ECPairInterface, chainFee: number }
export type TransactionSwapData = { createdResponse: TransactionSwapResponse, info: TransactionSwapInfo } export type TransactionSwapData = { createdResponse: TransactionSwapResponse, info: TransactionSwapInfo }
const network = Networks.bitcoinMainnet
export class Swaps { export class Swaps {
reverseSwaps: ReverseSwaps reverseSwaps: ReverseSwaps
submarineSwaps: SubmarineSwaps submarineSwaps: SubmarineSwaps
@ -52,6 +52,8 @@ export class Swaps {
this.submarineSwaps = new SubmarineSwaps(settings) this.submarineSwaps = new SubmarineSwaps(settings)
} }
Stop = () => { }
GetKeys = (privateKey: string) => { GetKeys = (privateKey: string) => {
const keys = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKey, 'hex')) const keys = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKey, 'hex'))
return keys return keys
@ -230,17 +232,11 @@ export class ReverseSwaps {
const url = `${this.settings.getSettings().swapsSettings.boltzHttpUrl}/v2/swap/reverse` const url = `${this.settings.getSettings().swapsSettings.boltzHttpUrl}/v2/swap/reverse`
const req: any = { const req: any = {
onchainAmount: txAmount, onchainAmount: txAmount,
// invoiceAmount,
to: 'BTC', to: 'BTC',
from: 'BTC', from: 'BTC',
claimPublicKey: Buffer.from(keys.publicKey).toString('hex'), claimPublicKey: Buffer.from(keys.publicKey).toString('hex'),
preimageHash: createHash('sha256').update(preimage).digest('hex'), preimageHash: createHash('sha256').update(preimage).digest('hex'),
} }
/* if (amount.type === Types.TransactionSwapRequest_amount_type.INVOICE_AMOUNT_SATS) {
req.invoiceAmount = amount.invoice_amount_sats
} else if (amount.type === Types.TransactionSwapRequest_amount_type.TRANSACTION_AMOUNT_SATS) {
req.onchainAmount = amount.transaction_amount_sats
} */
const createdResponseRes = await loggedPost<TransactionSwapResponse>(this.log, url, req) const createdResponseRes = await loggedPost<TransactionSwapResponse>(this.log, url, req)
if (!createdResponseRes.ok) { if (!createdResponseRes.ok) {
return createdResponseRes return createdResponseRes
@ -262,11 +258,21 @@ export class ReverseSwaps {
webSocket.on('open', () => { webSocket.on('open', () => {
webSocket.send(JSON.stringify(subReq)) webSocket.send(JSON.stringify(subReq))
}) })
let txId = "" let txId = "", isDone = false
const done = () => { const done = () => {
isDone = true
webSocket.close() webSocket.close()
swapDone({ ok: true, txId }) swapDone({ ok: true, txId })
} }
webSocket.on('error', (err) => {
this.log(ERROR, 'Error in WebSocket', err.message)
})
webSocket.on('close', () => {
if (!isDone) {
this.log(ERROR, 'WebSocket closed before swap was done');
swapDone({ ok: false, error: 'WebSocket closed before swap was done' })
}
})
webSocket.on('message', async (rawMsg) => { webSocket.on('message', async (rawMsg) => {
try { try {
const result = await this.handleSwapTransactionMessage(rawMsg, data, done) const result = await this.handleSwapTransactionMessage(rawMsg, data, done)
@ -275,6 +281,7 @@ export class ReverseSwaps {
} }
} catch (err: any) { } catch (err: any) {
this.log(ERROR, 'Error handling transaction WebSocket message', err.message) this.log(ERROR, 'Error handling transaction WebSocket message', err.message)
isDone = true
webSocket.close() webSocket.close()
swapDone({ ok: false, error: err.message }) swapDone({ ok: false, error: err.message })
return return
@ -336,7 +343,7 @@ export class ReverseSwaps {
this.log(ERROR, 'No swap output found in lockup transaction'); this.log(ERROR, 'No swap output found in lockup transaction');
return { ok: false, error: 'No swap output found in lockup transaction' } return { ok: false, error: 'No swap output found in lockup transaction' }
} }
const network = getNetwork(this.settings.getSettings().lndSettings.network)
// Create a claim transaction to be signed cooperatively via a key path spend // Create a claim transaction to be signed cooperatively via a key path spend
const claimTx = constructClaimTransaction( const claimTx = constructClaimTransaction(
[ [
@ -436,4 +443,17 @@ const loggedGet = async <T>(log: PubLogger, url: string): Promise<{ ok: true, da
log(ERROR, 'Error getting request', err.message) log(ERROR, 'Error getting request', err.message)
return { ok: false, error: err.message } return { ok: false, error: err.message }
} }
}
const getNetwork = (network: BTCNetwork): Network => {
switch (network) {
case 'mainnet':
return Networks.bitcoinMainnet
case 'testnet':
return Networks.bitcoinTestnet
case 'regtest':
return Networks.bitcoinRegtest
default:
throw new Error(`Invalid network: ${network}`)
}
} }

View file

@ -69,14 +69,14 @@ export default class {
throw new Error(`app user ${ctx.user_id} not found`) // TODO: fix logs doxing throw new Error(`app user ${ctx.user_id} not found`) // TODO: fix logs doxing
} }
const nostrSettings = this.settings.getSettings().nostrRelaySettings const nostrSettings = this.settings.getSettings().nostrRelaySettings
const { max, networkFeeFixed, serviceFeeBps } = this.applicationManager.paymentManager.GetMaxPayableInvoice(user.balance_sats) const { max, outboundFeeFloor, serviceFeeBps } = this.applicationManager.paymentManager.GetMaxPayableInvoice(user.balance_sats)
return { return {
userId: ctx.user_id, userId: ctx.user_id,
balance: user.balance_sats, balance: user.balance_sats,
max_withdrawable: max, max_withdrawable: max,
user_identifier: appUser.identifier, user_identifier: appUser.identifier,
network_max_fee_bps: 0, network_max_fee_bps: 0,
network_max_fee_fixed: networkFeeFixed, network_max_fee_fixed: outboundFeeFloor,
service_fee_bps: serviceFeeBps, service_fee_bps: serviceFeeBps,
noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: appUser.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }), noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: appUser.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }),
ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: appUser.identifier, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: appUser.identifier, relay: nostrSettings.relays[0] }),

View file

@ -167,7 +167,7 @@ export default class {
const ndebitString = ndebitEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }) const ndebitString = ndebitEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] })
log("🔗 [DEBUG] Generated ndebit for user", { userId: u.user.user_id, ndebit: ndebitString }) log("🔗 [DEBUG] Generated ndebit for user", { userId: u.user.user_id, ndebit: ndebitString })
const { max, networkFeeFixed, serviceFeeBps } = this.paymentManager.GetMaxPayableInvoice(u.user.balance_sats) const { max, outboundFeeFloor, serviceFeeBps } = this.paymentManager.GetMaxPayableInvoice(u.user.balance_sats)
return { return {
identifier: u.identifier, identifier: u.identifier,
info: { info: {
@ -176,7 +176,7 @@ export default class {
max_withdrawable: max, max_withdrawable: max,
user_identifier: u.identifier, user_identifier: u.identifier,
network_max_fee_bps: 0, network_max_fee_bps: 0,
network_max_fee_fixed: networkFeeFixed, network_max_fee_fixed: outboundFeeFloor,
service_fee_bps: serviceFeeBps, service_fee_bps: serviceFeeBps,
noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: u.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }), noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: u.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }),
ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }),
@ -225,14 +225,14 @@ export default class {
const app = await this.storage.applicationStorage.GetApplication(appId) const app = await this.storage.applicationStorage.GetApplication(appId)
const user = await this.storage.applicationStorage.GetApplicationUser(app, req.user_identifier) const user = await this.storage.applicationStorage.GetApplicationUser(app, req.user_identifier)
const nostrSettings = this.settings.getSettings().nostrRelaySettings const nostrSettings = this.settings.getSettings().nostrRelaySettings
const { max, networkFeeFixed, serviceFeeBps } = this.paymentManager.GetMaxPayableInvoice(user.user.balance_sats) const { max, outboundFeeFloor, serviceFeeBps } = this.paymentManager.GetMaxPayableInvoice(user.user.balance_sats)
return { return {
max_withdrawable: max, identifier: req.user_identifier, info: { max_withdrawable: max, identifier: req.user_identifier, info: {
userId: user.user.user_id, balance: user.user.balance_sats, userId: user.user.user_id, balance: user.user.balance_sats,
max_withdrawable: max, max_withdrawable: max,
user_identifier: user.identifier, user_identifier: user.identifier,
network_max_fee_bps: 0, network_max_fee_bps: 0,
network_max_fee_fixed: networkFeeFixed, network_max_fee_fixed: outboundFeeFloor,
service_fee_bps: serviceFeeBps, service_fee_bps: serviceFeeBps,
noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: user.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }), noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: user.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }),
ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: user.identifier, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: user.identifier, relay: nostrSettings.relays[0] }),

View file

@ -170,7 +170,8 @@ export default class {
NewBlockHandler = async (height: number) => { NewBlockHandler = async (height: number) => {
let confirmed: (PendingTx & { confs: number; })[] let confirmed: (PendingTx & { confs: number; })[]
let log = getLogger({}) let log = getLogger({})
this.storage.paymentStorage.DeleteExpiredTransactionSwaps(height)
.catch(err => log(ERROR, "failed to delete expired transaction swaps", err.message || err))
try { try {
const balanceEvents = await this.paymentManager.GetLndBalance() const balanceEvents = await this.paymentManager.GetLndBalance()
await this.metricsManager.NewBlockCb(height, balanceEvents) await this.metricsManager.NewBlockCb(height, balanceEvents)

View file

@ -131,7 +131,7 @@ export class LiquidityProvider {
return res return res
} }
this.feesCache = { this.feesCache = {
networkFeeFixed: res.network_max_fee_fixed, outboundFeeFloor: res.network_max_fee_fixed,
serviceFeeBps: res.service_fee_bps serviceFeeBps: res.service_fee_bps
} }
this.latestReceivedBalance = res.balance this.latestReceivedBalance = res.balance
@ -152,11 +152,11 @@ export class LiquidityProvider {
return 0 return 0
} }
const balance = this.latestReceivedBalance const balance = this.latestReceivedBalance
const { networkFeeFixed, serviceFeeBps } = this.feesCache const { outboundFeeFloor, serviceFeeBps } = this.feesCache
const div = 1 + (serviceFeeBps / 10000) const div = 1 + (serviceFeeBps / 10000)
const maxWithoutFixed = Math.floor(balance / div) const maxWithoutFixed = Math.floor(balance / div)
const fee = balance - maxWithoutFixed const fee = balance - maxWithoutFixed
return balance - Math.max(fee, networkFeeFixed) return balance - Math.max(fee, outboundFeeFloor)
} }
GetLatestBalance = () => { GetLatestBalance = () => {
@ -174,7 +174,7 @@ export class LiquidityProvider {
const fees = f ? f : this.GetFees() const fees = f ? f : this.GetFees()
const serviceFeeRate = fees.serviceFeeBps / 10000 const serviceFeeRate = fees.serviceFeeBps / 10000
const serviceFee = Math.ceil(serviceFeeRate * amount) const serviceFee = Math.ceil(serviceFeeRate * amount)
return Math.max(serviceFee, fees.networkFeeFixed) return Math.max(serviceFee, fees.outboundFeeFloor)
} }
CanProviderPay = async (amount: number, localServiceFee: number): Promise<boolean> => { CanProviderPay = async (amount: number, localServiceFee: number): Promise<boolean> => {

View file

@ -55,6 +55,7 @@ export default class {
liquidityManager: LiquidityManager liquidityManager: LiquidityManager
utils: Utils utils: Utils
swaps: Swaps swaps: Swaps
invoiceLock: InvoiceLock
constructor(storage: Storage, lnd: LND, settings: SettingsManager, liquidityManager: LiquidityManager, utils: Utils, addressPaidCb: AddressPaidCb, invoicePaidCb: InvoicePaidCb) { constructor(storage: Storage, lnd: LND, settings: SettingsManager, liquidityManager: LiquidityManager, utils: Utils, addressPaidCb: AddressPaidCb, invoicePaidCb: InvoicePaidCb) {
this.storage = storage this.storage = storage
this.settings = settings this.settings = settings
@ -65,9 +66,13 @@ export default class {
this.swaps = new Swaps(settings) this.swaps = new Swaps(settings)
this.addressPaidCb = addressPaidCb this.addressPaidCb = addressPaidCb
this.invoicePaidCb = invoicePaidCb this.invoicePaidCb = invoicePaidCb
this.invoiceLock = new InvoiceLock()
} }
Stop() { Stop() {
this.watchDog.Stop() this.watchDog.Stop()
this.swaps.Stop()
} }
checkPendingPayments = async () => { checkPendingPayments = async () => {
@ -192,7 +197,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.feeFixedLimit) return Math.max(fee, this.settings.getSettings().lndSettings.outboundFeeFloor)
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)
@ -251,17 +256,17 @@ export default class {
GetFees = (): Types.CumulativeFees => { GetFees = (): Types.CumulativeFees => {
const { outgoingAppUserInvoiceFeeBps } = this.settings.getSettings().serviceFeeSettings const { outgoingAppUserInvoiceFeeBps } = this.settings.getSettings().serviceFeeSettings
const { feeFixedLimit } = this.settings.getSettings().lndSettings const { outboundFeeFloor } = this.settings.getSettings().lndSettings
return { networkFeeFixed: feeFixedLimit, serviceFeeBps: outgoingAppUserInvoiceFeeBps } return { outboundFeeFloor, serviceFeeBps: outgoingAppUserInvoiceFeeBps }
} }
GetMaxPayableInvoice(balance: number): Types.CumulativeFees & { max: number } { GetMaxPayableInvoice(balance: number): Types.CumulativeFees & { max: number } {
const { networkFeeFixed, serviceFeeBps } = this.GetFees() const { outboundFeeFloor, serviceFeeBps } = this.GetFees()
const div = 1 + (serviceFeeBps / 10000) const div = 1 + (serviceFeeBps / 10000)
const maxWithoutFixed = Math.floor(balance / div) const maxWithoutFixed = Math.floor(balance / div)
const fee = balance - maxWithoutFixed const fee = balance - maxWithoutFixed
const max = balance - Math.max(fee, networkFeeFixed) const max = balance - Math.max(fee, outboundFeeFloor)
return { max, networkFeeFixed, serviceFeeBps } return { max, outboundFeeFloor, serviceFeeBps }
} }
async DecodeInvoice(req: Types.DecodeInvoiceRequest): Promise<Types.DecodeInvoiceResponse> { async DecodeInvoice(req: Types.DecodeInvoiceRequest): Promise<Types.DecodeInvoiceResponse> {
const decoded = await this.lnd.DecodeInvoice(req.invoice) const decoded = await this.lnd.DecodeInvoice(req.invoice)
@ -277,10 +282,10 @@ export default class {
throw new Error("user is banned, cannot send payment") throw new Error("user is banned, cannot send payment")
} }
if (req.expected_fees) { if (req.expected_fees) {
const { networkFeeFixed, serviceFeeBps } = req.expected_fees const { outboundFeeFloor, serviceFeeBps } = req.expected_fees
const serviceFixed = this.settings.getSettings().lndSettings.feeFixedLimit const serviceFixed = this.settings.getSettings().lndSettings.outboundFeeFloor
const serviceBps = this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFeeBps const serviceBps = this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFeeBps
if (serviceFixed !== networkFeeFixed || 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")
} }
} }
@ -303,10 +308,20 @@ export default class {
throw new Error("this invoice was already paid") throw new Error("this invoice was already paid")
} }
let paymentInfo = { preimage: "", amtPaid: 0, networkFee: 0, serialId: 0 } let paymentInfo = { preimage: "", amtPaid: 0, networkFee: 0, serialId: 0 }
if (internalInvoice) { if (this.invoiceLock.isLocked(req.invoice)) {
paymentInfo = await this.PayInternalInvoice(userId, internalInvoice, { payAmount, serviceFee }, linkedApplication, req.debit_npub) throw new Error("this invoice is already being paid")
} else { }
paymentInfo = await this.PayExternalInvoice(userId, req.invoice, { payAmount, serviceFee, amountForLnd: req.amount }, linkedApplication, { ...optionals, debitNpub: req.debit_npub }) this.invoiceLock.lock(req.invoice)
try {
if (internalInvoice) {
paymentInfo = await this.PayInternalInvoice(userId, internalInvoice, { payAmount, serviceFee }, linkedApplication, req.debit_npub)
} else {
paymentInfo = await this.PayExternalInvoice(userId, req.invoice, { payAmount, serviceFee, amountForLnd: req.amount }, linkedApplication, { ...optionals, debitNpub: req.debit_npub })
}
this.invoiceLock.unlock(req.invoice)
} catch (err) {
this.invoiceLock.unlock(req.invoice)
throw err
} }
const feeDiff = serviceFee - paymentInfo.networkFee const feeDiff = serviceFee - paymentInfo.networkFee
if (isAppUserPayment && feeDiff > 0) { if (isAppUserPayment && feeDiff > 0) {
@ -456,7 +471,7 @@ export default class {
async PayAddressWithSwap(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise<Types.PayAddressResponse> { async PayAddressWithSwap(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise<Types.PayAddressResponse> {
this.log("paying external address") this.log("paying external address")
if (!req.swap_operation_id) { if (!req.swap_operation_id) {
throw new Error("request a swap quote before payng an external address") throw new Error("request a swap quote before paying an external address")
} }
const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const app = await this.storage.applicationStorage.GetApplication(ctx.app_id)
const txSwap = await this.storage.paymentStorage.GetTransactionSwap(req.swap_operation_id, ctx.app_user_id) const txSwap = await this.storage.paymentStorage.GetTransactionSwap(req.swap_operation_id, ctx.app_user_id)
@ -547,6 +562,7 @@ export default class {
async ListSwaps(ctx: Types.UserContext): Promise<Types.SwapsList> { async ListSwaps(ctx: Types.UserContext): Promise<Types.SwapsList> {
const swaps = await this.storage.paymentStorage.ListCompletedSwaps(ctx.app_user_id) const swaps = await this.storage.paymentStorage.ListCompletedSwaps(ctx.app_user_id)
const pendingSwaps = await this.storage.paymentStorage.ListPendingTransactionSwaps(ctx.app_user_id)
return { return {
swaps: swaps.map(s => { swaps: swaps.map(s => {
const p = s.payment const p = s.payment
@ -558,6 +574,17 @@ export default class {
address_paid: s.swap.address_paid, address_paid: s.swap.address_paid,
failure_reason: s.swap.failure_reason, failure_reason: s.swap.failure_reason,
} }
}),
quotes: pendingSwaps.map(s => {
const serviceFee = this.getSendServiceFee(Types.UserOperationType.OUTGOING_INVOICE, s.invoice_amount, true)
return {
swap_operation_id: s.swap_operation_id,
invoice_amount_sats: s.invoice_amount,
transaction_amount_sats: s.transaction_amount,
chain_fee_sats: s.chain_fee_sats,
service_fee_sats: serviceFee,
swap_fee_sats: s.swap_fee_sats,
}
}) })
} }
} }
@ -948,3 +975,16 @@ export default class {
} }
} }
class InvoiceLock {
locked: Record<string, boolean> = {}
lock(invoice: string) {
this.locked[invoice] = true
}
unlock(invoice: string) {
delete this.locked[invoice]
}
isLocked(invoice: string) {
return this.locked[invoice]
}
}

View file

@ -76,11 +76,13 @@ export type LndNodeSettings = {
lndCertPath: string // cold setting lndCertPath: string // cold setting
lndMacaroonPath: string // cold setting lndMacaroonPath: string // cold setting
} }
const networks = ['mainnet', 'testnet', 'regtest'] as const
export type BTCNetwork = (typeof networks)[number]
export type LndSettings = { export type LndSettings = {
lndLogDir: string lndLogDir: string
feeFixedLimit: number outboundFeeFloor: number
mockLnd: boolean mockLnd: boolean
network: BTCNetwork
} }
const resolveHome = (filepath: string) => { const resolveHome = (filepath: string) => {
@ -109,10 +111,14 @@ 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 limitOld = chooseEnvInt('OUTBOUND_MAX_FEE_EXTRA_SATS', dbEnv, 10, addToDb)
const outboundFeeFloor = chooseEnvInt('OUTBOUND_FEE_FLOOR_SATS', dbEnv, limitOld, 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),
feeFixedLimit: chooseEnvInt('OUTBOUND_MAX_FEE_EXTRA_SATS', dbEnv, 10, addToDb), outboundFeeFloor,
mockLnd: false mockLnd: false,
network: networks.includes(network) ? network : 'mainnet'
} }
} }

View file

@ -45,6 +45,9 @@ export class TransactionSwap {
@Column() @Column()
ephemeral_public_key: string ephemeral_public_key: string
// the private key is used on to perform a swap, it does not hold any funds once the swap is completed
// the swap should only last a few seconds, so it is not a security risk to store the private key in the database
// the key is stored here mostly for recovery purposes, in case something goes wrong with the swap
@Column() @Column()
ephemeral_private_key: string ephemeral_private_key: string

View file

@ -494,6 +494,10 @@ export default class {
return this.dbs.Delete<TransactionSwap>('TransactionSwap', { timeout_block_height: LessThan(currentHeight) }, txId) return this.dbs.Delete<TransactionSwap>('TransactionSwap', { timeout_block_height: LessThan(currentHeight) }, txId)
} }
async ListPendingTransactionSwaps(appUserId: string, txId?: string) {
return this.dbs.Find<TransactionSwap>('TransactionSwap', { where: { used: false, app_user_id: appUserId } }, txId)
}
async ListCompletedSwaps(appUserId: string, txId?: string) { async ListCompletedSwaps(appUserId: string, txId?: string) {
const completed = await this.dbs.Find<TransactionSwap>('TransactionSwap', { where: { used: true, app_user_id: appUserId } }, txId) const completed = await this.dbs.Find<TransactionSwap>('TransactionSwap', { where: { used: true, app_user_id: appUserId } }, txId)
const payments = await this.dbs.Find<UserInvoicePayment>('UserInvoicePayment', { where: { swap_operation_id: Not(IsNull()) } }, txId) const payments = await this.dbs.Find<UserInvoicePayment>('UserInvoicePayment', { where: { swap_operation_id: Not(IsNull()) } }, txId)