lsp
This commit is contained in:
parent
c60547b7bb
commit
3c6dc7c962
9 changed files with 293 additions and 20 deletions
|
|
@ -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%
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
52
src/services/lnd/liquidityManager.ts
Normal file
52
src/services/lnd/liquidityManager.ts
Normal 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 () => { }
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 || "")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue