diff --git a/src/services/lnd/index.ts b/src/services/lnd/index.ts index b60f12dd..4bd0ea1a 100644 --- a/src/services/lnd/index.ts +++ b/src/services/lnd/index.ts @@ -8,5 +8,5 @@ export const LoadLndSettingsFromEnv = (): LndSettings => { 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 } + return { mainNode: { lndAddr, lndCertPath, lndMacaroonPath }, feeRateLimit, feeFixedLimit, mockLnd, liquidityProviderPub, useOnlyLiquidityProvider: false } } diff --git a/src/services/lnd/liquidityProvider.ts b/src/services/lnd/liquidityProvider.ts index 9e34510e..18af9d68 100644 --- a/src/services/lnd/liquidityProvider.ts +++ b/src/services/lnd/liquidityProvider.ts @@ -5,7 +5,9 @@ import { decodeNprofile } from '../../custom-nip19.js' import { getLogger } from '../helpers/logger.js' import { NostrEvent, NostrSend } from '../nostr/handler.js' import { relayInit } from '../nostr/tools/relay.js' +import { InvoicePaidCb } from './settings.js' +export type LiquidityRequest = { action: 'spend' | 'receive', amount: number } export type nostrCallback = { startedAtMillis: number, type: 'single' | 'stream', f: (res: T) => void } export class LiquidityProvider { @@ -17,13 +19,15 @@ export class LiquidityProvider { nostrSend: NostrSend | null = null ready = false pubDestination: string - + latestMaxWithdrawable: number | null = null + invoicePaidCb: InvoicePaidCb // make the sub process accept client - constructor(pubDestination: string) { + constructor(pubDestination: string, invoicePaidCb: InvoicePaidCb) { if (!pubDestination) { this.log("No pub provider to liquidity provider, will not be initialized") } this.pubDestination = pubDestination + this.invoicePaidCb = invoicePaidCb this.client = newNostrClient({ pubDestination: this.pubDestination, retrieveNostrUserAuth: async () => this.myPub, @@ -32,20 +36,71 @@ export class LiquidityProvider { const interval = setInterval(() => { if (this.ready) { clearInterval(interval) - this.CheckUSerState() + this.Connect() } }, 1000) } - CheckUSerState = async () => { + Connect = async () => { await new Promise(res => setTimeout(res, 2000)) this.log("ready") + this.CheckUSerState() + if (this.latestMaxWithdrawable === null) { + return + } + this.client.GetLiveUserOperations(res => { + if (res.status === 'ERROR') { + this.log("error getting user operations", res.reason) + return + } + this.log("got user operation", res.operation) + if (res.operation.type === Types.UserOperationType.INCOMING_INVOICE) { + this.log("invoice was paid", res.operation.identifier) + this.invoicePaidCb(res.operation.identifier, res.operation.amount, false) + } + }) + } + + CheckUSerState = async () => { const res = await this.client.GetUserInfo() if (res.status === 'ERROR') { this.log("error getting user info", res) return } - this.log("got user info", res) + this.latestMaxWithdrawable = res.max_withdrawable + this.log("latest provider balance:", res.max_withdrawable) + } + + CanProviderHandle = (req: LiquidityRequest) => { + if (this.latestMaxWithdrawable === null) { + return false + } + if (req.action === 'spend') { + return this.latestMaxWithdrawable > req.amount + } + return true + } + + AddInvoice = async (amount: number, memo: string) => { + const res = await this.client.NewInvoice({ amountSats: amount, memo }) + if (res.status === 'ERROR') { + this.log("error creating invoice", res.reason) + throw new Error(res.reason) + } + this.log("new invoice", res.invoice) + this.CheckUSerState() + return res.invoice + } + + PayInvoice = async (invoice: string) => { + const res = await this.client.PayInvoice({ invoice, amount: 0 }) + if (res.status === 'ERROR') { + this.log("error paying invoice", res.reason) + throw new Error(res.reason) + } + this.log("paid invoice", res) + this.CheckUSerState() + return res } setNostrInfo = ({ clientId, myPub }: { myPub: string, clientId: string }) => { @@ -68,9 +123,7 @@ export class LiquidityProvider { } } - IsReady = () => { - return this.ready - } + onEvent = async (res: { requestId: string }, fromPub: string) => { if (fromPub !== this.pubDestination) { diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index 2caa70ed..31ef23a9 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -16,7 +16,7 @@ import { SendCoinsReq } from './sendCoinsReq.js'; import { LndSettings, AddressPaidCb, InvoicePaidCb, NodeInfo, Invoice, DecodedInvoice, PaidInvoice, NewBlockCb, HtlcCb, BalanceInfo } from './settings.js'; import { getLogger } from '../helpers/logger.js'; import { HtlcEvent_EventType } from '../../../proto/lnd/router.js'; -import { LiquidityProvider } from './liquidityProvider.js'; +import { LiquidityProvider, LiquidityRequest } from './liquidityProvider.js'; const DeadLineMetadata = (deadline = 10 * 1000) => ({ deadline: Date.now() + deadline }) const deadLndRetrySeconds = 5 export default class { @@ -36,7 +36,6 @@ export default class { log = getLogger({ component: 'lndManager' }) outgoingOpsLocked = false liquidProvider: LiquidityProvider - useLiquidityProvider = false constructor(settings: LndSettings, liquidProvider: LiquidityProvider, addressPaidCb: AddressPaidCb, invoicePaidCb: InvoicePaidCb, newBlockCb: NewBlockCb, htlcCb: HtlcCb) { this.settings = settings this.addressPaidCb = addressPaidCb @@ -64,7 +63,6 @@ export default class { this.router = new RouterClient(transport) this.chainNotifier = new ChainNotifierClient(transport) this.liquidProvider = liquidProvider - this.useLiquidityProvider = !!settings.liquidityProviderPub } LockOutgoingOperations(): void { @@ -80,6 +78,21 @@ export default class { Stop() { this.abortController.abort() } + + async ShouldUseLiquidityProvider(req: LiquidityRequest): Promise { + if (this.settings.useOnlyLiquidityProvider) { + return true + } + if (!this.liquidProvider.CanProviderHandle(req)) { + return false + } + const channels = await this.ListChannels() + if (channels.channels.length === 0) { + this.log("no channels, will use liquidity provider") + return true + } + return false + } async Warmup() { this.SubscribeAddressPaid() this.SubscribeInvoicePaid() @@ -256,6 +269,11 @@ export default class { async NewInvoice(value: number, memo: string, expiry: number): Promise { this.log("generating new invoice for", value, "sats") await this.Health() + const shouldUseLiquidityProvider = await this.ShouldUseLiquidityProvider({ action: 'receive', amount: value }) + if (shouldUseLiquidityProvider) { + const invoice = await this.liquidProvider.AddInvoice(value, memo) + return { payRequest: invoice } + } const res = await this.lightning.addInvoice(AddInvoiceReq(value, expiry, false, memo), DeadLineMetadata()) this.log("new invoice", res.response.paymentRequest) return { payRequest: res.response.paymentRequest } @@ -286,6 +304,11 @@ export default class { } await this.Health() this.log("paying invoice", invoice, "for", amount, "sats") + const shouldUseLiquidityProvider = await this.ShouldUseLiquidityProvider({ action: 'spend', amount }) + if (shouldUseLiquidityProvider) { + const res = await this.liquidProvider.PayInvoice(invoice) + return { feeSat: res.network_fee + res.service_fee, valueSat: res.amount_paid, paymentPreimage: res.preimage } + } const abortController = new AbortController() const req = PayInvoiceReq(invoice, amount, feeLimit) const stream = this.router.sendPaymentV2(req, { abort: abortController.signal }) diff --git a/src/services/lnd/settings.ts b/src/services/lnd/settings.ts index c240e200..f89062bc 100644 --- a/src/services/lnd/settings.ts +++ b/src/services/lnd/settings.ts @@ -10,6 +10,7 @@ export type LndSettings = { feeFixedLimit: number mockLnd: boolean liquidityProviderPub: string + useOnlyLiquidityProvider: boolean otherNode?: NodeSettings thirdNode?: NodeSettings diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 89a9b7f8..f530bdef 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -38,11 +38,10 @@ export default class { metricsManager: MetricsManager liquidProvider: LiquidityProvider nostrSend: NostrSend = () => { getLogger({})("nostr send not initialized yet") } - constructor(settings: MainSettings, storage: Storage, liquidProvider: LiquidityProvider) { + constructor(settings: MainSettings, storage: Storage) { this.settings = settings this.storage = storage - this.liquidProvider = liquidProvider - + this.liquidProvider = new LiquidityProvider(settings.lndSettings.liquidityProviderPub, this.invoicePaidCb) this.lnd = new LND(settings.lndSettings, this.liquidProvider, this.addressPaidCb, this.invoicePaidCb, this.newBlockCb, this.htlcCb) this.metricsManager = new MetricsManager(this.storage, this.lnd) diff --git a/src/services/main/init.ts b/src/services/main/init.ts index fb21b170..11875a40 100644 --- a/src/services/main/init.ts +++ b/src/services/main/init.ts @@ -17,8 +17,8 @@ export const initMainHandler = async (log: PubLogger, mainSettings: MainSettings if (manualMigration) { return } - const liquidityProvider = new LiquidityProvider(mainSettings.lndSettings.liquidityProviderPub) - const mainHandler = new Main(mainSettings, storageManager, liquidityProvider) + + const mainHandler = new Main(mainSettings, storageManager) await mainHandler.lnd.Warmup() if (!mainSettings.skipSanityCheck) { const sanityChecker = new SanityChecker(storageManager, mainHandler.lnd) @@ -49,7 +49,7 @@ export const initMainHandler = async (log: PubLogger, mainSettings: MainSettings publicKey: liquidityProviderApp.publicKey, name: "liquidity_provider", clientId: `client_${liquidityProviderApp.appId}` } - liquidityProvider.setNostrInfo({ clientId: liquidityProviderInfo.clientId, myPub: liquidityProviderInfo.publicKey }) + mainHandler.liquidProvider.setNostrInfo({ clientId: liquidityProviderInfo.clientId, myPub: liquidityProviderInfo.publicKey }) const stop = await processArgs(mainHandler) if (stop) { return diff --git a/src/tests/liquidityProvider.spec.ts b/src/tests/liquidityProvider.spec.ts new file mode 100644 index 00000000..af20eb4c --- /dev/null +++ b/src/tests/liquidityProvider.spec.ts @@ -0,0 +1,31 @@ +import { initMainHandler } from '../services/main/init.js' +import { LoadTestSettingsFromEnv } from '../services/main/settings.js' +import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js' +import { runSanityCheck, safelySetUserBalance, TestBase } from './testBase.js' +export const ignore = false +export const dev = true + + + +export default async (T: TestBase) => { + await safelySetUserBalance(T, T.user1, 2000) + const bootstrapped = await initBootstrappedInstance(T) + bootstrapped.appUserManager.NewInvoice({ app_id: T.user1.appId, user_id: T.user1.userId, app_user_id: T.user1.appUserIdentifier }, { amountSats: 2000, memo: "liquidityTest" }) + await runSanityCheck(T) +} + +const initBootstrappedInstance = async (T: TestBase) => { + const settings = LoadTestSettingsFromEnv() + settings.lndSettings.useOnlyLiquidityProvider = true + const initialized = await initMainHandler(console.log, settings) + if (!initialized) { + throw new Error("failed to initialize bootstrapped main handler") + } + const { mainHandler: bootstrapped, liquidityProviderInfo } = initialized + + bootstrapped.liquidProvider.attachNostrSend((identifier, data, r) => { + console.log(identifier, data) + }) + bootstrapped.liquidProvider.setNostrInfo({ clientId: liquidityProviderInfo.clientId, myPub: liquidityProviderInfo.publicKey }) + return bootstrapped +} \ No newline at end of file