diff --git a/env.example b/env.example index 42bc9330..acf74802 100644 --- a/env.example +++ b/env.example @@ -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% diff --git a/src/services/lnd/index.ts b/src/services/lnd/index.ts index 4bd0ea1a..5cc46931 100644 --- a/src/services/lnd/index.ts +++ b/src/services/lnd/index.ts @@ -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 } } diff --git a/src/services/lnd/liquidityManager.ts b/src/services/lnd/liquidityManager.ts new file mode 100644 index 00000000..781763a0 --- /dev/null +++ b/src/services/lnd/liquidityManager.ts @@ -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 () => { } +} \ No newline at end of file diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index 58fa0db2..54c3db66 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -81,7 +81,7 @@ export default class { } async ShouldUseLiquidityProvider(req: LiquidityRequest): Promise { - if (this.settings.useOnlyLiquidityProvider) { + if (this.settings.liquiditySettings.useOnlyLiquidityProvider) { return true } if (!this.liquidProvider.CanProviderHandle(req)) { diff --git a/src/services/lnd/lsp.ts b/src/services/lnd/lsp.ts index 87f6a99c..711d91d3 100644 --- a/src/services/lnd/lsp.ts +++ b/src/services/lnd/lsp.ts @@ -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 => { + 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 => { + 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 + } } \ No newline at end of file diff --git a/src/services/lnd/settings.ts b/src/services/lnd/settings.ts index f89062bc..e9ea5aa1 100644 --- a/src/services/lnd/settings.ts +++ b/src/services/lnd/settings.ts @@ -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 diff --git a/src/services/main/index.ts b/src/services/main/index.ts index f530bdef..80c9c0cf 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -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 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 || "") } diff --git a/src/services/main/settings.ts b/src/services/main/settings.ts index d692c54f..a4337274 100644 --- a/src/services/main/settings.ts +++ b/src/services/main/settings.ts @@ -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: { diff --git a/src/tests/setupBootstrapped.ts b/src/tests/setupBootstrapped.ts index 9b1bfc5a..92e0fa70 100644 --- a/src/tests/setupBootstrapped.ts +++ b/src/tests/setupBootstrapped.ts @@ -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) {