This commit is contained in:
hatim boufnichel 2024-06-12 19:16:51 +02:00
parent c60547b7bb
commit 3c6dc7c962
9 changed files with 293 additions and 20 deletions

View file

@ -30,6 +30,12 @@ LIQUIDITY_PROVIDER_PUB=
# Will execute when it costs less than 1% of balance and uses a trusted peer
#BOOTSTRAP=1
#LSP
OLYMPUS_LSP_URL=
VOLTAGE_LSP_URL=
LSP_CHANNEL_THRESHOLD=0
LSP_MAX_FEE_BPS=0
#ROOT_FEES
# Applied to either debits or credits and sent to an admin account
# BPS are basis points, 100 BPS = 1%

View file

@ -1,4 +1,5 @@
import { EnvMustBeNonEmptyString, EnvMustBeInteger, EnvCanBeBoolean, EnvCanBeInteger } from '../helpers/envParser.js'
import { LoadLiquiditySettingsFromEnv } from './liquidityManager.js'
import { LndSettings } from './settings.js'
export const LoadLndSettingsFromEnv = (): LndSettings => {
const lndAddr = process.env.LND_ADDRESS || "127.0.0.1:10009"
@ -7,6 +8,6 @@ export const LoadLndSettingsFromEnv = (): LndSettings => {
const feeRateLimit = EnvCanBeInteger("OUTBOUND_MAX_FEE_BPS", 60) / 10000
const feeFixedLimit = EnvCanBeInteger("OUTBOUND_MAX_FEE_EXTRA_SATS", 100)
const mockLnd = EnvCanBeBoolean("MOCK_LND")
const liquidityProviderPub = process.env.LIQUIDITY_PROVIDER_PUB || ""
return { mainNode: { lndAddr, lndCertPath, lndMacaroonPath }, feeRateLimit, feeFixedLimit, mockLnd, liquidityProviderPub, useOnlyLiquidityProvider: false }
const liquiditySettings = LoadLiquiditySettingsFromEnv()
return { mainNode: { lndAddr, lndCertPath, lndMacaroonPath }, feeRateLimit, feeFixedLimit, mockLnd, liquiditySettings }
}

View file

@ -0,0 +1,52 @@
import { getLogger } from "../helpers/logger.js"
import { LiquidityProvider } from "./liquidityProvider.js"
import LND from "./lnd.js"
import { LoadLSPSettingsFromEnv, LSPSettings, OlympusLSP, VoltageLSP } from "./lsp.js"
export type LiquiditySettings = {
lspSettings: LSPSettings
liquidityProviderPub: string
useOnlyLiquidityProvider: boolean
}
export const LoadLiquiditySettingsFromEnv = (): LiquiditySettings => {
const lspSettings = LoadLSPSettingsFromEnv()
const liquidityProviderPub = process.env.LIQUIDITY_PROVIDER_PUB || ""
return { lspSettings, liquidityProviderPub, useOnlyLiquidityProvider: false }
}
export class LiquidityManager {
settings: LiquiditySettings
liquidityProvider: LiquidityProvider
lnd: LND
olympusLSP: OlympusLSP
voltageLSP: VoltageLSP
log = getLogger({ component: "liquidityManager" })
channelRequested = false
constructor(settings: LiquiditySettings, liquidityProvider: LiquidityProvider, lnd: LND) {
this.settings = settings
this.liquidityProvider = liquidityProvider
this.lnd = lnd
this.olympusLSP = new OlympusLSP(settings.lspSettings, lnd, liquidityProvider)
this.voltageLSP = new VoltageLSP(settings.lspSettings, lnd, liquidityProvider)
}
beforeInvoiceCreation = async () => { }
afterInInvoicePaid = async () => {
if (this.channelRequested) {
return
}
const olympusOk = await this.olympusLSP.openChannelIfReady()
if (olympusOk) {
this.log("requested channel from olympus")
this.channelRequested = true
return
}
const voltageOk = await this.voltageLSP.openChannelIfReady()
if (voltageOk) {
this.log("requested channel from voltage")
this.channelRequested = true
return
}
this.log("no channel requested")
}
beforeOutInvoicePayment = async () => { }
afterOutInvoicePaid = async () => { }
}

View file

@ -81,7 +81,7 @@ export default class {
}
async ShouldUseLiquidityProvider(req: LiquidityRequest): Promise<boolean> {
if (this.settings.useOnlyLiquidityProvider) {
if (this.settings.liquiditySettings.useOnlyLiquidityProvider) {
return true
}
if (!this.liquidProvider.CanProviderHandle(req)) {

View file

@ -1,29 +1,235 @@
import fetch from "node-fetch"
import { LiquidityProvider } from "./liquidityProvider.js"
import { getLogger, PubLogger } from '../helpers/logger.js'
import LND from "./lnd.js"
import { AddressType } from "../../../proto/autogenerated/ts/types.js"
import { EnvCanBeInteger } from "../helpers/envParser.js"
export type LSPSettings = {
olympusServiceUrl: string
voltageServiceUrl: string
channelThreshold: number
maxRelativeFee: number
}
export class LSP {
serviceUrl: string
constructor(serviceUrl: string) {
this.serviceUrl = serviceUrl
export const LoadLSPSettingsFromEnv = (): LSPSettings => {
const olympusServiceUrl = process.env.OLYMPUS_LSP_URL || ""
const voltageServiceUrl = process.env.VOLTAGE_LSP_URL || ""
const channelThreshold = EnvCanBeInteger("LSP_CHANNEL_THRESHOLD")
const maxRelativeFee = EnvCanBeInteger("LSP_MAX_FEE_BPS") / 10000
return { olympusServiceUrl, voltageServiceUrl, channelThreshold, maxRelativeFee }
}
type OlympusOrder = {
"lsp_balance_sat": string,
"client_balance_sat": string,
"required_channel_confirmations": number,
"funding_confirms_within_blocks": number,
"channel_expiry_blocks": number,
"refund_onchain_address": string,
"announce_channel": boolean,
"public_key": string
}
class LSP {
settings: LSPSettings
liquidityProvider: LiquidityProvider
lnd: LND
log: PubLogger
constructor(serviceName: string, settings: LSPSettings, lnd: LND, liquidityProvider: LiquidityProvider) {
this.settings = settings
this.lnd = lnd
this.liquidityProvider = liquidityProvider
this.log = getLogger({ component: serviceName })
}
shouldOpenChannel = async (): Promise<{ shouldOpen: false } | { shouldOpen: true, maxSpendable: number }> => {
if (this.settings.channelThreshold === 0) {
this.log("channel threshold is 0")
return { shouldOpen: false }
}
const channels = await this.lnd.ListChannels()
if (channels.channels.length > 0) {
this.log("this node already has open channels")
return { shouldOpen: false }
}
const pendingChannels = await this.lnd.ListPendingChannels()
if (pendingChannels.pendingOpenChannels.length > 0) {
this.log("this node already has pending channels")
return { shouldOpen: false }
}
const userState = await this.liquidityProvider.CheckUserState()
if (!userState || userState.max_withdrawable < this.settings.channelThreshold) {
this.log("user balance too low to trigger channel request")
return { shouldOpen: false }
}
return { shouldOpen: true, maxSpendable: userState.max_withdrawable }
}
addPeer = async (pubKey: string, host: string) => {
const { peers } = await this.lnd.ListPeers()
if (!peers.find(p => p.pubKey === pubKey)) {
await this.lnd.ConnectPeer({ host, pubkey: pubKey })
}
}
}
export class OlympusLSP extends LSP {
constructor(settings: LSPSettings, lnd: LND, liquidityProvider: LiquidityProvider) {
super("OlympusLSP", settings, lnd, liquidityProvider)
}
openChannelIfReady = async (): Promise<boolean> => {
this.log("checking if channel should be opened")
const shouldOpen = await this.shouldOpenChannel()
if (!shouldOpen.shouldOpen) {
return false
}
if (!this.settings.olympusServiceUrl) {
this.log("no olympus service url provided")
return false
}
const serviceInfo = await this.getInfo()
if (+serviceInfo.options.min_initial_lsp_balance_sat > shouldOpen.maxSpendable) {
this.log("user balance too low for service minimum")
return false
}
const [servicePub, host] = serviceInfo.uris[0].split('@')
await this.addPeer(servicePub, host)
const lndInfo = await this.lnd.GetInfo()
const myPub = lndInfo.identityPubkey
const refundAddr = await this.lnd.NewAddress(AddressType.WITNESS_PUBKEY_HASH)
const lspBalance = (this.settings.channelThreshold * 2).toString()
const order = await this.createOrder({ pubKey: myPub, refundAddr: refundAddr.address, lspBalance, clientBalance: "0" })
if (order.payment.state !== 'EXPECT_PAYMENT') {
this.log("order not in expect payment state")
return false
}
const decoded = await this.lnd.DecodeInvoice(order.payment.bolt11_invoice)
if (decoded.numSatoshis !== +order.payment.order_total_sat) {
this.log("invoice amount does not match order total")
return false
}
if (decoded.numSatoshis > shouldOpen.maxSpendable) {
this.log("invoice amount exceeds user balance")
return false
}
const relativeFee = +order.payment.fee_total_sat / this.settings.channelThreshold
if (relativeFee > this.settings.maxRelativeFee) {
this.log("invoice fee exceeds max fee percent")
return false
}
await this.liquidityProvider.PayInvoice(order.payment.bolt11_invoice)
this.log("paid invoice to open channel")
return true
}
getInfo = async () => {
const res = await fetch(`${this.serviceUrl}/getinfo`)
const json = await res.json() as { options: {}, uris: string[] }
const res = await fetch(`${this.settings.olympusServiceUrl}/getinfo`)
const json = await res.json() as { options: { min_initial_lsp_balance_sat: string }, uris: string[] }
return json
}
createOrder = async (req: { public_key: string }) => {
const res = await fetch(`${this.serviceUrl}/create_order`, {
createOrder = async (orderInfo: { pubKey: string, refundAddr: string, lspBalance: string, clientBalance: string }) => {
const req: OlympusOrder = {
public_key: orderInfo.pubKey,
announce_channel: true,
refund_onchain_address: orderInfo.refundAddr,
lsp_balance_sat: orderInfo.lspBalance,
client_balance_sat: orderInfo.clientBalance,
channel_expiry_blocks: 144,
funding_confirms_within_blocks: 6,
required_channel_confirmations: 0
}
const res = await fetch(`${this.settings.olympusServiceUrl}/create_order`, {
method: "POST",
body: JSON.stringify(req),
headers: { "Content-Type": "application/json" }
})
const json = await res.json() as {}
const json = await res.json() as { order_id: string, payment: { state: 'EXPECT_PAYMENT', bolt11_invoice: string, fee_total_sat: string, order_total_sat: string } }
return json
}
getOrder = async (orderId: string) => {
const res = await fetch(`${this.serviceUrl}/get_order&order_id=${orderId}`)
const res = await fetch(`${this.settings.olympusServiceUrl}/get_order&order_id=${orderId}`)
const json = await res.json() as {}
return json
}
}
export class VoltageLSP extends LSP {
constructor(settings: LSPSettings, lnd: LND, liquidityProvider: LiquidityProvider) {
super("VoltageLSP", settings, lnd, liquidityProvider)
}
getInfo = async () => {
const res = await fetch(`${this.settings.voltageServiceUrl}/info`)
const json = await res.json() as { connection_methods: { address: string, port: string, type: string }[], pubkey: string }
return json
}
getFees = async (amtMsat: string, pubkey: string) => {
const res = await fetch(`${this.settings.voltageServiceUrl}/fee`, {
method: "POST",
body: JSON.stringify({ amount_msat: amtMsat, pubkey }),
headers: { "Content-Type": "application/json" }
})
const json = await res.json() as { fee_amount_msat: number, id: string }
return json
}
openChannelIfReady = async (): Promise<boolean> => {
const shouldOpen = await this.shouldOpenChannel()
if (!shouldOpen.shouldOpen) {
return false
}
if (!this.settings.voltageServiceUrl) {
this.log("no voltage service url provided")
return false
}
const lndInfo = await this.lnd.GetInfo()
const myPub = lndInfo.identityPubkey
const amtMsats = this.settings.channelThreshold.toString() + "000"
const fee = await this.getFees(amtMsats, myPub)
const feeSats = fee.fee_amount_msat / 1000
const relativeFee = feeSats / this.settings.channelThreshold
if (relativeFee > this.settings.maxRelativeFee) {
this.log("fee percent exceeds max fee percent")
return false
}
const info = await this.getInfo()
const ipv4 = info.connection_methods.find(c => c.type === 'ipv4')
if (!ipv4) {
this.log("no ipv4 address found")
return false
}
await this.addPeer(info.pubkey, `${ipv4.address}:${ipv4.port}`)
const invoice = await this.lnd.NewInvoice(this.settings.channelThreshold, "open channel", 60 * 60)
const res = await this.proposal(invoice.payRequest, fee.id)
const decoded = await this.lnd.DecodeInvoice(res.jit_bolt11)
if (decoded.numSatoshis !== this.settings.channelThreshold + feeSats) {
this.log("invoice amount does not math requested amount")
return false
}
await this.liquidityProvider.PayInvoice(res.jit_bolt11)
this.log("paid invoice to open channel")
return true
}
proposal = async (bolt11: string, feeId: string) => {
const res = await fetch(`${this.settings.voltageServiceUrl}/proposal`, {
method: "POST",
body: JSON.stringify({ bolt11, fee_id: feeId }),
headers: { "Content-Type": "application/json" }
})
const json = await res.json() as { jit_bolt11: string }
return json
}
}

View file

@ -1,4 +1,5 @@
import { HtlcEvent } from "../../../proto/lnd/router"
import { LiquiditySettings } from "./liquidityManager"
export type NodeSettings = {
lndAddr: string
lndCertPath: string
@ -9,11 +10,11 @@ export type LndSettings = {
feeRateLimit: number
feeFixedLimit: number
mockLnd: boolean
liquidityProviderPub: string
useOnlyLiquidityProvider: boolean
otherNode?: NodeSettings
thirdNode?: NodeSettings
liquiditySettings: LiquiditySettings
}
type TxOutput = {
hash: string

View file

@ -16,6 +16,7 @@ import { NostrSend } from '../nostr/handler.js'
import MetricsManager from '../metrics/index.js'
import { LoggedEvent } from '../storage/eventsLog.js'
import { LiquidityProvider } from "../lnd/liquidityProvider.js"
import { LiquidityManager } from "../lnd/liquidityManager.js"
type UserOperationsSub = {
id: string
@ -37,20 +38,22 @@ export default class {
paymentSubs: Record<string, ((op: Types.UserOperation) => void) | null> = {}
metricsManager: MetricsManager
liquidProvider: LiquidityProvider
liquidityManager: LiquidityManager
nostrSend: NostrSend = () => { getLogger({})("nostr send not initialized yet") }
constructor(settings: MainSettings, storage: Storage) {
this.settings = settings
this.storage = storage
this.liquidProvider = new LiquidityProvider(settings.lndSettings.liquidityProviderPub, this.invoicePaidCb)
this.liquidProvider = new LiquidityProvider(settings.lndSettings.liquiditySettings.liquidityProviderPub, this.invoicePaidCb)
this.lnd = new LND(settings.lndSettings, this.liquidProvider, this.addressPaidCb, this.invoicePaidCb, this.newBlockCb, this.htlcCb)
this.liquidityManager = new LiquidityManager(this.settings.lndSettings.liquiditySettings, this.liquidProvider, this.lnd)
this.metricsManager = new MetricsManager(this.storage, this.lnd)
this.paymentManager = new PaymentManager(this.storage, this.lnd, this.settings, this.addressPaidCb, this.invoicePaidCb)
this.productManager = new ProductManager(this.storage, this.paymentManager, this.settings)
this.applicationManager = new ApplicationManager(this.storage, this.settings, this.paymentManager)
this.appUserManager = new AppUserManager(this.storage, this.settings, this.applicationManager)
}
Stop() {
this.lnd.Stop()
this.applicationManager.Stop()
@ -187,6 +190,7 @@ export default class {
this.sendOperationToNostr(userInvoice.linkedApplication, userInvoice.user.user_id, op)
this.createZapReceipt(log, userInvoice)
log("paid invoice processed successfully")
this.liquidityManager.afterInInvoicePaid()
} catch (err: any) {
log(ERROR, "cannot process paid invoice", err.message || "")
}

View file

@ -76,7 +76,10 @@ export const LoadTestSettingsFromEnv = (): TestSettings => {
lndCertPath: EnvMustBeNonEmptyString("LND_FOURTH_CERT_PATH"),
lndMacaroonPath: EnvMustBeNonEmptyString("LND_FOURTH_MACAROON_PATH")
},
liquidityProviderPub: ""
liquiditySettings: {
...settings.lndSettings.liquiditySettings,
liquidityProviderPub: "",
}
},
skipSanityCheck: true,
bitcoinCoreSettings: {

View file

@ -7,8 +7,8 @@ import * as Types from '../../proto/autogenerated/ts/types.js'
export const initBootstrappedInstance = async (T: TestBase) => {
const settings = LoadTestSettingsFromEnv()
settings.lndSettings.useOnlyLiquidityProvider = true
settings.lndSettings.liquidityProviderPub = T.app.publicKey
settings.lndSettings.liquiditySettings.useOnlyLiquidityProvider = true
settings.lndSettings.liquiditySettings.liquidityProviderPub = T.app.publicKey
settings.lndSettings.mainNode = settings.lndSettings.thirdNode
const initialized = await initMainHandler(getLogger({ component: "bootstrapped" }), settings)
if (!initialized) {