Merge branch 'master' into umbrel-works

This commit is contained in:
Justin (shocknet) 2024-06-30 14:52:54 -04:00
commit 8d5a9069ae
24 changed files with 12438 additions and 11898 deletions

View file

@ -1,89 +1,96 @@
# Example configuration for Lightning.Pub
# Copy this file as .env in the Pub folder and uncomment the desired settings to override defaults
# Alternatively, these settings can be passed as environment variables at startup
#LND_CONNECTION
# Defaults typical for straight Linux
# Containers, Mac and Windows may need more detailed paths
#LND_ADDRESS=127.0.0.1:10009
#LND_CERT_PATH=~/.lnd/tls.cert
#LND_MACAROON_PATH=~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon
LIQUIDITY_PROVIDER_PUB=
#DB
#DATABASE_FILE=db.sqlite
#METRICS_DATABASE_FILE=metrics.sqlite
#LOGS_DIR=logs
#LOCALHOST
#ADMIN_TOKEN=
#PORT=1776
#JWT_SECRET=
#LIGHTNING
# Maximum amount in network fees passed to LND when it pays an external invoice
# BPS are basis points, 100 BPS = 1%
#OUTBOUND_MAX_FEE_BPS=60
#OUTBOUND_MAX_FEE_EXTRA_SATS=100
# 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
#BOOTSTRAP=1
#ROOT_FEES
# Applied to either debits or credits and sent to an admin account
# BPS are basis points, 100 BPS = 1%
#INCOMING_CHAIN_FEE_ROOT_BPS=0
#INCOMING_INVOICE_FEE_ROOT_BPS=0
# Chain spends are currently unstable and thus disabled, do not use until further notice
#OUTGOING_CHAIN_FEE_ROOT_BPS=60
# Outgoing Invoice Fee must be >= Lightning Outbound Max Fee so admins don't incur losses on spends
#OUTGOING_INVOICE_FEE_ROOT_BPS=60
# Internal user fees bugged, do not use until further notice
#TX_FEE_INTERNAL_ROOT_BPS=0 #applied to inter-application txns
#APP_FEES
# An extra fee applied at the app level and sent to the application owner
#INCOMING_INVOICE_FEE_USER_BPS=0
#OUTGOING_INVOICE_FEE_USER_BPS=0
#TX_FEE_INTERNAL_USER_BPS=0
#NOSTR
# Default relay may become rate-limited without a paid subscription
#NOSTR_RELAYS=wss://strfry.shock.network
#LNURL
# Optional
# If undefined, LNURLs (including Lightning Address) will be disabled
# To enable, add a reachable https endpoint for requests (or purchase a subscription)
# You also need an SSL reverse proxy from the domain to this local host
# Read more at https://docs.shock.network
#SERVICE_URL=https://yourdomainhere.xyz
#SUBSCRIPTION_SERVICES
# Opt-in to cloud relays for LNURL and Nostr
# A small monthly fee supports the developers
# Read more at https://docs.shock.network
#SUBSCRIBER=1
#DEV_OPTS
#MOCK_LND=false
#ALLOW_BALANCE_MIGRATION=false
#MIGRATE_DB=false
#LOG_LEVEL=DEBUG
#METRICS
#RECORD_PERFORMANCE=true
#SKIP_SANITY_CHECK=false
# A read-only token that can be used with dashboard to view reports
#METRICS_TOKEN=
# Disable outbound payments aka honeypot mode
#DISABLE_EXTERNAL_PAYMENTS=false
#WATCHDOG SECURITY
# A last line of defense against 0-day drainage attacks
# This will monitor LND separately and terminate sends if a balance discrepency is detected
# This setting defaults to 0 meaning no discrepency will be tolerated
# Increase this values to add a spending buffer for non-Pub services sharing LND
# Max difference between users balance and LND balance at Pub startup
#WATCHDOG_MAX_DIFF_SATS=0
# Example configuration for Lightning.Pub
# Copy this file as .env in the Pub folder and uncomment the desired settings to override defaults
# Alternatively, these settings can be passed as environment variables at startup
#LND_CONNECTION
# Defaults typical for straight Linux
# Containers, Mac and Windows may need more detailed paths
#LND_ADDRESS=127.0.0.1:10009
#LND_CERT_PATH=~/.lnd/tls.cert
#LND_MACAROON_PATH=~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon
LIQUIDITY_PROVIDER_PUB=
#DB
#DATABASE_FILE=db.sqlite
#METRICS_DATABASE_FILE=metrics.sqlite
#LOGS_DIR=logs
#LOCALHOST
#ADMIN_TOKEN=
#PORT=1776
#JWT_SECRET=
#LIGHTNING
# Maximum amount in network fees passed to LND when it pays an external invoice
# BPS are basis points, 100 BPS = 1%
#OUTBOUND_MAX_FEE_BPS=60
#OUTBOUND_MAX_FEE_EXTRA_SATS=100
# 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
#BOOTSTRAP=1
#LSP
OLYMPUS_LSP_URL=https://lsps1.lnolymp.us/api/v1
VOLTAGE_LSP_URL=https://lsp.voltageapi.com/api/v1
FLASHSATS_LSP_URL=https://lsp.flashsats.xyz/lsp/channel
LSP_CHANNEL_THRESHOLD=1000000
LSP_MAX_FEE_BPS=100
#ROOT_FEES
# Applied to either debits or credits and sent to an admin account
# BPS are basis points, 100 BPS = 1%
#INCOMING_CHAIN_FEE_ROOT_BPS=0
#INCOMING_INVOICE_FEE_ROOT_BPS=0
# Chain spends are currently unstable and thus disabled, do not use until further notice
#OUTGOING_CHAIN_FEE_ROOT_BPS=60
# Outgoing Invoice Fee must be >= Lightning Outbound Max Fee so admins don't incur losses on spends
#OUTGOING_INVOICE_FEE_ROOT_BPS=60
# Internal user fees bugged, do not use until further notice
#TX_FEE_INTERNAL_ROOT_BPS=0 #applied to inter-application txns
#APP_FEES
# An extra fee applied at the app level and sent to the application owner
#INCOMING_INVOICE_FEE_USER_BPS=0
#OUTGOING_INVOICE_FEE_USER_BPS=0
#TX_FEE_INTERNAL_USER_BPS=0
#NOSTR
# Default relay may become rate-limited without a paid subscription
#NOSTR_RELAYS=wss://strfry.shock.network
#LNURL
# Optional
# If undefined, LNURLs (including Lightning Address) will be disabled
# To enable, add a reachable https endpoint for requests (or purchase a subscription)
# You also need an SSL reverse proxy from the domain to this local host
# Read more at https://docs.shock.network
#SERVICE_URL=https://yourdomainhere.xyz
#SUBSCRIPTION_SERVICES
# Opt-in to cloud relays for LNURL and Nostr
# A small monthly fee supports the developers
# Read more at https://docs.shock.network
#SUBSCRIBER=1
#DEV_OPTS
#MOCK_LND=false
#ALLOW_BALANCE_MIGRATION=false
#MIGRATE_DB=false
#LOG_LEVEL=DEBUG
#METRICS
#RECORD_PERFORMANCE=true
#SKIP_SANITY_CHECK=false
# A read-only token that can be used with dashboard to view reports
#METRICS_TOKEN=
# Disable outbound payments aka honeypot mode
#DISABLE_EXTERNAL_PAYMENTS=false
#WATCHDOG SECURITY
# A last line of defense against 0-day drainage attacks
# This will monitor LND separately and terminate sends if a balance discrepency is detected
# This setting defaults to 0 meaning no discrepency will be tolerated
# Increase this values to add a spending buffer for non-Pub services sharing LND
# Max difference between users balance and LND balance at Pub startup
#WATCHDOG_MAX_DIFF_SATS=0

View file

@ -1,10 +1,10 @@
import { DataSource } from "typeorm"
import { ChannelRouting } from "./build/src/services/storage/entity/ChannelRouting.js"
export default new DataSource({
type: "sqlite",
database: "metrics.sqlite",
entities: [ChannelRouting],
import { DataSource } from "typeorm"
import { LspOrder } from "./build/src/services/storage/entity/LspOrder.js"
export default new DataSource({
type: "sqlite",
database: "db.sqlite",
entities: [LspOrder],
});

18636
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,5 @@ 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 }
return { mainNode: { lndAddr, lndCertPath, lndMacaroonPath }, feeRateLimit, feeFixedLimit, mockLnd }
}

View file

@ -1,226 +1,252 @@
import newNostrClient from '../../../proto/autogenerated/ts/nostr_client.js'
import { NostrRequest } from '../../../proto/autogenerated/ts/nostr_transport.js'
import * as Types from '../../../proto/autogenerated/ts/types.js'
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<T> = { startedAtMillis: number, type: 'single' | 'stream', f: (res: T) => void }
export class LiquidityProvider {
client: ReturnType<typeof newNostrClient>
clientCbs: Record<string, nostrCallback<any>> = {}
clientId: string = ""
myPub: string = ""
log = getLogger({ component: 'liquidityProvider' })
nostrSend: NostrSend | null = null
ready = false
pubDestination: string
latestMaxWithdrawable: number | null = null
invoicePaidCb: InvoicePaidCb
connecting = false
readyInterval: NodeJS.Timeout
// make the sub process accept client
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,
}, this.clientSend, this.clientSub)
this.readyInterval = setInterval(() => {
if (this.ready) {
clearInterval(this.readyInterval)
this.Connect()
}
}, 1000)
}
Stop = () => {
clearInterval(this.readyInterval)
}
Connect = async () => {
await new Promise(res => setTimeout(res, 2000))
this.log("ready")
await this.CheckUserState()
if (this.latestMaxWithdrawable === null) {
return
}
this.log("subbing to user operations")
this.client.GetLiveUserOperations(res => {
console.log("got user operation", 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.latestMaxWithdrawable = res.max_withdrawable
this.log("latest provider balance:", res.max_withdrawable)
return res
}
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 }) => {
this.clientId = clientId
this.myPub = myPub
this.setSetIfReady()
}
attachNostrSend(f: NostrSend) {
this.nostrSend = f
this.setSetIfReady()
}
setSetIfReady = () => {
if (this.nostrSend && !!this.pubDestination && !!this.clientId && !!this.myPub) {
this.ready = true
this.log("ready to send to ", this.pubDestination)
}
}
onEvent = async (res: { requestId: string }, fromPub: string) => {
if (fromPub !== this.pubDestination) {
this.log("got event from invalid pub", fromPub, this.pubDestination)
return false
}
if (this.clientCbs[res.requestId]) {
const cb = this.clientCbs[res.requestId]
cb.f(res)
if (cb.type === 'single') {
delete this.clientCbs[res.requestId]
this.log(this.getSingleSubs(), "single subs left")
}
return true
}
return false
}
clientSend = (to: string, message: NostrRequest): Promise<any> => {
if (!this.ready || !this.nostrSend) {
throw new Error("liquidity provider not initialized")
}
if (!message.requestId) {
message.requestId = makeId(16)
}
const reqId = message.requestId
if (this.clientCbs[reqId]) {
throw new Error("request was already sent")
}
this.nostrSend({ type: 'client', clientId: this.clientId }, {
type: 'content',
pub: to,
content: JSON.stringify(message)
})
//this.nostrSend(this.relays, to, JSON.stringify(message), this.settings)
this.log("subbing to single send", reqId, message.rpcName || 'no rpc name')
return new Promise(res => {
this.clientCbs[reqId] = {
startedAtMillis: Date.now(),
type: 'single',
f: (response: any) => { res(response) },
}
})
}
clientSub = (to: string, message: NostrRequest, cb: (res: any) => void): void => {
if (!this.ready || !this.nostrSend) {
throw new Error("liquidity provider not initialized")
}
if (!message.requestId) {
message.requestId = message.rpcName
}
const reqId = message.requestId
if (!reqId) {
throw new Error("invalid sub")
}
if (this.clientCbs[reqId]) {
this.clientCbs[reqId] = {
startedAtMillis: Date.now(),
type: 'stream',
f: (response: any) => { cb(response) },
}
this.log("sub for", reqId, "was already registered, overriding")
return
}
this.nostrSend({ type: 'client', clientId: this.clientId }, {
type: 'content',
pub: to,
content: JSON.stringify(message)
})
this.log("subbing to stream", reqId)
this.clientCbs[reqId] = {
startedAtMillis: Date.now(),
type: 'stream',
f: (response: any) => { cb(response) }
}
}
getSingleSubs = () => {
return Object.entries(this.clientCbs).filter(([_, cb]) => cb.type === 'single')
}
}
export const makeId = (length: number) => {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
import newNostrClient from '../../../proto/autogenerated/ts/nostr_client.js'
import { NostrRequest } from '../../../proto/autogenerated/ts/nostr_transport.js'
import * as Types from '../../../proto/autogenerated/ts/types.js'
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<T> = { startedAtMillis: number, type: 'single' | 'stream', f: (res: T) => void }
export class LiquidityProvider {
client: ReturnType<typeof newNostrClient>
clientCbs: Record<string, nostrCallback<any>> = {}
clientId: string = ""
myPub: string = ""
log = getLogger({ component: 'liquidityProvider' })
nostrSend: NostrSend | null = null
ready = false
pubDestination: string
latestMaxWithdrawable: number | null = null
latestBalance: number | null = null
invoicePaidCb: InvoicePaidCb
connecting = false
readyInterval: NodeJS.Timeout
// make the sub process accept client
constructor(pubDestination: string, invoicePaidCb: InvoicePaidCb) {
if (!pubDestination) {
this.log("No pub provider to liquidity provider, will not be initialized")
return
}
this.log("connecting to liquidity provider", pubDestination)
this.pubDestination = pubDestination
this.invoicePaidCb = invoicePaidCb
this.client = newNostrClient({
pubDestination: this.pubDestination,
retrieveNostrUserAuth: async () => this.myPub,
}, this.clientSend, this.clientSub)
this.readyInterval = setInterval(() => {
if (this.ready) {
clearInterval(this.readyInterval)
this.Connect()
}
}, 1000)
}
Stop = () => {
clearInterval(this.readyInterval)
}
Connect = async () => {
await new Promise(res => setTimeout(res, 2000))
this.log("ready")
await this.CheckUserState()
if (this.latestMaxWithdrawable === null) {
return
}
this.log("subbing to user operations")
this.client.GetLiveUserOperations(res => {
console.log("got user operation", 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)
}
})
}
GetLatestMaxWithdrawable = async (fetch = false) => {
if (this.latestMaxWithdrawable === null) {
this.log("liquidity provider is not ready yet")
return 0
}
if (fetch) {
await this.CheckUserState()
}
return this.latestMaxWithdrawable || 0
}
GetLatestBalance = async (fetch = false) => {
if (this.latestMaxWithdrawable === null) {
this.log("liquidity provider is not ready yet")
return 0
}
if (fetch) {
await this.CheckUserState()
}
return this.latestBalance || 0
}
CheckUserState = async () => {
const res = await this.client.GetUserInfo()
if (res.status === 'ERROR') {
this.log("error getting user info", res)
return
}
this.latestMaxWithdrawable = res.max_withdrawable
this.latestBalance = res.balance
this.log("latest provider balance:", res.balance, "latest max withdrawable:", res.max_withdrawable)
return res
}
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 }) => {
this.clientId = clientId
this.myPub = myPub
this.setSetIfReady()
}
attachNostrSend(f: NostrSend) {
this.nostrSend = f
this.setSetIfReady()
}
setSetIfReady = () => {
if (this.nostrSend && !!this.pubDestination && !!this.clientId && !!this.myPub) {
this.ready = true
this.log("ready to send to ", this.pubDestination)
}
}
onEvent = async (res: { requestId: string }, fromPub: string) => {
if (fromPub !== this.pubDestination) {
this.log("got event from invalid pub", fromPub, this.pubDestination)
return false
}
if (this.clientCbs[res.requestId]) {
const cb = this.clientCbs[res.requestId]
cb.f(res)
if (cb.type === 'single') {
delete this.clientCbs[res.requestId]
this.log(this.getSingleSubs(), "single subs left")
}
return true
}
return false
}
clientSend = (to: string, message: NostrRequest): Promise<any> => {
if (!this.ready || !this.nostrSend) {
throw new Error("liquidity provider not initialized")
}
if (!message.requestId) {
message.requestId = makeId(16)
}
const reqId = message.requestId
if (this.clientCbs[reqId]) {
throw new Error("request was already sent")
}
this.nostrSend({ type: 'client', clientId: this.clientId }, {
type: 'content',
pub: to,
content: JSON.stringify(message)
})
//this.nostrSend(this.relays, to, JSON.stringify(message), this.settings)
this.log("subbing to single send", reqId, message.rpcName || 'no rpc name')
return new Promise(res => {
this.clientCbs[reqId] = {
startedAtMillis: Date.now(),
type: 'single',
f: (response: any) => { res(response) },
}
})
}
clientSub = (to: string, message: NostrRequest, cb: (res: any) => void): void => {
if (!this.ready || !this.nostrSend) {
throw new Error("liquidity provider not initialized")
}
if (!message.requestId) {
message.requestId = message.rpcName
}
const reqId = message.requestId
if (!reqId) {
throw new Error("invalid sub")
}
if (this.clientCbs[reqId]) {
this.clientCbs[reqId] = {
startedAtMillis: Date.now(),
type: 'stream',
f: (response: any) => { cb(response) },
}
this.log("sub for", reqId, "was already registered, overriding")
return
}
this.nostrSend({ type: 'client', clientId: this.clientId }, {
type: 'content',
pub: to,
content: JSON.stringify(message)
})
this.log("subbing to stream", reqId)
this.clientCbs[reqId] = {
startedAtMillis: Date.now(),
type: 'stream',
f: (response: any) => { cb(response) }
}
}
getSingleSubs = () => {
return Object.entries(this.clientCbs).filter(([_, cb]) => cb.type === 'single')
}
}
export const makeId = (length: number) => {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}

View file

@ -1,437 +1,439 @@
//const grpc = require('@grpc/grpc-js');
import crypto from 'crypto'
import { credentials, Metadata } from '@grpc/grpc-js'
import { GrpcTransport } from "@protobuf-ts/grpc-transport";
import fs from 'fs'
import * as Types from '../../../proto/autogenerated/ts/types.js'
import { LightningClient } from '../../../proto/lnd/lightning.client.js'
import { InvoicesClient } from '../../../proto/lnd/invoices.client.js'
import { RouterClient } from '../../../proto/lnd/router.client.js'
import { ChainNotifierClient } from '../../../proto/lnd/chainnotifier.client.js'
import { GetInfoResponse, AddressType, NewAddressResponse, AddInvoiceResponse, Invoice_InvoiceState, PayReq, Payment_PaymentStatus, Payment, PaymentFailureReason, SendCoinsResponse, EstimateFeeResponse, ChannelBalanceResponse, TransactionDetails, ListChannelsResponse, ClosedChannelsResponse, PendingChannelsResponse, ForwardingHistoryResponse } from '../../../proto/lnd/lightning.js'
import { OpenChannelReq } from './openChannelReq.js';
import { AddInvoiceReq } from './addInvoiceReq.js';
import { PayInvoiceReq } from './payInvoiceReq.js';
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, LiquidityRequest } from './liquidityProvider.js';
const DeadLineMetadata = (deadline = 10 * 1000) => ({ deadline: Date.now() + deadline })
const deadLndRetrySeconds = 5
export default class {
lightning: LightningClient
invoices: InvoicesClient
router: RouterClient
chainNotifier: ChainNotifierClient
settings: LndSettings
ready = false
latestKnownBlockHeigh = 0
latestKnownSettleIndex = 0
abortController = new AbortController()
addressPaidCb: AddressPaidCb
invoicePaidCb: InvoicePaidCb
newBlockCb: NewBlockCb
htlcCb: HtlcCb
log = getLogger({ component: 'lndManager' })
outgoingOpsLocked = false
liquidProvider: LiquidityProvider
constructor(settings: LndSettings, liquidProvider: LiquidityProvider, addressPaidCb: AddressPaidCb, invoicePaidCb: InvoicePaidCb, newBlockCb: NewBlockCb, htlcCb: HtlcCb) {
this.settings = settings
this.addressPaidCb = addressPaidCb
this.invoicePaidCb = invoicePaidCb
this.newBlockCb = newBlockCb
this.htlcCb = htlcCb
const { lndAddr, lndCertPath, lndMacaroonPath } = this.settings.mainNode
const lndCert = fs.readFileSync(lndCertPath);
const macaroon = fs.readFileSync(lndMacaroonPath).toString('hex');
const sslCreds = credentials.createSsl(lndCert);
const macaroonCreds = credentials.createFromMetadataGenerator(
function (args: any, callback: any) {
let metadata = new Metadata();
metadata.add('macaroon', macaroon);
callback(null, metadata);
},
);
const creds = credentials.combineChannelCredentials(
sslCreds,
macaroonCreds,
);
const transport = new GrpcTransport({ host: lndAddr, channelCredentials: creds })
this.lightning = new LightningClient(transport)
this.invoices = new InvoicesClient(transport)
this.router = new RouterClient(transport)
this.chainNotifier = new ChainNotifierClient(transport)
this.liquidProvider = liquidProvider
}
LockOutgoingOperations(): void {
this.outgoingOpsLocked = true
}
UnlockOutgoingOperations(): void {
this.outgoingOpsLocked = false
}
SetMockInvoiceAsPaid(invoice: string, amount: number): Promise<void> {
throw new Error("SetMockInvoiceAsPaid only available in mock mode")
}
Stop() {
this.abortController.abort()
this.liquidProvider.Stop()
}
async ShouldUseLiquidityProvider(req: LiquidityRequest): Promise<boolean> {
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()
this.SubscribeNewBlock()
this.SubscribeHtlcEvents()
const now = Date.now()
return new Promise<void>((res, rej) => {
const interval = setInterval(async () => {
try {
await this.GetInfo()
clearInterval(interval)
this.ready = true
res()
} catch (err) {
this.log("LND is not ready yet, will try again in 1 second")
if (Date.now() - now > 1000 * 60) {
rej(new Error("LND not ready after 1 minute"))
}
}
}, 1000)
})
}
async GetInfo(): Promise<NodeInfo> {
const res = await this.lightning.getInfo({}, DeadLineMetadata())
return res.response
}
async ListPendingChannels(): Promise<PendingChannelsResponse> {
const res = await this.lightning.pendingChannels({}, DeadLineMetadata())
return res.response
}
async ListChannels(): Promise<ListChannelsResponse> {
const res = await this.lightning.listChannels({
activeOnly: false, inactiveOnly: false, privateOnly: false, publicOnly: false, peer: Buffer.alloc(0)
}, DeadLineMetadata())
return res.response
}
async ListClosedChannels(): Promise<ClosedChannelsResponse> {
const res = await this.lightning.closedChannels({
abandoned: true,
breach: true,
cooperative: true,
fundingCanceled: true,
localForce: true,
remoteForce: true
}, DeadLineMetadata())
return res.response
}
async Health(): Promise<void> {
if (!this.ready) {
throw new Error("not ready")
}
const info = await this.GetInfo()
if (!info.syncedToChain || !info.syncedToGraph) {
throw new Error("not synced")
}
}
RestartStreams() {
if (!this.ready) {
return
}
this.log("LND is dead, will try to reconnect in", deadLndRetrySeconds, "seconds")
const interval = setInterval(async () => {
try {
await this.Health()
this.log("LND is back online")
clearInterval(interval)
this.Warmup()
} catch (err) {
this.log("LND still dead, will try again in", deadLndRetrySeconds, "seconds")
}
}, deadLndRetrySeconds * 1000)
}
async SubscribeHtlcEvents() {
const stream = this.router.subscribeHtlcEvents({}, { abort: this.abortController.signal })
stream.responses.onMessage(htlc => {
this.htlcCb(htlc)
})
stream.responses.onError(error => {
this.log("Error with subscribeHtlcEvents stream")
})
stream.responses.onComplete(() => {
this.log("subscribeHtlcEvents stream closed")
})
}
async SubscribeNewBlock() {
const { blockHeight } = await this.GetInfo()
const stream = this.chainNotifier.registerBlockEpochNtfn({ height: blockHeight, hash: Buffer.alloc(0) }, { abort: this.abortController.signal })
stream.responses.onMessage(block => {
this.newBlockCb(block.height)
})
stream.responses.onError(error => {
this.log("Error with onchain tx stream")
})
stream.responses.onComplete(() => {
this.log("onchain tx stream closed")
})
}
SubscribeAddressPaid(): void {
const stream = this.lightning.subscribeTransactions({
account: "",
endHeight: 0,
startHeight: this.latestKnownBlockHeigh,
}, { abort: this.abortController.signal })
stream.responses.onMessage(tx => {
if (tx.blockHeight > this.latestKnownBlockHeigh) {
this.latestKnownBlockHeigh = tx.blockHeight
}
if (tx.numConfirmations === 0) { // only process pending transactions, confirmed transaction are processed by the newBlock CB
tx.outputDetails.forEach(output => {
if (output.isOurAddress) {
this.log("received chan TX", Number(output.amount), "sats", "for", output.address)
this.addressPaidCb({ hash: tx.txHash, index: Number(output.outputIndex) }, output.address, Number(output.amount), false)
}
})
}
})
stream.responses.onError(error => {
this.log("Error with onchain tx stream")
})
stream.responses.onComplete(() => {
this.log("onchain tx stream closed")
})
}
SubscribeInvoicePaid(): void {
const stream = this.lightning.subscribeInvoices({
settleIndex: BigInt(this.latestKnownSettleIndex),
addIndex: 0n,
}, { abort: this.abortController.signal })
stream.responses.onMessage(invoice => {
if (invoice.state === Invoice_InvoiceState.SETTLED) {
this.log("An invoice was paid for", Number(invoice.amtPaidSat), "sats", invoice.paymentRequest)
this.latestKnownSettleIndex = Number(invoice.settleIndex)
this.invoicePaidCb(invoice.paymentRequest, Number(invoice.amtPaidSat), false)
}
})
stream.responses.onError(error => {
this.log("Error with invoice stream")
})
stream.responses.onComplete(() => {
this.log("invoice stream closed")
this.RestartStreams()
})
}
async NewAddress(addressType: Types.AddressType): Promise<NewAddressResponse> {
this.log("generating new address")
await this.Health()
let lndAddressType: AddressType
switch (addressType) {
case Types.AddressType.NESTED_PUBKEY_HASH:
lndAddressType = AddressType.NESTED_PUBKEY_HASH
break;
case Types.AddressType.WITNESS_PUBKEY_HASH:
lndAddressType = AddressType.WITNESS_PUBKEY_HASH
break;
case Types.AddressType.TAPROOT_PUBKEY:
lndAddressType = AddressType.TAPROOT_PUBKEY
break;
default:
throw new Error("unknown address type " + addressType)
}
const res = await this.lightning.newAddress({ account: "", type: lndAddressType }, DeadLineMetadata())
this.log("new address", res.response.address)
return res.response
}
async NewInvoice(value: number, memo: string, expiry: number): Promise<Invoice> {
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 }
}
async DecodeInvoice(paymentRequest: string): Promise<DecodedInvoice> {
const res = await this.lightning.decodePayReq({ payReq: paymentRequest }, DeadLineMetadata())
return { numSatoshis: Number(res.response.numSatoshis), paymentHash: res.response.paymentHash }
}
GetFeeLimitAmount(amount: number): number {
return Math.ceil(amount * this.settings.feeRateLimit + this.settings.feeFixedLimit);
}
GetMaxWithinLimit(amount: number): number {
return Math.max(0, Math.floor(amount * (1 - this.settings.feeRateLimit) - this.settings.feeFixedLimit))
}
async ChannelBalance(): Promise<{ local: number, remote: number }> {
const res = await this.lightning.channelBalance({})
const r = res.response
return { local: r.localBalance ? Number(r.localBalance.sat) : 0, remote: r.remoteBalance ? Number(r.remoteBalance.sat) : 0 }
}
async PayInvoice(invoice: string, amount: number, feeLimit: number): Promise<PaidInvoice> {
if (this.outgoingOpsLocked) {
this.log("outgoing ops locked, rejecting payment request")
throw new Error("lnd node is currently out of sync")
}
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 })
return new Promise((res, rej) => {
stream.responses.onError(error => {
this.log("invoice payment failed", error)
rej(error)
})
stream.responses.onMessage(payment => {
switch (payment.status) {
case Payment_PaymentStatus.FAILED:
console.log(payment)
this.log("invoice payment failed", payment.failureReason)
rej(PaymentFailureReason[payment.failureReason])
return
case Payment_PaymentStatus.SUCCEEDED:
this.log("invoice payment succeded", Number(payment.valueSat))
res({ feeSat: Math.ceil(Number(payment.feeMsat) / 1000), valueSat: Number(payment.valueSat), paymentPreimage: payment.paymentPreimage })
}
})
})
}
async EstimateChainFees(address: string, amount: number, targetConf: number): Promise<EstimateFeeResponse> {
await this.Health()
const res = await this.lightning.estimateFee({
addrToAmount: { [address]: BigInt(amount) },
minConfs: 1,
spendUnconfirmed: false,
targetConf: targetConf
})
return res.response
}
async PayAddress(address: string, amount: number, satPerVByte: number, label = ""): Promise<SendCoinsResponse> {
if (this.outgoingOpsLocked) {
this.log("outgoing ops locked, rejecting payment request")
throw new Error("lnd node is currently out of sync")
}
await this.Health()
this.log("sending chain TX for", amount, "sats", "to", address)
const res = await this.lightning.sendCoins(SendCoinsReq(address, amount, satPerVByte, label), DeadLineMetadata())
this.log("sent chain TX for", amount, "sats", "to", address)
return res.response
}
async GetTransactions(startHeight: number): Promise<TransactionDetails> {
await this.Health()
const res = await this.lightning.getTransactions({ startHeight, endHeight: 0, account: "" }, DeadLineMetadata())
return res.response
}
async GetChannelBalance() {
const res = await this.lightning.channelBalance({}, DeadLineMetadata())
return res.response
}
async GetWalletBalance() {
const res = await this.lightning.walletBalance({}, DeadLineMetadata())
return res.response
}
async GetBalance(): Promise<BalanceInfo> {
const wRes = await this.lightning.walletBalance({}, DeadLineMetadata())
const { confirmedBalance, unconfirmedBalance, totalBalance } = wRes.response
const { response } = await this.lightning.listChannels({
activeOnly: false, inactiveOnly: false, privateOnly: false, publicOnly: false, peer: Buffer.alloc(0)
}, DeadLineMetadata())
const channelsBalance = response.channels.map(c => ({
channelId: c.chanId,
localBalanceSats: Number(c.localBalance),
remoteBalanceSats: Number(c.remoteBalance),
htlcs: c.pendingHtlcs.map(htlc => ({ incoming: htlc.incoming, amount: Number(htlc.amount), index: Number(htlc.htlcIndex), fwIndex: Number(htlc.forwardingHtlcIndex) }))
}))
return { confirmedBalance: Number(confirmedBalance), unconfirmedBalance: Number(unconfirmedBalance), totalBalance: Number(totalBalance), channelsBalance }
}
async GetForwardingHistory(indexOffset: number, startTime = 0): Promise<ForwardingHistoryResponse> {
const { response } = await this.lightning.forwardingHistory({ indexOffset, numMaxEvents: 0, startTime: BigInt(startTime), endTime: 0n, peerAliasLookup: false }, DeadLineMetadata())
return response
}
async GetAllPaidInvoices(max: number) {
const res = await this.lightning.listInvoices({ indexOffset: 0n, numMaxInvoices: BigInt(max), pendingOnly: false, reversed: true }, DeadLineMetadata())
return res.response
}
async GetAllPayments(max: number) {
const res = await this.lightning.listPayments({ countTotalPayments: false, includeIncomplete: false, indexOffset: 0n, maxPayments: BigInt(max), reversed: true })
return res.response
}
async ConnectPeer(addr: { pubkey: string, host: string }) {
const res = await this.lightning.connectPeer({
addr,
perm: true,
timeout: 0n
}, DeadLineMetadata())
return res.response
}
async ListPeers() {
const res = await this.lightning.listPeers({ latestError: true }, DeadLineMetadata())
return res.response
}
async OpenChannel(destination: string, closeAddress: string, fundingAmount: number, pushSats: number) {
const abortController = new AbortController()
const req = OpenChannelReq(destination, closeAddress, fundingAmount, pushSats)
const stream = this.lightning.openChannel(req, { abort: abortController.signal })
return new Promise((res, rej) => {
stream.responses.onMessage(message => {
console.log("message", message)
switch (message.update.oneofKind) {
case 'chanPending':
res(Buffer.from(message.pendingChanId).toString('base64'))
break
}
})
stream.responses.onError(error => {
console.log("error", error)
rej(error)
})
})
}
}
//const grpc = require('@grpc/grpc-js');
import crypto from 'crypto'
import { credentials, Metadata } from '@grpc/grpc-js'
import { GrpcTransport } from "@protobuf-ts/grpc-transport";
import fs from 'fs'
import * as Types from '../../../proto/autogenerated/ts/types.js'
import { LightningClient } from '../../../proto/lnd/lightning.client.js'
import { InvoicesClient } from '../../../proto/lnd/invoices.client.js'
import { RouterClient } from '../../../proto/lnd/router.client.js'
import { ChainNotifierClient } from '../../../proto/lnd/chainnotifier.client.js'
import { GetInfoResponse, AddressType, NewAddressResponse, AddInvoiceResponse, Invoice_InvoiceState, PayReq, Payment_PaymentStatus, Payment, PaymentFailureReason, SendCoinsResponse, EstimateFeeResponse, ChannelBalanceResponse, TransactionDetails, ListChannelsResponse, ClosedChannelsResponse, PendingChannelsResponse, ForwardingHistoryResponse } from '../../../proto/lnd/lightning.js'
import { OpenChannelReq } from './openChannelReq.js';
import { AddInvoiceReq } from './addInvoiceReq.js';
import { PayInvoiceReq } from './payInvoiceReq.js';
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, LiquidityRequest } from './liquidityProvider.js';
const DeadLineMetadata = (deadline = 10 * 1000) => ({ deadline: Date.now() + deadline })
const deadLndRetrySeconds = 5
export default class {
lightning: LightningClient
invoices: InvoicesClient
router: RouterClient
chainNotifier: ChainNotifierClient
settings: LndSettings
ready = false
latestKnownBlockHeigh = 0
latestKnownSettleIndex = 0
abortController = new AbortController()
addressPaidCb: AddressPaidCb
invoicePaidCb: InvoicePaidCb
newBlockCb: NewBlockCb
htlcCb: HtlcCb
log = getLogger({ component: 'lndManager' })
outgoingOpsLocked = false
liquidProvider: LiquidityProvider
useOnlyLiquidityProvider = false
constructor(settings: LndSettings, provider: { liquidProvider: LiquidityProvider, useOnly?: boolean }, addressPaidCb: AddressPaidCb, invoicePaidCb: InvoicePaidCb, newBlockCb: NewBlockCb, htlcCb: HtlcCb) {
this.settings = settings
this.addressPaidCb = addressPaidCb
this.invoicePaidCb = invoicePaidCb
this.newBlockCb = newBlockCb
this.htlcCb = htlcCb
const { lndAddr, lndCertPath, lndMacaroonPath } = this.settings.mainNode
const lndCert = fs.readFileSync(lndCertPath);
const macaroon = fs.readFileSync(lndMacaroonPath).toString('hex');
const sslCreds = credentials.createSsl(lndCert);
const macaroonCreds = credentials.createFromMetadataGenerator(
function (args: any, callback: any) {
let metadata = new Metadata();
metadata.add('macaroon', macaroon);
callback(null, metadata);
},
);
const creds = credentials.combineChannelCredentials(
sslCreds,
macaroonCreds,
);
const transport = new GrpcTransport({ host: lndAddr, channelCredentials: creds })
this.lightning = new LightningClient(transport)
this.invoices = new InvoicesClient(transport)
this.router = new RouterClient(transport)
this.chainNotifier = new ChainNotifierClient(transport)
this.liquidProvider = provider.liquidProvider
this.useOnlyLiquidityProvider = !!provider.useOnly
}
LockOutgoingOperations(): void {
this.outgoingOpsLocked = true
}
UnlockOutgoingOperations(): void {
this.outgoingOpsLocked = false
}
SetMockInvoiceAsPaid(invoice: string, amount: number): Promise<void> {
throw new Error("SetMockInvoiceAsPaid only available in mock mode")
}
Stop() {
this.abortController.abort()
this.liquidProvider.Stop()
}
async ShouldUseLiquidityProvider(req: LiquidityRequest): Promise<boolean> {
if (this.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()
this.SubscribeNewBlock()
this.SubscribeHtlcEvents()
const now = Date.now()
return new Promise<void>((res, rej) => {
const interval = setInterval(async () => {
try {
await this.GetInfo()
clearInterval(interval)
this.ready = true
res()
} catch (err) {
this.log("LND is not ready yet, will try again in 1 second")
if (Date.now() - now > 1000 * 60) {
rej(new Error("LND not ready after 1 minute"))
}
}
}, 1000)
})
}
async GetInfo(): Promise<NodeInfo> {
const res = await this.lightning.getInfo({}, DeadLineMetadata())
return res.response
}
async ListPendingChannels(): Promise<PendingChannelsResponse> {
const res = await this.lightning.pendingChannels({}, DeadLineMetadata())
return res.response
}
async ListChannels(): Promise<ListChannelsResponse> {
const res = await this.lightning.listChannels({
activeOnly: false, inactiveOnly: false, privateOnly: false, publicOnly: false, peer: Buffer.alloc(0)
}, DeadLineMetadata())
return res.response
}
async ListClosedChannels(): Promise<ClosedChannelsResponse> {
const res = await this.lightning.closedChannels({
abandoned: true,
breach: true,
cooperative: true,
fundingCanceled: true,
localForce: true,
remoteForce: true
}, DeadLineMetadata())
return res.response
}
async Health(): Promise<void> {
if (!this.ready) {
throw new Error("not ready")
}
const info = await this.GetInfo()
if (!info.syncedToChain || !info.syncedToGraph) {
throw new Error("not synced")
}
}
RestartStreams() {
if (!this.ready) {
return
}
this.log("LND is dead, will try to reconnect in", deadLndRetrySeconds, "seconds")
const interval = setInterval(async () => {
try {
await this.Health()
this.log("LND is back online")
clearInterval(interval)
this.Warmup()
} catch (err) {
this.log("LND still dead, will try again in", deadLndRetrySeconds, "seconds")
}
}, deadLndRetrySeconds * 1000)
}
async SubscribeHtlcEvents() {
const stream = this.router.subscribeHtlcEvents({}, { abort: this.abortController.signal })
stream.responses.onMessage(htlc => {
this.htlcCb(htlc)
})
stream.responses.onError(error => {
this.log("Error with subscribeHtlcEvents stream")
})
stream.responses.onComplete(() => {
this.log("subscribeHtlcEvents stream closed")
})
}
async SubscribeNewBlock() {
const { blockHeight } = await this.GetInfo()
const stream = this.chainNotifier.registerBlockEpochNtfn({ height: blockHeight, hash: Buffer.alloc(0) }, { abort: this.abortController.signal })
stream.responses.onMessage(block => {
this.newBlockCb(block.height)
})
stream.responses.onError(error => {
this.log("Error with onchain tx stream")
})
stream.responses.onComplete(() => {
this.log("onchain tx stream closed")
})
}
SubscribeAddressPaid(): void {
const stream = this.lightning.subscribeTransactions({
account: "",
endHeight: 0,
startHeight: this.latestKnownBlockHeigh,
}, { abort: this.abortController.signal })
stream.responses.onMessage(tx => {
if (tx.blockHeight > this.latestKnownBlockHeigh) {
this.latestKnownBlockHeigh = tx.blockHeight
}
if (tx.numConfirmations === 0) { // only process pending transactions, confirmed transaction are processed by the newBlock CB
tx.outputDetails.forEach(output => {
if (output.isOurAddress) {
this.log("received chan TX", Number(output.amount), "sats", "for", output.address)
this.addressPaidCb({ hash: tx.txHash, index: Number(output.outputIndex) }, output.address, Number(output.amount), false)
}
})
}
})
stream.responses.onError(error => {
this.log("Error with onchain tx stream")
})
stream.responses.onComplete(() => {
this.log("onchain tx stream closed")
})
}
SubscribeInvoicePaid(): void {
const stream = this.lightning.subscribeInvoices({
settleIndex: BigInt(this.latestKnownSettleIndex),
addIndex: 0n,
}, { abort: this.abortController.signal })
stream.responses.onMessage(invoice => {
if (invoice.state === Invoice_InvoiceState.SETTLED) {
this.log("An invoice was paid for", Number(invoice.amtPaidSat), "sats", invoice.paymentRequest)
this.latestKnownSettleIndex = Number(invoice.settleIndex)
this.invoicePaidCb(invoice.paymentRequest, Number(invoice.amtPaidSat), false)
}
})
stream.responses.onError(error => {
this.log("Error with invoice stream")
})
stream.responses.onComplete(() => {
this.log("invoice stream closed")
this.RestartStreams()
})
}
async NewAddress(addressType: Types.AddressType): Promise<NewAddressResponse> {
this.log("generating new address")
await this.Health()
let lndAddressType: AddressType
switch (addressType) {
case Types.AddressType.NESTED_PUBKEY_HASH:
lndAddressType = AddressType.NESTED_PUBKEY_HASH
break;
case Types.AddressType.WITNESS_PUBKEY_HASH:
lndAddressType = AddressType.WITNESS_PUBKEY_HASH
break;
case Types.AddressType.TAPROOT_PUBKEY:
lndAddressType = AddressType.TAPROOT_PUBKEY
break;
default:
throw new Error("unknown address type " + addressType)
}
const res = await this.lightning.newAddress({ account: "", type: lndAddressType }, DeadLineMetadata())
this.log("new address", res.response.address)
return res.response
}
async NewInvoice(value: number, memo: string, expiry: number, useProvider = false): Promise<Invoice> {
this.log("generating new invoice for", value, "sats")
await this.Health()
const shouldUseLiquidityProvider = await this.ShouldUseLiquidityProvider({ action: 'receive', amount: value })
if (shouldUseLiquidityProvider || useProvider) {
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 }
}
async DecodeInvoice(paymentRequest: string): Promise<DecodedInvoice> {
const res = await this.lightning.decodePayReq({ payReq: paymentRequest }, DeadLineMetadata())
return { numSatoshis: Number(res.response.numSatoshis), paymentHash: res.response.paymentHash }
}
GetFeeLimitAmount(amount: number): number {
return Math.ceil(amount * this.settings.feeRateLimit + this.settings.feeFixedLimit);
}
GetMaxWithinLimit(amount: number): number {
return Math.max(0, Math.floor(amount * (1 - this.settings.feeRateLimit) - this.settings.feeFixedLimit))
}
async ChannelBalance(): Promise<{ local: number, remote: number }> {
const res = await this.lightning.channelBalance({})
const r = res.response
return { local: r.localBalance ? Number(r.localBalance.sat) : 0, remote: r.remoteBalance ? Number(r.remoteBalance.sat) : 0 }
}
async PayInvoice(invoice: string, amount: number, feeLimit: number, useProvider = false): Promise<PaidInvoice> {
if (this.outgoingOpsLocked) {
this.log("outgoing ops locked, rejecting payment request")
throw new Error("lnd node is currently out of sync")
}
await this.Health()
this.log("paying invoice", invoice, "for", amount, "sats")
const shouldUseLiquidityProvider = await this.ShouldUseLiquidityProvider({ action: 'spend', amount })
if (shouldUseLiquidityProvider || useProvider) {
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 })
return new Promise((res, rej) => {
stream.responses.onError(error => {
this.log("invoice payment failed", error)
rej(error)
})
stream.responses.onMessage(payment => {
switch (payment.status) {
case Payment_PaymentStatus.FAILED:
console.log(payment)
this.log("invoice payment failed", payment.failureReason)
rej(PaymentFailureReason[payment.failureReason])
return
case Payment_PaymentStatus.SUCCEEDED:
this.log("invoice payment succeded", Number(payment.valueSat))
res({ feeSat: Math.ceil(Number(payment.feeMsat) / 1000), valueSat: Number(payment.valueSat), paymentPreimage: payment.paymentPreimage })
}
})
})
}
async EstimateChainFees(address: string, amount: number, targetConf: number): Promise<EstimateFeeResponse> {
await this.Health()
const res = await this.lightning.estimateFee({
addrToAmount: { [address]: BigInt(amount) },
minConfs: 1,
spendUnconfirmed: false,
targetConf: targetConf
})
return res.response
}
async PayAddress(address: string, amount: number, satPerVByte: number, label = ""): Promise<SendCoinsResponse> {
if (this.outgoingOpsLocked) {
this.log("outgoing ops locked, rejecting payment request")
throw new Error("lnd node is currently out of sync")
}
await this.Health()
this.log("sending chain TX for", amount, "sats", "to", address)
const res = await this.lightning.sendCoins(SendCoinsReq(address, amount, satPerVByte, label), DeadLineMetadata())
this.log("sent chain TX for", amount, "sats", "to", address)
return res.response
}
async GetTransactions(startHeight: number): Promise<TransactionDetails> {
await this.Health()
const res = await this.lightning.getTransactions({ startHeight, endHeight: 0, account: "" }, DeadLineMetadata())
return res.response
}
async GetChannelBalance() {
const res = await this.lightning.channelBalance({}, DeadLineMetadata())
return res.response
}
async GetWalletBalance() {
const res = await this.lightning.walletBalance({}, DeadLineMetadata())
return res.response
}
async GetBalance(): Promise<BalanceInfo> {
const wRes = await this.lightning.walletBalance({}, DeadLineMetadata())
const { confirmedBalance, unconfirmedBalance, totalBalance } = wRes.response
const { response } = await this.lightning.listChannels({
activeOnly: false, inactiveOnly: false, privateOnly: false, publicOnly: false, peer: Buffer.alloc(0)
}, DeadLineMetadata())
const channelsBalance = response.channels.map(c => ({
channelId: c.chanId,
localBalanceSats: Number(c.localBalance),
remoteBalanceSats: Number(c.remoteBalance),
htlcs: c.pendingHtlcs.map(htlc => ({ incoming: htlc.incoming, amount: Number(htlc.amount), index: Number(htlc.htlcIndex), fwIndex: Number(htlc.forwardingHtlcIndex) }))
}))
return { confirmedBalance: Number(confirmedBalance), unconfirmedBalance: Number(unconfirmedBalance), totalBalance: Number(totalBalance), channelsBalance }
}
async GetForwardingHistory(indexOffset: number, startTime = 0): Promise<ForwardingHistoryResponse> {
const { response } = await this.lightning.forwardingHistory({ indexOffset, numMaxEvents: 0, startTime: BigInt(startTime), endTime: 0n, peerAliasLookup: false }, DeadLineMetadata())
return response
}
async GetAllPaidInvoices(max: number) {
const res = await this.lightning.listInvoices({ indexOffset: 0n, numMaxInvoices: BigInt(max), pendingOnly: false, reversed: true }, DeadLineMetadata())
return res.response
}
async GetAllPayments(max: number) {
const res = await this.lightning.listPayments({ countTotalPayments: false, includeIncomplete: false, indexOffset: 0n, maxPayments: BigInt(max), reversed: true })
return res.response
}
async ConnectPeer(addr: { pubkey: string, host: string }) {
const res = await this.lightning.connectPeer({
addr,
perm: true,
timeout: 0n
}, DeadLineMetadata())
return res.response
}
async ListPeers() {
const res = await this.lightning.listPeers({ latestError: true }, DeadLineMetadata())
return res.response
}
async OpenChannel(destination: string, closeAddress: string, fundingAmount: number, pushSats: number) {
const abortController = new AbortController()
const req = OpenChannelReq(destination, closeAddress, fundingAmount, pushSats)
const stream = this.lightning.openChannel(req, { abort: abortController.signal })
return new Promise((res, rej) => {
stream.responses.onMessage(message => {
console.log("message", message)
switch (message.update.oneofKind) {
case 'chanPending':
res(Buffer.from(message.pendingChanId).toString('base64'))
break
}
})
stream.responses.onError(error => {
console.log("error", error)
rej(error)
})
})
}
}

View file

@ -1,29 +1,331 @@
import fetch from "node-fetch"
export class LSP {
serviceUrl: string
constructor(serviceUrl: string) {
this.serviceUrl = serviceUrl
}
getInfo = async () => {
const res = await fetch(`${this.serviceUrl}/getinfo`)
const json = await res.json() as { options: {}, uris: string[] }
}
createOrder = async (req: { public_key: string }) => {
const res = await fetch(`${this.serviceUrl}/create_order`, {
method: "POST",
body: JSON.stringify(req),
headers: { "Content-Type": "application/json" }
})
const json = await res.json() as {}
return json
}
getOrder = async (orderId: string) => {
const res = await fetch(`${this.serviceUrl}/get_order&order_id=${orderId}`)
const json = await res.json() as {}
return json
}
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
flashsatsServiceUrl: string
channelThreshold: number
maxRelativeFee: number
}
export const LoadLSPSettingsFromEnv = (): LSPSettings => {
const olympusServiceUrl = process.env.OLYMPUS_LSP_URL || "https://lsps1.lnolymp.us/api/v1"
const voltageServiceUrl = process.env.VOLTAGE_LSP_URL || "https://lsp.voltageapi.com/api/v1"
const flashsatsServiceUrl = process.env.FLASHSATS_LSP_URL || "https://lsp.flashsats.xyz/lsp/channel"
const channelThreshold = EnvCanBeInteger("LSP_CHANNEL_THRESHOLD", 1000000)
const maxRelativeFee = EnvCanBeInteger("LSP_MAX_FEE_BPS", 100) / 10000
return { olympusServiceUrl, voltageServiceUrl, channelThreshold, maxRelativeFee, flashsatsServiceUrl }
}
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
}
type FlashsatsOrder = {
"node_connection_info": string,
"lsp_balance_sat": number,
"client_balance_sat": number,
"confirms_within_blocks": number,
"channel_expiry_blocks": number,
"announce_channel": boolean,
"token": string
}
type OrderResponse = {
orderId: string
invoice: string
totalSats: number
fees: number
}
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("balance of", userState?.max_withdrawable || 0, "is lower than channel threshold of", this.settings.channelThreshold)
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 FlashsatsLSP extends LSP {
constructor(settings: LSPSettings, lnd: LND, liquidityProvider: LiquidityProvider) {
super("FlashsatsLSP", settings, lnd, liquidityProvider)
}
openChannelIfReady = async (): Promise<OrderResponse | null> => {
const shouldOpen = await this.shouldOpenChannel()
if (!shouldOpen.shouldOpen) {
return null
}
if (!this.settings.flashsatsServiceUrl) {
this.log("no flashsats service url provided")
return null
}
const serviceInfo = await this.getInfo()
if (+serviceInfo.options.min_initial_client_balance_sat > shouldOpen.maxSpendable) {
this.log("balance of", shouldOpen.maxSpendable, "is lower than service minimum of", serviceInfo.options.min_initial_client_balance_sat)
return null
}
const lndInfo = await this.lnd.GetInfo()
const myUri = lndInfo.uris.length > 0 ? lndInfo.uris[0] : ""
if (!myUri) {
this.log("no uri found for this node,uri is required to use flashsats")
return null
}
const lspBalance = (this.settings.channelThreshold * 2).toString()
const chanExpiryBlocks = serviceInfo.options.max_channel_expiry_blocks
const order = await this.createOrder({ nodeUri: myUri, lspBalance, clientBalance: "0", chanExpiryBlocks })
if (order.payment.state !== 'EXPECT_PAYMENT') {
this.log("order not in expect payment state")
return null
}
const decoded = await this.lnd.DecodeInvoice(order.payment.bolt11_invoice)
if (decoded.numSatoshis !== +order.payment.order_total_sat) {
this.log("invoice of amount", decoded.numSatoshis, "does not match order total of", order.payment.order_total_sat)
return null
}
if (decoded.numSatoshis > shouldOpen.maxSpendable) {
this.log("invoice of amount", decoded.numSatoshis, "exceeds user balance of", shouldOpen.maxSpendable)
return null
}
const relativeFee = +order.payment.fee_total_sat / this.settings.channelThreshold
if (relativeFee > this.settings.maxRelativeFee) {
this.log("invoice relative fee of", relativeFee, "exceeds max relative fee of", this.settings.maxRelativeFee)
return null
}
const res = await this.liquidityProvider.PayInvoice(order.payment.bolt11_invoice)
this.log("paid", res.amount_paid, "to open channel")
return { orderId: order.order_id, invoice: order.payment.bolt11_invoice, totalSats: +order.payment.order_total_sat, fees: +order.payment.fee_total_sat }
}
getInfo = async () => {
const res = await fetch(`${this.settings.flashsatsServiceUrl}/info`)
const json = await res.json() as { options: { min_initial_client_balance_sat: string, max_channel_expiry_blocks: number } }
return json
}
createOrder = async (orderInfo: { nodeUri: string, lspBalance: string, clientBalance: string, chanExpiryBlocks: number }) => {
const req: FlashsatsOrder = {
node_connection_info: orderInfo.nodeUri,
announce_channel: true,
channel_expiry_blocks: orderInfo.chanExpiryBlocks,
client_balance_sat: +orderInfo.clientBalance,
lsp_balance_sat: +orderInfo.lspBalance,
confirms_within_blocks: 6,
token: "flashsats"
}
const res = await fetch(`${this.settings.flashsatsServiceUrl}/channel`, {
method: "POST",
body: JSON.stringify(req),
headers: { "Content-Type": "application/json" }
})
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
}
}
export class OlympusLSP extends LSP {
constructor(settings: LSPSettings, lnd: LND, liquidityProvider: LiquidityProvider) {
super("OlympusLSP", settings, lnd, liquidityProvider)
}
openChannelIfReady = async (): Promise<OrderResponse | null> => {
const shouldOpen = await this.shouldOpenChannel()
if (!shouldOpen.shouldOpen) {
return null
}
if (!this.settings.olympusServiceUrl) {
this.log("no olympus service url provided")
return null
}
const serviceInfo = await this.getInfo()
if (+serviceInfo.options.min_initial_client_balance_sat > shouldOpen.maxSpendable) {
this.log("balance of", shouldOpen.maxSpendable, "is lower than service minimum of", serviceInfo.options.min_initial_client_balance_sat)
return null
}
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 chanExpiryBlocks = serviceInfo.options.max_channel_expiry_blocks
const order = await this.createOrder({ pubKey: myPub, refundAddr: refundAddr.address, lspBalance, clientBalance: "0", chanExpiryBlocks })
if (order.payment.state !== 'EXPECT_PAYMENT') {
this.log("order not in expect payment state")
return null
}
const decoded = await this.lnd.DecodeInvoice(order.payment.bolt11_invoice)
if (decoded.numSatoshis !== +order.payment.order_total_sat) {
this.log("invoice of amount", decoded.numSatoshis, "does not match order total of", order.payment.order_total_sat)
return null
}
if (decoded.numSatoshis > shouldOpen.maxSpendable) {
this.log("invoice of amount", decoded.numSatoshis, "exceeds user balance of", shouldOpen.maxSpendable)
return null
}
const relativeFee = +order.payment.fee_total_sat / this.settings.channelThreshold
if (relativeFee > this.settings.maxRelativeFee) {
this.log("invoice relative fee of", relativeFee, "exceeds max relative fee of", this.settings.maxRelativeFee)
return null
}
const res = await this.liquidityProvider.PayInvoice(order.payment.bolt11_invoice)
this.log("paid", res.amount_paid, "to open channel")
return { orderId: order.order_id, invoice: order.payment.bolt11_invoice, totalSats: +order.payment.order_total_sat, fees: +order.payment.fee_total_sat }
}
getInfo = async () => {
const res = await fetch(`${this.settings.olympusServiceUrl}/get_info`)
const json = await res.json() as { options: { min_initial_client_balance_sat: string, max_channel_expiry_blocks: number }, uris: string[] }
return json
}
createOrder = async (orderInfo: { pubKey: string, refundAddr: string, lspBalance: string, clientBalance: string, chanExpiryBlocks: number }) => {
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: orderInfo.chanExpiryBlocks,
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 { 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.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: number, 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 resText = await res.text()
this.log("fee response", resText)
const json = JSON.parse(resText) as { fee_amount_msat: number, id: string }
return json
}
openChannelIfReady = async (): Promise<OrderResponse | null> => {
const shouldOpen = await this.shouldOpenChannel()
if (!shouldOpen.shouldOpen) {
return null
}
if (!this.settings.voltageServiceUrl) {
this.log("no voltage service url provided")
return null
}
const lndInfo = await this.lnd.GetInfo()
const myPub = lndInfo.identityPubkey
const amtMsats = this.settings.channelThreshold * 1000
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("relative fee of", relativeFee, "exceeds max relative fee of", this.settings.maxRelativeFee)
return null
}
const info = await this.getInfo()
const ipv4 = info.connection_methods.find(c => c.type === 'ipv4')
if (!ipv4) {
this.log("no ipv4 address found")
return null
}
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 of amount", decoded.numSatoshis, "does not match expected amount of", this.settings.channelThreshold + feeSats)
return null
}
const invoiceRes = await this.liquidityProvider.PayInvoice(res.jit_bolt11)
this.log("paid", invoiceRes.amount_paid, "to open channel")
return { orderId: fee.id, invoice: res.jit_bolt11, totalSats: decoded.numSatoshis, fees: feeSats }
}
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,60 +1,58 @@
import { HtlcEvent } from "../../../proto/lnd/router"
export type NodeSettings = {
lndAddr: string
lndCertPath: string
lndMacaroonPath: string
}
export type LndSettings = {
mainNode: NodeSettings
feeRateLimit: number
feeFixedLimit: number
mockLnd: boolean
liquidityProviderPub: string
useOnlyLiquidityProvider: boolean
otherNode?: NodeSettings
thirdNode?: NodeSettings
}
type TxOutput = {
hash: string
index: number
}
export type ChannelBalance = {
channelId: string;
localBalanceSats: number;
remoteBalanceSats: number;
htlcs: { incoming: boolean, amount: number }[]
}
export type BalanceInfo = {
confirmedBalance: number;
unconfirmedBalance: number;
totalBalance: number;
channelsBalance: ChannelBalance[];
}
export type AddressPaidCb = (txOutput: TxOutput, address: string, amount: number, internal: boolean) => void
export type InvoicePaidCb = (paymentRequest: string, amount: number, internal: boolean) => void
export type NewBlockCb = (height: number) => void
export type HtlcCb = (event: HtlcEvent) => void
export type NodeInfo = {
alias: string
syncedToChain: boolean
syncedToGraph: boolean
blockHeight: number
blockHash: string
identityPubkey: string
uris: string[]
}
export type Invoice = {
payRequest: string
}
export type DecodedInvoice = {
numSatoshis: number
paymentHash: string
}
export type PaidInvoice = {
feeSat: number
valueSat: number
paymentPreimage: string
import { HtlcEvent } from "../../../proto/lnd/router"
export type NodeSettings = {
lndAddr: string
lndCertPath: string
lndMacaroonPath: string
}
export type LndSettings = {
mainNode: NodeSettings
feeRateLimit: number
feeFixedLimit: number
mockLnd: boolean
otherNode?: NodeSettings
thirdNode?: NodeSettings
}
type TxOutput = {
hash: string
index: number
}
export type ChannelBalance = {
channelId: string;
localBalanceSats: number;
remoteBalanceSats: number;
htlcs: { incoming: boolean, amount: number }[]
}
export type BalanceInfo = {
confirmedBalance: number;
unconfirmedBalance: number;
totalBalance: number;
channelsBalance: ChannelBalance[];
}
export type AddressPaidCb = (txOutput: TxOutput, address: string, amount: number, internal: boolean) => void
export type InvoicePaidCb = (paymentRequest: string, amount: number, internal: boolean) => void
export type NewBlockCb = (height: number) => void
export type HtlcCb = (event: HtlcEvent) => void
export type NodeInfo = {
alias: string
syncedToChain: boolean
syncedToGraph: boolean
blockHeight: number
blockHash: string
identityPubkey: string
uris: string[]
}
export type Invoice = {
payRequest: string
}
export type DecodedInvoice = {
numSatoshis: number
paymentHash: string
}
export type PaidInvoice = {
feeSat: number
valueSat: number
paymentPreimage: string
}

View file

@ -1,253 +1,259 @@
import fetch from "node-fetch"
import Storage, { LoadStorageSettingsFromEnv } from '../storage/index.js'
import * as Types from '../../../proto/autogenerated/ts/types.js'
import ProductManager from './productManager.js'
import ApplicationManager from './applicationManager.js'
import PaymentManager, { PendingTx } from './paymentManager.js'
import { MainSettings } from './settings.js'
import LND from "../lnd/lnd.js"
import { AddressPaidCb, HtlcCb, InvoicePaidCb, NewBlockCb } from "../lnd/settings.js"
import { ERROR, getLogger, PubLogger } from "../helpers/logger.js"
import AppUserManager from "./appUserManager.js"
import { Application } from '../storage/entity/Application.js'
import { UserReceivingInvoice } from '../storage/entity/UserReceivingInvoice.js'
import { UnsignedEvent } from '../nostr/tools/event.js'
import { NostrSend } from '../nostr/handler.js'
import MetricsManager from '../metrics/index.js'
import { LoggedEvent } from '../storage/eventsLog.js'
import { LiquidityProvider } from "../lnd/liquidityProvider.js"
type UserOperationsSub = {
id: string
newIncomingInvoice: (operation: Types.UserOperation) => void
newOutgoingInvoice: (operation: Types.UserOperation) => void
newIncomingTx: (operation: Types.UserOperation) => void
newOutgoingTx: (operation: Types.UserOperation) => void
}
const appTag = "Lightning.Pub"
export default class {
storage: Storage
lnd: LND
settings: MainSettings
userOperationsSub: UserOperationsSub | null = null
productManager: ProductManager
applicationManager: ApplicationManager
appUserManager: AppUserManager
paymentManager: PaymentManager
paymentSubs: Record<string, ((op: Types.UserOperation) => void) | null> = {}
metricsManager: MetricsManager
liquidProvider: LiquidityProvider
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.lnd = new LND(settings.lndSettings, this.liquidProvider, this.addressPaidCb, this.invoicePaidCb, this.newBlockCb, this.htlcCb)
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()
this.paymentManager.Stop()
}
StartBeacons() {
this.applicationManager.StartAppsServiceBeacon(app => {
this.UpdateBeacon(app, { type: 'service', name: app.name })
})
}
attachNostrSend(f: NostrSend) {
this.nostrSend = f
this.liquidProvider.attachNostrSend(f)
}
htlcCb: HtlcCb = (e) => {
this.metricsManager.HtlcCb(e)
}
newBlockCb: NewBlockCb = (height) => {
this.NewBlockHandler(height)
}
NewBlockHandler = async (height: number) => {
let confirmed: (PendingTx & { confs: number; })[]
let log = getLogger({})
try {
const balanceEvents = await this.paymentManager.GetLndBalance()
await this.metricsManager.NewBlockCb(height, balanceEvents)
confirmed = await this.paymentManager.CheckNewlyConfirmedTxs(height)
} catch (err: any) {
log(ERROR, "failed to check transactions after new block", err.message || err)
return
}
await Promise.all(confirmed.map(async c => {
if (c.type === 'outgoing') {
await this.storage.paymentStorage.UpdateUserTransactionPayment(c.tx.serial_id, { confs: c.confs })
const { linkedApplication, user, address, paid_amount: amount, service_fees: serviceFee, serial_id: serialId, chain_fees } = c.tx;
const operationId = `${Types.UserOperationType.OUTGOING_TX}-${serialId}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: false, type: Types.UserOperationType.OUTGOING_TX, identifier: address, operationId, network_fee: chain_fees, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal }
this.sendOperationToNostr(linkedApplication!, user.user_id, op)
} else {
this.storage.StartTransaction(async tx => {
const { user_address: userAddress, paid_amount: amount, service_fee: serviceFee, serial_id: serialId, tx_hash } = c.tx
if (!userAddress.linkedApplication) {
log(ERROR, "an address was paid, that has no linked application")
return
}
const updateResult = await this.storage.paymentStorage.UpdateAddressReceivingTransaction(serialId, { confs: c.confs }, tx)
if (!updateResult.affected) {
throw new Error("unable to flag chain transaction as paid")
}
const addressData = `${userAddress.address}:${tx_hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, amount - serviceFee, addressData, tx)
if (serviceFee > 0) {
await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, serviceFee, 'fees', tx)
}
const operationId = `${Types.UserOperationType.INCOMING_TX}-${serialId}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal }
this.sendOperationToNostr(userAddress.linkedApplication!, userAddress.user.user_id, op)
})
}
}))
}
addressPaidCb: AddressPaidCb = (txOutput, address, amount, internal) => {
this.storage.StartTransaction(async tx => {
const { blockHeight } = await this.lnd.GetInfo()
const userAddress = await this.storage.paymentStorage.GetAddressOwner(address, tx)
if (!userAddress) { return }
let log = getLogger({})
if (!userAddress.linkedApplication) {
log(ERROR, "an address was paid, that has no linked application")
return
}
log = getLogger({ appName: userAddress.linkedApplication.name })
const isAppUserPayment = userAddress.user.user_id !== userAddress.linkedApplication.owner.user_id
let fee = this.paymentManager.getServiceFee(Types.UserOperationType.INCOMING_TX, amount, isAppUserPayment)
if (userAddress.linkedApplication && userAddress.linkedApplication.owner.user_id === userAddress.user.user_id) {
fee = 0
}
try {
// This call will fail if the transaction is already registered
const addedTx = await this.storage.paymentStorage.AddAddressReceivingTransaction(userAddress, txOutput.hash, txOutput.index, amount, fee, internal, blockHeight, tx)
if (internal) {
const addressData = `${address}:${txOutput.hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, addedTx.paid_amount - fee, addressData, tx)
if (fee > 0) {
await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, fee, 'fees', tx)
}
}
const operationId = `${Types.UserOperationType.INCOMING_TX}-${addedTx.serial_id}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: fee, confirmed: internal, tx_hash: txOutput.hash, internal: false }
this.sendOperationToNostr(userAddress.linkedApplication, userAddress.user.user_id, op)
} catch {
log(ERROR, "cannot process address paid transaction, already registered")
}
})
}
invoicePaidCb: InvoicePaidCb = (paymentRequest, amount, internal) => {
this.storage.StartTransaction(async tx => {
let log = getLogger({})
const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(paymentRequest, tx)
if (!userInvoice) { return }
if (userInvoice.paid_at_unix > 0 && internal) { log("cannot pay internally, invoice already paid"); return }
if (userInvoice.paid_at_unix > 0 && !internal && userInvoice.paidByLnd) { log("invoice already paid by lnd"); return }
if (!userInvoice.linkedApplication) {
log(ERROR, "an invoice was paid, that has no linked application")
return
}
log = getLogger({ appName: userInvoice.linkedApplication.name })
const isAppUserPayment = userInvoice.user.user_id !== userInvoice.linkedApplication.owner.user_id
let fee = this.paymentManager.getServiceFee(Types.UserOperationType.INCOMING_INVOICE, amount, isAppUserPayment)
if (userInvoice.linkedApplication && userInvoice.linkedApplication.owner.user_id === userInvoice.user.user_id) {
fee = 0
}
try {
await this.storage.paymentStorage.FlagInvoiceAsPaid(userInvoice, amount, fee, internal, tx)
this.storage.eventsLog.LogEvent({ type: 'invoice_paid', userId: userInvoice.user.user_id, appId: userInvoice.linkedApplication.app_id, appUserId: "", balance: userInvoice.user.balance_sats, data: paymentRequest, amount })
await this.storage.userStorage.IncrementUserBalance(userInvoice.user.user_id, amount - fee, userInvoice.invoice, tx)
if (fee > 0) {
await this.storage.userStorage.IncrementUserBalance(userInvoice.linkedApplication.owner.user_id, fee, 'fees', tx)
}
await this.triggerPaidCallback(log, userInvoice.callbackUrl)
const operationId = `${Types.UserOperationType.INCOMING_INVOICE}-${userInvoice.serial_id}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_INVOICE, identifier: userInvoice.invoice, operationId, network_fee: 0, service_fee: fee, confirmed: true, tx_hash: "", internal }
this.sendOperationToNostr(userInvoice.linkedApplication, userInvoice.user.user_id, op)
this.createZapReceipt(log, userInvoice)
log("paid invoice processed successfully")
} catch (err: any) {
log(ERROR, "cannot process paid invoice", err.message || "")
}
})
}
async triggerPaidCallback(log: PubLogger, url: string) {
if (!url) {
return
}
try {
await fetch(url + "&ok=true")
} catch (err: any) {
log(ERROR, "error sending paid callback for invoice", err.message || "")
}
}
async sendOperationToNostr(app: Application, userId: string, op: Types.UserOperation) {
const user = await this.storage.applicationStorage.GetAppUserFromUser(app, userId)
if (!user || !user.nostr_public_key) {
getLogger({ appName: app.name })("cannot notify user, not a nostr user")
return
}
const message: Types.LiveUserOperation & { requestId: string, status: 'OK' } = { operation: op, requestId: "GetLiveUserOperations", status: 'OK' }
this.nostrSend({ type: 'app', appId: app.app_id }, { type: 'content', content: JSON.stringify(message), pub: user.nostr_public_key })
}
async UpdateBeacon(app: Application, content: { type: 'service', name: string }) {
if (!app.nostr_public_key) {
getLogger({ appName: app.name })("cannot update beacon, public key not set")
return
}
const tags = [["d", appTag]]
const event: UnsignedEvent = {
content: JSON.stringify(content),
created_at: Math.floor(Date.now() / 1000),
kind: 30078,
pubkey: app.nostr_public_key,
tags,
}
this.nostrSend({ type: 'app', appId: app.app_id }, { type: 'event', event })
}
async createZapReceipt(log: PubLogger, invoice: UserReceivingInvoice) {
const zapInfo = invoice.zap_info
if (!zapInfo || !invoice.linkedApplication || !invoice.linkedApplication.nostr_public_key) {
log(ERROR, "no zap info linked to payment")
return
}
const tags = [["p", zapInfo.pub], ["bolt11", invoice.invoice], ["description", zapInfo.description]]
if (zapInfo.eventId) {
tags.push(["e", zapInfo.eventId])
}
const event: UnsignedEvent = {
content: "",
created_at: invoice.paid_at_unix,
kind: 9735,
pubkey: invoice.linkedApplication.nostr_public_key,
tags,
}
log({ unsigned: event })
this.nostrSend({ type: 'app', appId: invoice.linkedApplication.app_id }, { type: 'event', event })
}
}
import fetch from "node-fetch"
import Storage, { LoadStorageSettingsFromEnv } from '../storage/index.js'
import * as Types from '../../../proto/autogenerated/ts/types.js'
import ProductManager from './productManager.js'
import ApplicationManager from './applicationManager.js'
import PaymentManager, { PendingTx } from './paymentManager.js'
import { MainSettings } from './settings.js'
import LND from "../lnd/lnd.js"
import { AddressPaidCb, HtlcCb, InvoicePaidCb, NewBlockCb } from "../lnd/settings.js"
import { ERROR, getLogger, PubLogger } from "../helpers/logger.js"
import AppUserManager from "./appUserManager.js"
import { Application } from '../storage/entity/Application.js'
import { UserReceivingInvoice } from '../storage/entity/UserReceivingInvoice.js'
import { UnsignedEvent } from '../nostr/tools/event.js'
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 "./liquidityManager.js"
type UserOperationsSub = {
id: string
newIncomingInvoice: (operation: Types.UserOperation) => void
newOutgoingInvoice: (operation: Types.UserOperation) => void
newIncomingTx: (operation: Types.UserOperation) => void
newOutgoingTx: (operation: Types.UserOperation) => void
}
const appTag = "Lightning.Pub"
export default class {
storage: Storage
lnd: LND
settings: MainSettings
userOperationsSub: UserOperationsSub | null = null
productManager: ProductManager
applicationManager: ApplicationManager
appUserManager: AppUserManager
paymentManager: PaymentManager
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.liquiditySettings.liquidityProviderPub, this.invoicePaidCb)
const provider = { liquidProvider: this.liquidProvider, useOnly: settings.liquiditySettings.useOnlyLiquidityProvider }
this.lnd = new LND(settings.lndSettings, provider, this.addressPaidCb, this.invoicePaidCb, this.newBlockCb, this.htlcCb)
this.liquidityManager = new LiquidityManager(this.settings.liquiditySettings, this.storage, this.liquidProvider, this.lnd)
this.metricsManager = new MetricsManager(this.storage, this.lnd)
this.paymentManager = new PaymentManager(this.storage, this.lnd, this.settings, this.liquidityManager, 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()
this.paymentManager.Stop()
}
StartBeacons() {
this.applicationManager.StartAppsServiceBeacon(app => {
this.UpdateBeacon(app, { type: 'service', name: app.name })
})
}
attachNostrSend(f: NostrSend) {
this.nostrSend = f
this.liquidProvider.attachNostrSend(f)
}
htlcCb: HtlcCb = (e) => {
this.metricsManager.HtlcCb(e)
}
newBlockCb: NewBlockCb = (height) => {
this.NewBlockHandler(height)
}
NewBlockHandler = async (height: number) => {
let confirmed: (PendingTx & { confs: number; })[]
let log = getLogger({})
try {
const balanceEvents = await this.paymentManager.GetLndBalance()
await this.metricsManager.NewBlockCb(height, balanceEvents)
confirmed = await this.paymentManager.CheckNewlyConfirmedTxs(height)
this.liquidityManager.onNewBlock()
} catch (err: any) {
log(ERROR, "failed to check transactions after new block", err.message || err)
return
}
await Promise.all(confirmed.map(async c => {
if (c.type === 'outgoing') {
await this.storage.paymentStorage.UpdateUserTransactionPayment(c.tx.serial_id, { confs: c.confs })
const { linkedApplication, user, address, paid_amount: amount, service_fees: serviceFee, serial_id: serialId, chain_fees } = c.tx;
const operationId = `${Types.UserOperationType.OUTGOING_TX}-${serialId}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: false, type: Types.UserOperationType.OUTGOING_TX, identifier: address, operationId, network_fee: chain_fees, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal }
this.sendOperationToNostr(linkedApplication!, user.user_id, op)
} else {
this.storage.StartTransaction(async tx => {
const { user_address: userAddress, paid_amount: amount, service_fee: serviceFee, serial_id: serialId, tx_hash } = c.tx
if (!userAddress.linkedApplication) {
log(ERROR, "an address was paid, that has no linked application")
return
}
const updateResult = await this.storage.paymentStorage.UpdateAddressReceivingTransaction(serialId, { confs: c.confs }, tx)
if (!updateResult.affected) {
throw new Error("unable to flag chain transaction as paid")
}
const addressData = `${userAddress.address}:${tx_hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, amount - serviceFee, addressData, tx)
if (serviceFee > 0) {
await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, serviceFee, 'fees', tx)
}
const operationId = `${Types.UserOperationType.INCOMING_TX}-${serialId}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal }
this.sendOperationToNostr(userAddress.linkedApplication!, userAddress.user.user_id, op)
})
}
}))
}
addressPaidCb: AddressPaidCb = (txOutput, address, amount, internal) => {
this.storage.StartTransaction(async tx => {
const { blockHeight } = await this.lnd.GetInfo()
const userAddress = await this.storage.paymentStorage.GetAddressOwner(address, tx)
if (!userAddress) { return }
let log = getLogger({})
if (!userAddress.linkedApplication) {
log(ERROR, "an address was paid, that has no linked application")
return
}
log = getLogger({ appName: userAddress.linkedApplication.name })
const isAppUserPayment = userAddress.user.user_id !== userAddress.linkedApplication.owner.user_id
let fee = this.paymentManager.getServiceFee(Types.UserOperationType.INCOMING_TX, amount, isAppUserPayment)
if (userAddress.linkedApplication && userAddress.linkedApplication.owner.user_id === userAddress.user.user_id) {
fee = 0
}
try {
// This call will fail if the transaction is already registered
const addedTx = await this.storage.paymentStorage.AddAddressReceivingTransaction(userAddress, txOutput.hash, txOutput.index, amount, fee, internal, blockHeight, tx)
if (internal) {
const addressData = `${address}:${txOutput.hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, addedTx.paid_amount - fee, addressData, tx)
if (fee > 0) {
await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, fee, 'fees', tx)
}
}
const operationId = `${Types.UserOperationType.INCOMING_TX}-${addedTx.serial_id}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: fee, confirmed: internal, tx_hash: txOutput.hash, internal: false }
this.sendOperationToNostr(userAddress.linkedApplication, userAddress.user.user_id, op)
} catch {
log(ERROR, "cannot process address paid transaction, already registered")
}
})
}
invoicePaidCb: InvoicePaidCb = (paymentRequest, amount, internal) => {
this.storage.StartTransaction(async tx => {
let log = getLogger({})
const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(paymentRequest, tx)
if (!userInvoice) { return }
if (userInvoice.paid_at_unix > 0 && internal) { log("cannot pay internally, invoice already paid"); return }
if (userInvoice.paid_at_unix > 0 && !internal && userInvoice.paidByLnd) { log("invoice already paid by lnd"); return }
if (!userInvoice.linkedApplication) {
log(ERROR, "an invoice was paid, that has no linked application")
return
}
log = getLogger({ appName: userInvoice.linkedApplication.name })
const isAppUserPayment = userInvoice.user.user_id !== userInvoice.linkedApplication.owner.user_id
let fee = this.paymentManager.getServiceFee(Types.UserOperationType.INCOMING_INVOICE, amount, isAppUserPayment)
if (userInvoice.linkedApplication && userInvoice.linkedApplication.owner.user_id === userInvoice.user.user_id) {
fee = 0
}
try {
await this.storage.paymentStorage.FlagInvoiceAsPaid(userInvoice, amount, fee, internal, tx)
this.storage.eventsLog.LogEvent({ type: 'invoice_paid', userId: userInvoice.user.user_id, appId: userInvoice.linkedApplication.app_id, appUserId: "", balance: userInvoice.user.balance_sats, data: paymentRequest, amount })
await this.storage.userStorage.IncrementUserBalance(userInvoice.user.user_id, amount - fee, userInvoice.invoice, tx)
if (fee > 0) {
await this.storage.userStorage.IncrementUserBalance(userInvoice.linkedApplication.owner.user_id, fee, 'fees', tx)
}
await this.triggerPaidCallback(log, userInvoice.callbackUrl)
const operationId = `${Types.UserOperationType.INCOMING_INVOICE}-${userInvoice.serial_id}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_INVOICE, identifier: userInvoice.invoice, operationId, network_fee: 0, service_fee: fee, confirmed: true, tx_hash: "", internal }
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 || "")
}
})
}
async triggerPaidCallback(log: PubLogger, url: string) {
if (!url) {
return
}
try {
await fetch(url + "&ok=true")
} catch (err: any) {
log(ERROR, "error sending paid callback for invoice", err.message || "")
}
}
async sendOperationToNostr(app: Application, userId: string, op: Types.UserOperation) {
const user = await this.storage.applicationStorage.GetAppUserFromUser(app, userId)
if (!user || !user.nostr_public_key) {
getLogger({ appName: app.name })("cannot notify user, not a nostr user")
return
}
const message: Types.LiveUserOperation & { requestId: string, status: 'OK' } = { operation: op, requestId: "GetLiveUserOperations", status: 'OK' }
this.nostrSend({ type: 'app', appId: app.app_id }, { type: 'content', content: JSON.stringify(message), pub: user.nostr_public_key })
}
async UpdateBeacon(app: Application, content: { type: 'service', name: string }) {
if (!app.nostr_public_key) {
getLogger({ appName: app.name })("cannot update beacon, public key not set")
return
}
const tags = [["d", appTag]]
const event: UnsignedEvent = {
content: JSON.stringify(content),
created_at: Math.floor(Date.now() / 1000),
kind: 30078,
pubkey: app.nostr_public_key,
tags,
}
this.nostrSend({ type: 'app', appId: app.app_id }, { type: 'event', event })
}
async createZapReceipt(log: PubLogger, invoice: UserReceivingInvoice) {
const zapInfo = invoice.zap_info
if (!zapInfo || !invoice.linkedApplication || !invoice.linkedApplication.nostr_public_key) {
log(ERROR, "no zap info linked to payment")
return
}
const tags = [["p", zapInfo.pub], ["bolt11", invoice.invoice], ["description", zapInfo.description]]
if (zapInfo.eventId) {
tags.push(["e", zapInfo.eventId])
}
const event: UnsignedEvent = {
content: "",
created_at: invoice.paid_at_unix,
kind: 9735,
pubkey: invoice.linkedApplication.nostr_public_key,
tags,
}
log({ unsigned: event })
this.nostrSend({ type: 'app', appId: invoice.linkedApplication.app_id }, { type: 'event', event })
}
}

View file

@ -0,0 +1,106 @@
import { getLogger } from "../helpers/logger.js"
import { LiquidityProvider } from "../lnd/liquidityProvider.js"
import LND from "../lnd/lnd.js"
import { FlashsatsLSP, LoadLSPSettingsFromEnv, LSPSettings, OlympusLSP, VoltageLSP } from "../lnd/lsp.js"
import Storage from '../storage/index.js'
import { defaultInvoiceExpiry } from "../storage/paymentStorage.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
storage: Storage
liquidityProvider: LiquidityProvider
lnd: LND
olympusLSP: OlympusLSP
voltageLSP: VoltageLSP
flashsatsLSP: FlashsatsLSP
log = getLogger({ component: "liquidityManager" })
channelRequested = false
channelRequesting = false
constructor(settings: LiquiditySettings, storage: Storage, liquidityProvider: LiquidityProvider, lnd: LND) {
this.settings = settings
this.storage = storage
this.liquidityProvider = liquidityProvider
this.lnd = lnd
this.olympusLSP = new OlympusLSP(settings.lspSettings, lnd, liquidityProvider)
this.voltageLSP = new VoltageLSP(settings.lspSettings, lnd, liquidityProvider)
this.flashsatsLSP = new FlashsatsLSP(settings.lspSettings, lnd, liquidityProvider)
}
onNewBlock = async () => {
const balance = await this.liquidityProvider.GetLatestMaxWithdrawable()
const { remote } = await this.lnd.ChannelBalance()
if (remote > balance) {
this.log("draining provider balance to channel")
const invoice = await this.lnd.NewInvoice(balance, "liqudity provider drain", defaultInvoiceExpiry)
const res = await this.liquidityProvider.PayInvoice(invoice.payRequest)
this.log("drained provider balance to channel", res.amount_paid)
}
}
beforeInvoiceCreation = async (amount: number): Promise<'lnd' | 'provider'> => {
const { remote } = await this.lnd.ChannelBalance()
if (remote > amount) {
this.log("channel has enough balance for invoice")
return 'lnd'
}
this.log("channel does not have enough balance for invoice,suggesting provider")
return 'provider'
}
afterInInvoicePaid = async () => {
const existingOrder = await this.storage.liquidityStorage.GetLatestLspOrder()
if (existingOrder) {
return
}
if (this.channelRequested || this.channelRequesting) {
return
}
this.channelRequesting = true
this.log("checking if channel should be requested")
const olympusOk = await this.olympusLSP.openChannelIfReady()
if (olympusOk) {
this.log("requested channel from olympus")
this.channelRequested = true
this.channelRequesting = false
await this.storage.liquidityStorage.SaveLspOrder({ service_name: 'olympus', invoice: olympusOk.invoice, total_paid: olympusOk.totalSats, order_id: olympusOk.orderId, fees: olympusOk.fees })
return
}
const voltageOk = await this.voltageLSP.openChannelIfReady()
if (voltageOk) {
this.log("requested channel from voltage")
this.channelRequested = true
this.channelRequesting = false
await this.storage.liquidityStorage.SaveLspOrder({ service_name: 'voltage', invoice: voltageOk.invoice, total_paid: voltageOk.totalSats, order_id: voltageOk.orderId, fees: voltageOk.fees })
return
}
const flashsatsOk = await this.flashsatsLSP.openChannelIfReady()
if (flashsatsOk) {
this.log("requested channel from flashsats")
this.channelRequested = true
this.channelRequesting = false
await this.storage.liquidityStorage.SaveLspOrder({ service_name: 'flashsats', invoice: flashsatsOk.invoice, total_paid: flashsatsOk.totalSats, order_id: flashsatsOk.orderId, fees: flashsatsOk.fees })
return
}
this.channelRequesting = false
this.log("no channel requested")
}
beforeOutInvoicePayment = async (amount: number): Promise<'lnd' | 'provider'> => {
const balance = await this.liquidityProvider.GetLatestMaxWithdrawable(true)
if (balance > amount) {
this.log("provider has enough balance for payment")
return 'provider'
}
this.log("provider does not have enough balance for payment, suggesting lnd")
return 'lnd'
}
afterOutInvoicePaid = async () => { }
}

File diff suppressed because it is too large Load diff

View file

@ -1,106 +1,114 @@
import { LoadStorageSettingsFromEnv, StorageSettings } from '../storage/index.js'
import { LndSettings, NodeSettings } from '../lnd/settings.js'
import { LoadWatchdogSettingsFromEnv, WatchdogSettings } from './watchdog.js'
import { LoadLndSettingsFromEnv } from '../lnd/index.js'
import { EnvCanBeInteger, EnvMustBeInteger, EnvMustBeNonEmptyString } from '../helpers/envParser.js'
import { getLogger } from '../helpers/logger.js'
import fs from 'fs'
import crypto from 'crypto';
export type MainSettings = {
storageSettings: StorageSettings,
lndSettings: LndSettings,
watchDogSettings: WatchdogSettings,
jwtSecret: string
incomingTxFee: number
outgoingTxFee: number
incomingAppInvoiceFee: number
incomingAppUserInvoiceFee: number
outgoingAppInvoiceFee: number
outgoingAppUserInvoiceFee: number
userToUserFee: number
appToUserFee: number
serviceUrl: string
servicePort: number
recordPerformance: boolean
skipSanityCheck: boolean
disableExternalPayments: boolean
}
export type BitcoinCoreSettings = {
port: number
user: string
pass: string
}
export type TestSettings = MainSettings & { lndSettings: { otherNode: NodeSettings, thirdNode: NodeSettings, fourthNode: NodeSettings }, bitcoinCoreSettings: BitcoinCoreSettings }
export const LoadMainSettingsFromEnv = (): MainSettings => {
return {
watchDogSettings: LoadWatchdogSettingsFromEnv(),
lndSettings: LoadLndSettingsFromEnv(),
storageSettings: LoadStorageSettingsFromEnv(),
jwtSecret: loadJwtSecret(),
incomingTxFee: EnvCanBeInteger("INCOMING_CHAIN_FEE_ROOT_BPS", 0) / 10000,
outgoingTxFee: EnvCanBeInteger("OUTGOING_CHAIN_FEE_ROOT_BPS", 60) / 10000,
incomingAppInvoiceFee: EnvCanBeInteger("INCOMING_INVOICE_FEE_ROOT_BPS", 0) / 10000,
outgoingAppInvoiceFee: EnvCanBeInteger("OUTGOING_INVOICE_FEE_ROOT_BPS", 60) / 10000,
incomingAppUserInvoiceFee: EnvCanBeInteger("INCOMING_INVOICE_FEE_USER_BPS", 0) / 10000,
outgoingAppUserInvoiceFee: EnvCanBeInteger("OUTGOING_INVOICE_FEE_USER_BPS", 0) / 10000,
userToUserFee: EnvCanBeInteger("TX_FEE_INTERNAL_USER_BPS", 0) / 10000,
appToUserFee: EnvCanBeInteger("TX_FEE_INTERNAL_ROOT_BPS", 0) / 10000,
serviceUrl: process.env.SERVICE_URL || `http://localhost:${EnvCanBeInteger("PORT", 1776)}`,
servicePort: EnvCanBeInteger("PORT", 1776),
recordPerformance: process.env.RECORD_PERFORMANCE === 'true' || false,
skipSanityCheck: process.env.SKIP_SANITY_CHECK === 'true' || false,
disableExternalPayments: process.env.DISABLE_EXTERNAL_PAYMENTS === 'true' || false,
}
}
export const LoadTestSettingsFromEnv = (): TestSettings => {
const eventLogPath = `logs/eventLogV2Test${Date.now()}.csv`
const settings = LoadMainSettingsFromEnv()
return {
...settings,
storageSettings: { dbSettings: { ...settings.storageSettings.dbSettings, databaseFile: ":memory:", metricsDatabaseFile: ":memory:" }, eventLogPath },
lndSettings: {
...settings.lndSettings,
otherNode: {
lndAddr: EnvMustBeNonEmptyString("LND_OTHER_ADDR"),
lndCertPath: EnvMustBeNonEmptyString("LND_OTHER_CERT_PATH"),
lndMacaroonPath: EnvMustBeNonEmptyString("LND_OTHER_MACAROON_PATH")
},
thirdNode: {
lndAddr: EnvMustBeNonEmptyString("LND_THIRD_ADDR"),
lndCertPath: EnvMustBeNonEmptyString("LND_THIRD_CERT_PATH"),
lndMacaroonPath: EnvMustBeNonEmptyString("LND_THIRD_MACAROON_PATH")
},
fourthNode: {
lndAddr: EnvMustBeNonEmptyString("LND_FOURTH_ADDR"),
lndCertPath: EnvMustBeNonEmptyString("LND_FOURTH_CERT_PATH"),
lndMacaroonPath: EnvMustBeNonEmptyString("LND_FOURTH_MACAROON_PATH")
},
liquidityProviderPub: ""
},
skipSanityCheck: true,
bitcoinCoreSettings: {
port: EnvMustBeInteger("BITCOIN_CORE_PORT"),
user: EnvMustBeNonEmptyString("BITCOIN_CORE_USER"),
pass: EnvMustBeNonEmptyString("BITCOIN_CORE_PASS")
},
}
}
export const loadJwtSecret = (): string => {
const secret = process.env["JWT_SECRET"]
const log = getLogger({})
if (secret) {
return secret
}
log("JWT_SECRET not set in env, checking .jwt_secret file")
try {
const fileContent = fs.readFileSync(".jwt_secret", "utf-8")
return fileContent.trim()
} catch (e) {
log(".jwt_secret file not found, generating random secret")
const secret = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(".jwt_secret", secret)
return secret
}
import { LoadStorageSettingsFromEnv, StorageSettings } from '../storage/index.js'
import { LndSettings, NodeSettings } from '../lnd/settings.js'
import { LoadWatchdogSettingsFromEnv, WatchdogSettings } from './watchdog.js'
import { LoadLndSettingsFromEnv } from '../lnd/index.js'
import { EnvCanBeInteger, EnvMustBeInteger, EnvMustBeNonEmptyString } from '../helpers/envParser.js'
import { getLogger } from '../helpers/logger.js'
import fs from 'fs'
import crypto from 'crypto';
import { LiquiditySettings, LoadLiquiditySettingsFromEnv } from './liquidityManager.js'
export type MainSettings = {
storageSettings: StorageSettings,
lndSettings: LndSettings,
watchDogSettings: WatchdogSettings,
liquiditySettings: LiquiditySettings,
jwtSecret: string
incomingTxFee: number
outgoingTxFee: number
incomingAppInvoiceFee: number
incomingAppUserInvoiceFee: number
outgoingAppInvoiceFee: number
outgoingAppUserInvoiceFee: number
userToUserFee: number
appToUserFee: number
serviceUrl: string
servicePort: number
recordPerformance: boolean
skipSanityCheck: boolean
disableExternalPayments: boolean
}
export type BitcoinCoreSettings = {
port: number
user: string
pass: string
}
export type TestSettings = MainSettings & { lndSettings: { otherNode: NodeSettings, thirdNode: NodeSettings, fourthNode: NodeSettings }, bitcoinCoreSettings: BitcoinCoreSettings }
export const LoadMainSettingsFromEnv = (): MainSettings => {
const storageSettings = LoadStorageSettingsFromEnv()
return {
watchDogSettings: LoadWatchdogSettingsFromEnv(),
lndSettings: LoadLndSettingsFromEnv(),
storageSettings: storageSettings,
liquiditySettings: LoadLiquiditySettingsFromEnv(),
jwtSecret: loadJwtSecret(storageSettings.dataDir),
incomingTxFee: EnvCanBeInteger("INCOMING_CHAIN_FEE_ROOT_BPS", 0) / 10000,
outgoingTxFee: EnvCanBeInteger("OUTGOING_CHAIN_FEE_ROOT_BPS", 60) / 10000,
incomingAppInvoiceFee: EnvCanBeInteger("INCOMING_INVOICE_FEE_ROOT_BPS", 0) / 10000,
outgoingAppInvoiceFee: EnvCanBeInteger("OUTGOING_INVOICE_FEE_ROOT_BPS", 60) / 10000,
incomingAppUserInvoiceFee: EnvCanBeInteger("INCOMING_INVOICE_FEE_USER_BPS", 0) / 10000,
outgoingAppUserInvoiceFee: EnvCanBeInteger("OUTGOING_INVOICE_FEE_USER_BPS", 0) / 10000,
userToUserFee: EnvCanBeInteger("TX_FEE_INTERNAL_USER_BPS", 0) / 10000,
appToUserFee: EnvCanBeInteger("TX_FEE_INTERNAL_ROOT_BPS", 0) / 10000,
serviceUrl: process.env.SERVICE_URL || `http://localhost:${EnvCanBeInteger("PORT", 1776)}`,
servicePort: EnvCanBeInteger("PORT", 1776),
recordPerformance: process.env.RECORD_PERFORMANCE === 'true' || false,
skipSanityCheck: process.env.SKIP_SANITY_CHECK === 'true' || false,
disableExternalPayments: process.env.DISABLE_EXTERNAL_PAYMENTS === 'true' || false,
}
}
export const LoadTestSettingsFromEnv = (): TestSettings => {
const eventLogPath = `logs/eventLogV2Test${Date.now()}.csv`
const settings = LoadMainSettingsFromEnv()
return {
...settings,
storageSettings: { dbSettings: { ...settings.storageSettings.dbSettings, databaseFile: ":memory:", metricsDatabaseFile: ":memory:" }, eventLogPath, dataDir: "data" },
lndSettings: {
...settings.lndSettings,
otherNode: {
lndAddr: EnvMustBeNonEmptyString("LND_OTHER_ADDR"),
lndCertPath: EnvMustBeNonEmptyString("LND_OTHER_CERT_PATH"),
lndMacaroonPath: EnvMustBeNonEmptyString("LND_OTHER_MACAROON_PATH")
},
thirdNode: {
lndAddr: EnvMustBeNonEmptyString("LND_THIRD_ADDR"),
lndCertPath: EnvMustBeNonEmptyString("LND_THIRD_CERT_PATH"),
lndMacaroonPath: EnvMustBeNonEmptyString("LND_THIRD_MACAROON_PATH")
},
fourthNode: {
lndAddr: EnvMustBeNonEmptyString("LND_FOURTH_ADDR"),
lndCertPath: EnvMustBeNonEmptyString("LND_FOURTH_CERT_PATH"),
lndMacaroonPath: EnvMustBeNonEmptyString("LND_FOURTH_MACAROON_PATH")
},
},
liquiditySettings: {
...settings.liquiditySettings,
liquidityProviderPub: "",
},
skipSanityCheck: true,
bitcoinCoreSettings: {
port: EnvMustBeInteger("BITCOIN_CORE_PORT"),
user: EnvMustBeNonEmptyString("BITCOIN_CORE_USER"),
pass: EnvMustBeNonEmptyString("BITCOIN_CORE_PASS")
},
}
}
export const loadJwtSecret = (dataDir: string): string => {
const secret = process.env["JWT_SECRET"]
const log = getLogger({})
if (secret) {
return secret
}
log("JWT_SECRET not set in env, checking .jwt_secret file")
const secretPath = dataDir !== "" ? `${dataDir}/.jwt_secret` : ".jwt_secret"
try {
const fileContent = fs.readFileSync(secretPath, "utf-8")
return fileContent.trim()
} catch (e) {
log(".jwt_secret file not found, generating random secret")
const secret = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(secretPath, secret)
return secret
}
}

View file

@ -1,179 +1,183 @@
import { EnvCanBeInteger } from "../helpers/envParser.js";
import FunctionQueue from "../helpers/functionQueue.js";
import { getLogger } from "../helpers/logger.js";
import LND from "../lnd/lnd.js";
import { ChannelBalance } from "../lnd/settings.js";
import Storage from '../storage/index.js'
export type WatchdogSettings = {
maxDiffSats: number
}
export const LoadWatchdogSettingsFromEnv = (test = false): WatchdogSettings => {
return {
maxDiffSats: EnvCanBeInteger("WATCHDOG_MAX_DIFF_SATS")
}
}
export class Watchdog {
queue: FunctionQueue<void>
initialLndBalance: number;
initialUsersBalance: number;
startedAtUnix: number;
latestIndexOffset: number;
accumulatedHtlcFees: number;
lnd: LND;
settings: WatchdogSettings;
storage: Storage;
latestCheckStart = 0
log = getLogger({ component: "watchdog" })
ready = false
interval: NodeJS.Timer;
constructor(settings: WatchdogSettings, lnd: LND, storage: Storage) {
this.lnd = lnd;
this.settings = settings;
this.storage = storage;
this.queue = new FunctionQueue("watchdog_queue", () => this.StartCheck())
}
Stop() {
if (this.interval) {
clearInterval(this.interval)
}
}
Start = async () => {
this.startedAtUnix = Math.floor(Date.now() / 1000)
const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance()
this.initialLndBalance = await this.getTotalLndBalance(totalUsersBalance)
this.initialUsersBalance = totalUsersBalance
const fwEvents = await this.lnd.GetForwardingHistory(0, this.startedAtUnix)
this.latestIndexOffset = fwEvents.lastOffsetIndex
this.accumulatedHtlcFees = 0
this.interval = setInterval(() => {
if (this.latestCheckStart + (1000 * 60) < Date.now()) {
this.log("No balance check was made in the last minute, checking now")
this.PaymentRequested()
}
}, 1000 * 60)
this.ready = true
}
updateAccumulatedHtlcFees = async () => {
const fwEvents = await this.lnd.GetForwardingHistory(this.latestIndexOffset, this.startedAtUnix)
this.latestIndexOffset = fwEvents.lastOffsetIndex
fwEvents.forwardingEvents.forEach((event) => {
this.accumulatedHtlcFees += Number(event.fee)
})
}
getTotalLndBalance = async (usersTotal: number) => {
const walletBalance = await this.lnd.GetWalletBalance()
this.log(Number(walletBalance.confirmedBalance), "sats in chain wallet")
const channelsBalance = await this.lnd.GetChannelBalance()
getLogger({ component: "debugLndBalancev3" })({ w: walletBalance, c: channelsBalance, u: usersTotal, f: this.accumulatedHtlcFees })
const totalLightningBalanceMsats = (channelsBalance.localBalance?.msat || 0n) + (channelsBalance.unsettledLocalBalance?.msat || 0n)
const totalLightningBalance = Math.ceil(Number(totalLightningBalanceMsats) / 1000)
return Number(walletBalance.confirmedBalance) + totalLightningBalance
}
checkBalanceUpdate = (deltaLnd: number, deltaUsers: number) => {
this.log("LND balance update:", deltaLnd, "sats since app startup")
this.log("Users balance update:", deltaUsers, "sats since app startup")
const result = this.checkDeltas(deltaLnd, deltaUsers)
switch (result.type) {
case 'mismatch':
if (deltaLnd < 0) {
this.log("WARNING! LND balance decreased while users balance increased creating a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > this.settings.maxDiffSats) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else {
this.log("LND balance increased while users balance decreased creating a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
break
case 'negative':
if (Math.abs(deltaLnd) > Math.abs(deltaUsers)) {
this.log("WARNING! LND balance decreased more than users balance with a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > this.settings.maxDiffSats) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else if (deltaLnd === deltaUsers) {
this.log("LND and users balance went both DOWN consistently")
return false
} else {
this.log("LND balance decreased less than users balance with a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
break
case 'positive':
if (deltaLnd < deltaUsers) {
this.log("WARNING! LND balance increased less than users balance with a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > this.settings.maxDiffSats) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else if (deltaLnd === deltaUsers) {
this.log("LND and users balance went both UP consistently")
return false
} else {
this.log("LND balance increased more than users balance with a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
}
return false
}
StartCheck = async () => {
this.latestCheckStart = Date.now()
await this.updateAccumulatedHtlcFees()
const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance()
const totalLndBalance = await this.getTotalLndBalance(totalUsersBalance)
const deltaLnd = totalLndBalance - (this.initialLndBalance + this.accumulatedHtlcFees)
const deltaUsers = totalUsersBalance - this.initialUsersBalance
const deny = this.checkBalanceUpdate(deltaLnd, deltaUsers)
if (deny) {
this.log("Balance mismatch detected in absolute update, locking outgoing operations")
this.lnd.LockOutgoingOperations()
return
}
this.lnd.UnlockOutgoingOperations()
}
PaymentRequested = async () => {
this.log("Payment requested, checking balance")
if (!this.ready) {
throw new Error("Watchdog not ready")
}
return new Promise<void>((res, rej) => {
this.queue.Run({ res, rej })
})
}
checkDeltas = (deltaLnd: number, deltaUsers: number): DeltaCheckResult => {
if (deltaLnd < 0) {
if (deltaUsers < 0) {
const diff = Math.abs(deltaLnd - deltaUsers)
return { type: 'negative', absoluteDiff: diff, relativeDiff: diff / Math.max(deltaLnd, deltaUsers) }
} else {
const diff = Math.abs(deltaLnd) + deltaUsers
return { type: 'mismatch', absoluteDiff: diff }
}
} else {
if (deltaUsers < 0) {
const diff = deltaLnd + Math.abs(deltaUsers)
return { type: 'mismatch', absoluteDiff: diff }
} else {
const diff = Math.abs(deltaLnd - deltaUsers)
return { type: 'positive', absoluteDiff: diff, relativeDiff: diff / Math.max(deltaLnd, deltaUsers) }
}
}
}
}
import { EnvCanBeInteger } from "../helpers/envParser.js";
import FunctionQueue from "../helpers/functionQueue.js";
import { getLogger } from "../helpers/logger.js";
import { LiquidityProvider } from "../lnd/liquidityProvider.js";
import LND from "../lnd/lnd.js";
import { ChannelBalance } from "../lnd/settings.js";
import Storage from '../storage/index.js'
export type WatchdogSettings = {
maxDiffSats: number
}
export const LoadWatchdogSettingsFromEnv = (test = false): WatchdogSettings => {
return {
maxDiffSats: EnvCanBeInteger("WATCHDOG_MAX_DIFF_SATS")
}
}
export class Watchdog {
queue: FunctionQueue<void>
initialLndBalance: number;
initialUsersBalance: number;
startedAtUnix: number;
latestIndexOffset: number;
accumulatedHtlcFees: number;
lnd: LND;
liquidProvider: LiquidityProvider;
settings: WatchdogSettings;
storage: Storage;
latestCheckStart = 0
log = getLogger({ component: "watchdog" })
ready = false
interval: NodeJS.Timer;
constructor(settings: WatchdogSettings, lnd: LND, storage: Storage) {
this.lnd = lnd;
this.settings = settings;
this.storage = storage;
this.liquidProvider = lnd.liquidProvider
this.queue = new FunctionQueue("watchdog_queue", () => this.StartCheck())
}
Stop() {
if (this.interval) {
clearInterval(this.interval)
}
}
Start = async () => {
this.startedAtUnix = Math.floor(Date.now() / 1000)
const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance()
this.initialLndBalance = await this.getTotalLndBalance(totalUsersBalance)
this.initialUsersBalance = totalUsersBalance
const fwEvents = await this.lnd.GetForwardingHistory(0, this.startedAtUnix)
this.latestIndexOffset = fwEvents.lastOffsetIndex
this.accumulatedHtlcFees = 0
this.interval = setInterval(() => {
if (this.latestCheckStart + (1000 * 60) < Date.now()) {
this.log("No balance check was made in the last minute, checking now")
this.PaymentRequested()
}
}, 1000 * 60)
this.ready = true
}
updateAccumulatedHtlcFees = async () => {
const fwEvents = await this.lnd.GetForwardingHistory(this.latestIndexOffset, this.startedAtUnix)
this.latestIndexOffset = fwEvents.lastOffsetIndex
fwEvents.forwardingEvents.forEach((event) => {
this.accumulatedHtlcFees += Number(event.fee)
})
}
getTotalLndBalance = async (usersTotal: number) => {
const walletBalance = await this.lnd.GetWalletBalance()
this.log(Number(walletBalance.confirmedBalance), "sats in chain wallet")
const channelsBalance = await this.lnd.GetChannelBalance()
getLogger({ component: "debugLndBalancev3" })({ w: walletBalance, c: channelsBalance, u: usersTotal, f: this.accumulatedHtlcFees })
const totalLightningBalanceMsats = (channelsBalance.localBalance?.msat || 0n) + (channelsBalance.unsettledLocalBalance?.msat || 0n)
const totalLightningBalance = Math.ceil(Number(totalLightningBalanceMsats) / 1000)
const providerBalance = await this.liquidProvider.GetLatestBalance()
return Number(walletBalance.confirmedBalance) + totalLightningBalance + providerBalance
}
checkBalanceUpdate = (deltaLnd: number, deltaUsers: number) => {
this.log("LND balance update:", deltaLnd, "sats since app startup")
this.log("Users balance update:", deltaUsers, "sats since app startup")
const result = this.checkDeltas(deltaLnd, deltaUsers)
switch (result.type) {
case 'mismatch':
if (deltaLnd < 0) {
this.log("WARNING! LND balance decreased while users balance increased creating a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > this.settings.maxDiffSats) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else {
this.log("LND balance increased while users balance decreased creating a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
break
case 'negative':
if (Math.abs(deltaLnd) > Math.abs(deltaUsers)) {
this.log("WARNING! LND balance decreased more than users balance with a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > this.settings.maxDiffSats) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else if (deltaLnd === deltaUsers) {
this.log("LND and users balance went both DOWN consistently")
return false
} else {
this.log("LND balance decreased less than users balance with a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
break
case 'positive':
if (deltaLnd < deltaUsers) {
this.log("WARNING! LND balance increased less than users balance with a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > this.settings.maxDiffSats) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else if (deltaLnd === deltaUsers) {
this.log("LND and users balance went both UP consistently")
return false
} else {
this.log("LND balance increased more than users balance with a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
}
return false
}
StartCheck = async () => {
this.latestCheckStart = Date.now()
await this.updateAccumulatedHtlcFees()
const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance()
const totalLndBalance = await this.getTotalLndBalance(totalUsersBalance)
const deltaLnd = totalLndBalance - (this.initialLndBalance + this.accumulatedHtlcFees)
const deltaUsers = totalUsersBalance - this.initialUsersBalance
const deny = this.checkBalanceUpdate(deltaLnd, deltaUsers)
if (deny) {
this.log("Balance mismatch detected in absolute update, locking outgoing operations")
this.lnd.LockOutgoingOperations()
return
}
this.lnd.UnlockOutgoingOperations()
}
PaymentRequested = async () => {
this.log("Payment requested, checking balance")
if (!this.ready) {
throw new Error("Watchdog not ready")
}
return new Promise<void>((res, rej) => {
this.queue.Run({ res, rej })
})
}
checkDeltas = (deltaLnd: number, deltaUsers: number): DeltaCheckResult => {
if (deltaLnd < 0) {
if (deltaUsers < 0) {
const diff = Math.abs(deltaLnd - deltaUsers)
return { type: 'negative', absoluteDiff: diff, relativeDiff: diff / Math.max(deltaLnd, deltaUsers) }
} else {
const diff = Math.abs(deltaLnd) + deltaUsers
return { type: 'mismatch', absoluteDiff: diff }
}
} else {
if (deltaUsers < 0) {
const diff = deltaLnd + Math.abs(deltaUsers)
return { type: 'mismatch', absoluteDiff: diff }
} else {
const diff = Math.abs(deltaLnd - deltaUsers)
return { type: 'positive', absoluteDiff: diff, relativeDiff: diff / Math.max(deltaLnd, deltaUsers) }
}
}
}
}
type DeltaCheckResult = { type: 'negative' | 'positive', absoluteDiff: number, relativeDiff: number } | { type: 'mismatch', absoluteDiff: number }

View file

@ -1,84 +1,85 @@
import "reflect-metadata"
import { DataSource, Migration } from "typeorm"
import { AddressReceivingTransaction } from "./entity/AddressReceivingTransaction.js"
import { User } from "./entity/User.js"
import { UserReceivingAddress } from "./entity/UserReceivingAddress.js"
import { UserReceivingInvoice } from "./entity/UserReceivingInvoice.js"
import { UserInvoicePayment } from "./entity/UserInvoicePayment.js"
import { EnvMustBeNonEmptyString } from "../helpers/envParser.js"
import { UserTransactionPayment } from "./entity/UserTransactionPayment.js"
import { UserBasicAuth } from "./entity/UserBasicAuth.js"
import { UserEphemeralKey } from "./entity/UserEphemeralKey.js"
import { Product } from "./entity/Product.js"
import { UserToUserPayment } from "./entity/UserToUserPayment.js"
import { Application } from "./entity/Application.js"
import { ApplicationUser } from "./entity/ApplicationUser.js"
import { BalanceEvent } from "./entity/BalanceEvent.js"
import { ChannelBalanceEvent } from "./entity/ChannelsBalanceEvent.js"
import { getLogger } from "../helpers/logger.js"
import { ChannelRouting } from "./entity/ChannelRouting.js"
export type DbSettings = {
databaseFile: string
migrate: boolean
metricsDatabaseFile: string
}
export const LoadDbSettingsFromEnv = (): DbSettings => {
return {
databaseFile: process.env.DATABASE_FILE || "db.sqlite",
migrate: process.env.MIGRATE_DB === 'true' || false,
metricsDatabaseFile: process.env.METRICS_DATABASE_FILE || "metrics.sqlite"
}
}
export const newMetricsDb = async (settings: DbSettings, metricsMigrations: Function[]): Promise<{ source: DataSource, executedMigrations: Migration[] }> => {
const source = await new DataSource({
type: "sqlite",
database: settings.metricsDatabaseFile,
entities: [BalanceEvent, ChannelBalanceEvent, ChannelRouting],
migrations: metricsMigrations
}).initialize();
const log = getLogger({});
const pendingMigrations = await source.showMigrations()
if (pendingMigrations) {
log("Migrations found, migrating...")
const executedMigrations = await source.runMigrations({ transaction: 'all' })
return { source, executedMigrations }
}
return { source, executedMigrations: [] }
}
export default async (settings: DbSettings, migrations: Function[]): Promise<{ source: DataSource, executedMigrations: Migration[] }> => {
const source = await new DataSource({
type: "sqlite",
database: settings.databaseFile,
// logging: true,
entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment,
UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment],
//synchronize: true,
migrations
}).initialize()
const log = getLogger({})
const pendingMigrations = await source.showMigrations()
if (pendingMigrations) {
log("migrations found, migrating...")
const executedMigrations = await source.runMigrations({ transaction: 'all' })
return { source, executedMigrations }
}
return { source, executedMigrations: [] }
}
export const runFakeMigration = async (databaseFile: string, migrations: Function[]) => {
const source = await new DataSource({
type: "sqlite",
database: databaseFile,
// logging: true,
entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment,
UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment],
//synchronize: true,
migrations
}).initialize()
return source.runMigrations({ fake: true })
import "reflect-metadata"
import { DataSource, Migration } from "typeorm"
import { AddressReceivingTransaction } from "./entity/AddressReceivingTransaction.js"
import { User } from "./entity/User.js"
import { UserReceivingAddress } from "./entity/UserReceivingAddress.js"
import { UserReceivingInvoice } from "./entity/UserReceivingInvoice.js"
import { UserInvoicePayment } from "./entity/UserInvoicePayment.js"
import { EnvMustBeNonEmptyString } from "../helpers/envParser.js"
import { UserTransactionPayment } from "./entity/UserTransactionPayment.js"
import { UserBasicAuth } from "./entity/UserBasicAuth.js"
import { UserEphemeralKey } from "./entity/UserEphemeralKey.js"
import { Product } from "./entity/Product.js"
import { UserToUserPayment } from "./entity/UserToUserPayment.js"
import { Application } from "./entity/Application.js"
import { ApplicationUser } from "./entity/ApplicationUser.js"
import { BalanceEvent } from "./entity/BalanceEvent.js"
import { ChannelBalanceEvent } from "./entity/ChannelsBalanceEvent.js"
import { getLogger } from "../helpers/logger.js"
import { ChannelRouting } from "./entity/ChannelRouting.js"
import { LspOrder } from "./entity/LspOrder.js"
export type DbSettings = {
databaseFile: string
migrate: boolean
metricsDatabaseFile: string
}
export const LoadDbSettingsFromEnv = (): DbSettings => {
return {
databaseFile: process.env.DATABASE_FILE || "db.sqlite",
migrate: process.env.MIGRATE_DB === 'true' || false,
metricsDatabaseFile: process.env.METRICS_DATABASE_FILE || "metrics.sqlite"
}
}
export const newMetricsDb = async (settings: DbSettings, metricsMigrations: Function[]): Promise<{ source: DataSource, executedMigrations: Migration[] }> => {
const source = await new DataSource({
type: "sqlite",
database: settings.metricsDatabaseFile,
entities: [BalanceEvent, ChannelBalanceEvent, ChannelRouting],
migrations: metricsMigrations
}).initialize();
const log = getLogger({});
const pendingMigrations = await source.showMigrations()
if (pendingMigrations) {
log("Migrations found, migrating...")
const executedMigrations = await source.runMigrations({ transaction: 'all' })
return { source, executedMigrations }
}
return { source, executedMigrations: [] }
}
export default async (settings: DbSettings, migrations: Function[]): Promise<{ source: DataSource, executedMigrations: Migration[] }> => {
const source = await new DataSource({
type: "sqlite",
database: settings.databaseFile,
// logging: true,
entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment,
UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder],
//synchronize: true,
migrations
}).initialize()
const log = getLogger({})
const pendingMigrations = await source.showMigrations()
if (pendingMigrations) {
log("migrations found, migrating...")
const executedMigrations = await source.runMigrations({ transaction: 'all' })
return { source, executedMigrations }
}
return { source, executedMigrations: [] }
}
export const runFakeMigration = async (databaseFile: string, migrations: Function[]) => {
const source = await new DataSource({
type: "sqlite",
database: databaseFile,
// logging: true,
entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment,
UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment],
//synchronize: true,
migrations
}).initialize()
return source.runMigrations({ fake: true })
}

View file

@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from "typeorm"
@Entity()
export class LspOrder {
@PrimaryGeneratedColumn()
serial_id: number
@Column()
service_name: string
@Column()
invoice: string
@Column()
order_id: string
@Column()
total_paid: number
@Column()
fees: number
@CreateDateColumn()
created_at: Date
@UpdateDateColumn()
updated_at: Date
}

View file

@ -1,47 +1,51 @@
import { DataSource, EntityManager } from "typeorm"
import NewDB, { DbSettings, LoadDbSettingsFromEnv } from "./db.js"
import ProductStorage from './productStorage.js'
import ApplicationStorage from './applicationStorage.js'
import UserStorage from "./userStorage.js";
import PaymentStorage from "./paymentStorage.js";
import MetricsStorage from "./metricsStorage.js";
import TransactionsQueue, { TX } from "./transactionsQueue.js";
import EventsLogManager from "./eventsLog.js";
export type StorageSettings = {
dbSettings: DbSettings
eventLogPath: string
}
export const LoadStorageSettingsFromEnv = (): StorageSettings => {
return { dbSettings: LoadDbSettingsFromEnv(), eventLogPath: "logs/eventLogV2.csv" }
}
export default class {
DB: DataSource | EntityManager
settings: StorageSettings
txQueue: TransactionsQueue
productStorage: ProductStorage
applicationStorage: ApplicationStorage
userStorage: UserStorage
paymentStorage: PaymentStorage
metricsStorage: MetricsStorage
eventsLog: EventsLogManager
constructor(settings: StorageSettings) {
this.settings = settings
this.eventsLog = new EventsLogManager(settings.eventLogPath)
}
async Connect(migrations: Function[], metricsMigrations: Function[]) {
const { source, executedMigrations } = await NewDB(this.settings.dbSettings, migrations)
this.DB = source
this.txQueue = new TransactionsQueue("main", this.DB)
this.userStorage = new UserStorage(this.DB, this.txQueue, this.eventsLog)
this.productStorage = new ProductStorage(this.DB, this.txQueue)
this.applicationStorage = new ApplicationStorage(this.DB, this.userStorage, this.txQueue)
this.paymentStorage = new PaymentStorage(this.DB, this.userStorage, this.txQueue)
this.metricsStorage = new MetricsStorage(this.settings)
const executedMetricsMigrations = await this.metricsStorage.Connect(metricsMigrations)
return { executedMigrations, executedMetricsMigrations };
}
StartTransaction<T>(exec: TX<T>, description?: string) {
return this.txQueue.PushToQueue({ exec, dbTx: true, description })
}
import { DataSource, EntityManager } from "typeorm"
import NewDB, { DbSettings, LoadDbSettingsFromEnv } from "./db.js"
import ProductStorage from './productStorage.js'
import ApplicationStorage from './applicationStorage.js'
import UserStorage from "./userStorage.js";
import PaymentStorage from "./paymentStorage.js";
import MetricsStorage from "./metricsStorage.js";
import TransactionsQueue, { TX } from "./transactionsQueue.js";
import EventsLogManager from "./eventsLog.js";
import { LiquidityStorage } from "./liquidityStorage.js";
export type StorageSettings = {
dbSettings: DbSettings
eventLogPath: string
dataDir: string
}
export const LoadStorageSettingsFromEnv = (): StorageSettings => {
return { dbSettings: LoadDbSettingsFromEnv(), eventLogPath: "logs/eventLogV2.csv", dataDir: process.env.DATA_DIR || "" }
}
export default class {
DB: DataSource | EntityManager
settings: StorageSettings
txQueue: TransactionsQueue
productStorage: ProductStorage
applicationStorage: ApplicationStorage
userStorage: UserStorage
paymentStorage: PaymentStorage
metricsStorage: MetricsStorage
liquidityStorage: LiquidityStorage
eventsLog: EventsLogManager
constructor(settings: StorageSettings) {
this.settings = settings
this.eventsLog = new EventsLogManager(settings.eventLogPath)
}
async Connect(migrations: Function[], metricsMigrations: Function[]) {
const { source, executedMigrations } = await NewDB(this.settings.dbSettings, migrations)
this.DB = source
this.txQueue = new TransactionsQueue("main", this.DB)
this.userStorage = new UserStorage(this.DB, this.txQueue, this.eventsLog)
this.productStorage = new ProductStorage(this.DB, this.txQueue)
this.applicationStorage = new ApplicationStorage(this.DB, this.userStorage, this.txQueue)
this.paymentStorage = new PaymentStorage(this.DB, this.userStorage, this.txQueue)
this.metricsStorage = new MetricsStorage(this.settings)
this.liquidityStorage = new LiquidityStorage(this.DB, this.txQueue)
const executedMetricsMigrations = await this.metricsStorage.Connect(metricsMigrations)
return { executedMigrations, executedMetricsMigrations };
}
StartTransaction<T>(exec: TX<T>, description?: string) {
return this.txQueue.PushToQueue({ exec, dbTx: true, description })
}
}

View file

@ -0,0 +1,20 @@
import { DataSource, EntityManager, MoreThan } from "typeorm"
import { LspOrder } from "./entity/LspOrder.js";
import TransactionsQueue, { TX } from "./transactionsQueue.js";
export class LiquidityStorage {
DB: DataSource | EntityManager
txQueue: TransactionsQueue
constructor(DB: DataSource | EntityManager, txQueue: TransactionsQueue) {
this.DB = DB
this.txQueue = txQueue
}
GetLatestLspOrder() {
return this.DB.getRepository(LspOrder).findOne({ where: { serial_id: MoreThan(0) }, order: { serial_id: "DESC" } })
}
SaveLspOrder(order: Partial<LspOrder>) {
const entry = this.DB.getRepository(LspOrder).create(order)
return this.txQueue.PushToQueue<LspOrder>({ exec: async db => db.getRepository(LspOrder).save(entry), dbTx: false })
}
}

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class LspOrder1718387847693 implements MigrationInterface {
name = 'LspOrder1718387847693'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "lsp_order" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "service_name" varchar NOT NULL, "invoice" varchar NOT NULL, "order_id" varchar NOT NULL, "total_paid" integer NOT NULL, "fees" integer NOT NULL, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "lsp_order"`);
}
}

View file

@ -1,29 +1,30 @@
import { PubLogger } from '../../helpers/logger.js'
import { DbSettings, runFakeMigration } from '../db.js'
import Storage, { StorageSettings } from '../index.js'
import { Initial1703170309875 } from './1703170309875-initial.js'
import { LndMetrics1703170330183 } from './1703170330183-lnd_metrics.js'
import { ChannelRouting1709316653538 } from './1709316653538-channel_routing.js'
const allMigrations = [Initial1703170309875]
const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538]
export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise<boolean> => {
if (arg === 'fake_initial_migration') {
runFakeMigration(settings.databaseFile, [Initial1703170309875])
return true
}
await connectAndMigrate(log, storageManager, allMigrations, allMetricsMigrations)
return false
}
const connectAndMigrate = async (log: PubLogger, storageManager: Storage, migrations: Function[], metricsMigrations: Function[]) => {
const { executedMigrations, executedMetricsMigrations } = await storageManager.Connect(migrations, metricsMigrations)
if (migrations.length > 0) {
log(executedMigrations.length, "of", migrations.length, "migrations were executed correctly")
log(executedMigrations)
log("-------------------")
} if (metricsMigrations.length > 0) {
log(executedMetricsMigrations.length, "of", metricsMigrations.length, "metrics migrations were executed correctly")
log(executedMetricsMigrations)
}
import { PubLogger } from '../../helpers/logger.js'
import { DbSettings, runFakeMigration } from '../db.js'
import Storage, { StorageSettings } from '../index.js'
import { Initial1703170309875 } from './1703170309875-initial.js'
import { LndMetrics1703170330183 } from './1703170330183-lnd_metrics.js'
import { ChannelRouting1709316653538 } from './1709316653538-channel_routing.js'
import { LspOrder1718387847693 } from './1718387847693-lsp_order.js'
const allMigrations = [Initial1703170309875, LspOrder1718387847693]
const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538]
export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise<boolean> => {
if (arg === 'fake_initial_migration') {
runFakeMigration(settings.databaseFile, [Initial1703170309875])
return true
}
await connectAndMigrate(log, storageManager, allMigrations, allMetricsMigrations)
return false
}
const connectAndMigrate = async (log: PubLogger, storageManager: Storage, migrations: Function[], metricsMigrations: Function[]) => {
const { executedMigrations, executedMetricsMigrations } = await storageManager.Connect(migrations, metricsMigrations)
if (migrations.length > 0) {
log(executedMigrations.length, "of", migrations.length, "migrations were executed correctly")
log(executedMigrations)
log("-------------------")
} if (metricsMigrations.length > 0) {
log(executedMetricsMigrations.length, "of", metricsMigrations.length, "metrics migrations were executed correctly")
log(executedMetricsMigrations)
}
}

View file

@ -1,108 +1,108 @@
version: '3.3'
services:
backend1:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 5m
image: polarlightning/bitcoind:26.0
container_name: polar-n2-backend1
hostname: backend1
command: >-
bitcoind -server=1 -regtest=1 -rpcauth=polaruser:5e5e98c21f5c814568f8b55d83b23c1c$$066b03f92df30b11de8e4b1b1cd5b1b4281aa25205bd57df9be82caf97a05526 -debug=1 -zmqpubrawblock=tcp://0.0.0.0:28334 -zmqpubrawtx=tcp://0.0.0.0:28335 -zmqpubhashblock=tcp://0.0.0.0:28336 -txindex=1 -dnsseed=0 -upnp=0 -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 -rpcport=18443 -rest -listen=1 -listenonion=0 -fallbackfee=0.0002 -blockfilterindex=1 -peerblockfilters=1
volumes:
- ./volumes/bitcoind/backend1:/home/bitcoin/.bitcoin
expose:
- '18443'
- '18444'
- '28334'
- '28335'
ports:
- '18443:18443'
- '19444:18444'
- '28334:28334'
- '29335:28335'
alice:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 2m
image: polarlightning/lnd:0.17.3-beta
container_name: polar-n2-alice
hostname: alice
command: >-
lnd --noseedbackup --trickledelay=5000 --alias=alice --externalip=alice --tlsextradomain=alice --tlsextradomain=polar-n2-alice --tlsextradomain=host.docker.internal --listen=0.0.0.0:9735 --rpclisten=0.0.0.0:10009 --restlisten=0.0.0.0:8080 --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind --bitcoind.rpchost=polar-n2-backend1 --bitcoind.rpcuser=polaruser --bitcoind.rpcpass=polarpass --bitcoind.zmqpubrawblock=tcp://polar-n2-backend1:28334 --bitcoind.zmqpubrawtx=tcp://polar-n2-backend1:28335
restart: always
volumes:
- ./volumes/lnd/alice:/home/lnd/.lnd
expose:
- '8080'
- '10009'
- '9735'
ports:
# - '8081:8080'
- '10001:10009'
- '9735:9735'
bob:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 2m
image: polarlightning/lnd:0.17.3-beta
container_name: polar-n2-bob
hostname: bob
command: >-
lnd --noseedbackup --trickledelay=5000 --alias=bob --externalip=bob --tlsextradomain=bob --tlsextradomain=polar-n2-bob --tlsextradomain=host.docker.internal --listen=0.0.0.0:9735 --rpclisten=0.0.0.0:10009 --restlisten=0.0.0.0:8080 --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind --bitcoind.rpchost=polar-n2-backend1 --bitcoind.rpcuser=polaruser --bitcoind.rpcpass=polarpass --bitcoind.zmqpubrawblock=tcp://polar-n2-backend1:28334 --bitcoind.zmqpubrawtx=tcp://polar-n2-backend1:28335
restart: always
volumes:
- ./volumes/lnd/bob:/home/lnd/.lnd
expose:
- '8080'
- '10009'
- '9735'
ports:
# - '8082:8080'
- '10002:10009'
- '9736:9735'
carol:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 2m
image: polarlightning/lnd:0.17.3-beta
container_name: polar-n2-carol
hostname: carol
command: >-
lnd --noseedbackup --trickledelay=5000 --alias=carol --externalip=carol --tlsextradomain=carol --tlsextradomain=polar-n2-carol --tlsextradomain=host.docker.internal --listen=0.0.0.0:9735 --rpclisten=0.0.0.0:10009 --restlisten=0.0.0.0:8080 --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind --bitcoind.rpchost=polar-n2-backend1 --bitcoind.rpcuser=polaruser --bitcoind.rpcpass=polarpass --bitcoind.zmqpubrawblock=tcp://polar-n2-backend1:28334 --bitcoind.zmqpubrawtx=tcp://polar-n2-backend1:28335
restart: always
volumes:
- ./volumes/lnd/carol:/home/lnd/.lnd
expose:
- '8080'
- '10009'
- '9735'
ports:
# - '8083:8080'
- '10003:10009'
- '9737:9735'
dave:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 2m
image: polarlightning/lnd:0.17.3-beta
container_name: polar-n2-dave
hostname: dave
command: >-
lnd --noseedbackup --trickledelay=5000 --alias=dave --externalip=dave --tlsextradomain=dave --tlsextradomain=polar-n2-dave --tlsextradomain=host.docker.internal --listen=0.0.0.0:9735 --rpclisten=0.0.0.0:10009 --restlisten=0.0.0.0:8080 --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind --bitcoind.rpchost=polar-n2-backend1 --bitcoind.rpcuser=polaruser --bitcoind.rpcpass=polarpass --bitcoind.zmqpubrawblock=tcp://polar-n2-backend1:28334 --bitcoind.zmqpubrawtx=tcp://polar-n2-backend1:28335
restart: always
volumes:
- ./volumes/lnd/dave:/home/lnd/.lnd
expose:
- '8080'
- '10009'
- '9735'
ports:
# - '8084:8080'
- '10004:10009'
- '9738:9735'
version: '3.3'
services:
backend1:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 5m
image: polarlightning/bitcoind:26.0
container_name: polar-n2-backend1
hostname: backend1
command: >-
bitcoind -server=1 -regtest=1 -rpcauth=polaruser:5e5e98c21f5c814568f8b55d83b23c1c$$066b03f92df30b11de8e4b1b1cd5b1b4281aa25205bd57df9be82caf97a05526 -debug=1 -zmqpubrawblock=tcp://0.0.0.0:28334 -zmqpubrawtx=tcp://0.0.0.0:28335 -zmqpubhashblock=tcp://0.0.0.0:28336 -txindex=1 -dnsseed=0 -upnp=0 -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 -rpcport=18443 -rest -listen=1 -listenonion=0 -fallbackfee=0.0002 -blockfilterindex=1 -peerblockfilters=1
volumes:
- ./volumes/bitcoind/backend1:/home/bitcoin/.bitcoin
expose:
- '18443'
- '18444'
- '28334'
- '28335'
ports:
- '18443:18443'
- '19444:18444'
- '28334:28334'
- '29335:28335'
alice:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 2m
image: polarlightning/lnd:0.18.0-beta
container_name: polar-n2-alice
hostname: alice
command: >-
lnd --noseedbackup --trickledelay=5000 --alias=alice --externalip=alice --tlsextradomain=alice --tlsextradomain=polar-n2-alice --tlsextradomain=host.docker.internal --listen=0.0.0.0:9735 --rpclisten=0.0.0.0:10009 --restlisten=0.0.0.0:8080 --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind --bitcoind.rpchost=polar-n2-backend1 --bitcoind.rpcuser=polaruser --bitcoind.rpcpass=polarpass --bitcoind.zmqpubrawblock=tcp://polar-n2-backend1:28334 --bitcoind.zmqpubrawtx=tcp://polar-n2-backend1:28335
restart: always
volumes:
- ./volumes/lnd/alice:/home/lnd/.lnd
expose:
- '8080'
- '10009'
- '9735'
ports:
# - '8081:8080'
- '10001:10009'
- '9735:9735'
bob:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 2m
image: polarlightning/lnd:0.18.0-beta
container_name: polar-n2-bob
hostname: bob
command: >-
lnd --noseedbackup --trickledelay=5000 --alias=bob --externalip=bob --tlsextradomain=bob --tlsextradomain=polar-n2-bob --tlsextradomain=host.docker.internal --listen=0.0.0.0:9735 --rpclisten=0.0.0.0:10009 --restlisten=0.0.0.0:8080 --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind --bitcoind.rpchost=polar-n2-backend1 --bitcoind.rpcuser=polaruser --bitcoind.rpcpass=polarpass --bitcoind.zmqpubrawblock=tcp://polar-n2-backend1:28334 --bitcoind.zmqpubrawtx=tcp://polar-n2-backend1:28335
restart: always
volumes:
- ./volumes/lnd/bob:/home/lnd/.lnd
expose:
- '8080'
- '10009'
- '9735'
ports:
# - '8082:8080'
- '10002:10009'
- '9736:9735'
carol:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 2m
image: polarlightning/lnd:0.18.0-beta
container_name: polar-n2-carol
hostname: carol
command: >-
lnd --noseedbackup --trickledelay=5000 --alias=carol --externalip=carol --tlsextradomain=carol --tlsextradomain=polar-n2-carol --tlsextradomain=host.docker.internal --listen=0.0.0.0:9735 --rpclisten=0.0.0.0:10009 --restlisten=0.0.0.0:8080 --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind --bitcoind.rpchost=polar-n2-backend1 --bitcoind.rpcuser=polaruser --bitcoind.rpcpass=polarpass --bitcoind.zmqpubrawblock=tcp://polar-n2-backend1:28334 --bitcoind.zmqpubrawtx=tcp://polar-n2-backend1:28335
restart: always
volumes:
- ./volumes/lnd/carol:/home/lnd/.lnd
expose:
- '8080'
- '10009'
- '9735'
ports:
# - '8083:8080'
- '10003:10009'
- '9737:9735'
dave:
environment:
USERID: ${USERID:-1000}
GROUPID: ${GROUPID:-1000}
stop_grace_period: 2m
image: polarlightning/lnd:0.18.0-beta
container_name: polar-n2-dave
hostname: dave
command: >-
lnd --noseedbackup --trickledelay=5000 --alias=dave --externalip=dave --tlsextradomain=dave --tlsextradomain=polar-n2-dave --tlsextradomain=host.docker.internal --listen=0.0.0.0:9735 --rpclisten=0.0.0.0:10009 --restlisten=0.0.0.0:8080 --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind --bitcoind.rpchost=polar-n2-backend1 --bitcoind.rpcuser=polaruser --bitcoind.rpcpass=polarpass --bitcoind.zmqpubrawblock=tcp://polar-n2-backend1:28334 --bitcoind.zmqpubrawtx=tcp://polar-n2-backend1:28335
restart: always
volumes:
- ./volumes/lnd/dave:/home/lnd/.lnd
expose:
- '8080'
- '10009'
- '9735'
ports:
# - '8084:8080'
- '10004:10009'
- '9738:9735'

View file

@ -1,54 +1,56 @@
import { disableLoggers } from '../services/helpers/logger.js'
import { runSanityCheck, safelySetUserBalance, TestBase, TestUserData } from './testBase.js'
import { initBootstrappedInstance } from './setupBootstrapped.js'
import Main from '../services/main/index.js'
import { AppData } from '../services/main/init.js'
export const ignore = false
export const dev = false
export default async (T: TestBase) => {
disableLoggers([], ["EventsLogManager", "watchdog", "htlcTracker", "debugHtlcs", "debugLndBalancev3", "metrics", "mainForTest", "main"])
await safelySetUserBalance(T, T.user1, 2000)
T.d("starting liquidityProvider tests...")
const { bootstrapped, bootstrappedUser, stop } = await initBootstrappedInstance(T)
await testInboundPaymentFromProvider(T, bootstrapped, bootstrappedUser)
await testOutboundPaymentFromProvider(T, bootstrapped, bootstrappedUser)
stop()
await runSanityCheck(T)
}
const testInboundPaymentFromProvider = async (T: TestBase, bootstrapped: Main, bUser: TestUserData) => {
T.d("starting testInboundPaymentFromProvider")
const invoiceRes = await bootstrapped.appUserManager.NewInvoice({ app_id: bUser.appId, user_id: bUser.userId, app_user_id: bUser.appUserIdentifier }, { amountSats: 2000, memo: "liquidityTest" })
await T.externalAccessToOtherLnd.PayInvoice(invoiceRes.invoice, 0, 100)
const userBalance = await bootstrapped.appUserManager.GetUserInfo({ app_id: bUser.appId, user_id: bUser.userId, app_user_id: bUser.appUserIdentifier })
T.expect(userBalance.balance).to.equal(2000)
const providerBalance = await bootstrapped.liquidProvider.CheckUserState()
if (!providerBalance) {
throw new Error("provider balance not found")
}
T.expect(providerBalance.balance).to.equal(2000)
T.d("testInboundPaymentFromProvider done")
}
const testOutboundPaymentFromProvider = async (T: TestBase, bootstrapped: Main, bootstrappedUser: TestUserData) => {
T.d("starting testOutboundPaymentFromProvider")
const invoice = await T.externalAccessToOtherLnd.NewInvoice(1000, "", 60 * 60)
const ctx = { app_id: bootstrappedUser.appId, user_id: bootstrappedUser.userId, app_user_id: bootstrappedUser.appUserIdentifier }
const res = await bootstrapped.appUserManager.PayInvoice(ctx, { invoice: invoice.payRequest, amount: 0 })
const userBalance = await bootstrapped.appUserManager.GetUserInfo(ctx)
T.expect(userBalance.balance).to.equal(986) // 2000 - (1000 + 6(x2) + 2)
const providerBalance = await bootstrapped.liquidProvider.CheckUserState()
if (!providerBalance) {
throw new Error("provider balance not found")
}
T.expect(providerBalance.balance).to.equal(992) // 2000 - (1000 + 6 +2)
T.d("testOutboundPaymentFromProvider done")
import { disableLoggers } from '../services/helpers/logger.js'
import { runSanityCheck, safelySetUserBalance, TestBase, TestUserData } from './testBase.js'
import { initBootstrappedInstance } from './setupBootstrapped.js'
import Main from '../services/main/index.js'
import { AppData } from '../services/main/init.js'
export const ignore = false
export const dev = false
export default async (T: TestBase) => {
disableLoggers([], ["EventsLogManager", "watchdog", "htlcTracker", "debugHtlcs", "debugLndBalancev3", "metrics", "mainForTest", "main"])
await safelySetUserBalance(T, T.user1, 2000)
T.d("starting liquidityProvider tests...")
const { bootstrapped, bootstrappedUser, stop } = await initBootstrappedInstance(T)
await testInboundPaymentFromProvider(T, bootstrapped, bootstrappedUser)
await testOutboundPaymentFromProvider(T, bootstrapped, bootstrappedUser)
stop()
await runSanityCheck(T)
}
const testInboundPaymentFromProvider = async (T: TestBase, bootstrapped: Main, bUser: TestUserData) => {
T.d("starting testInboundPaymentFromProvider")
const invoiceRes = await bootstrapped.appUserManager.NewInvoice({ app_id: bUser.appId, user_id: bUser.userId, app_user_id: bUser.appUserIdentifier }, { amountSats: 2000, memo: "liquidityTest" })
await T.externalAccessToOtherLnd.PayInvoice(invoiceRes.invoice, 0, 100)
await new Promise((resolve) => setTimeout(resolve, 200))
const userBalance = await bootstrapped.appUserManager.GetUserInfo({ app_id: bUser.appId, user_id: bUser.userId, app_user_id: bUser.appUserIdentifier })
T.expect(userBalance.balance).to.equal(2000)
T.d("user balance is 2000")
const providerBalance = await bootstrapped.liquidProvider.CheckUserState()
if (!providerBalance) {
throw new Error("provider balance not found")
}
T.expect(providerBalance.balance).to.equal(2000)
T.d("provider balance is 2000")
T.d("testInboundPaymentFromProvider done")
}
const testOutboundPaymentFromProvider = async (T: TestBase, bootstrapped: Main, bootstrappedUser: TestUserData) => {
T.d("starting testOutboundPaymentFromProvider")
const invoice = await T.externalAccessToOtherLnd.NewInvoice(1000, "", 60 * 60)
const ctx = { app_id: bootstrappedUser.appId, user_id: bootstrappedUser.userId, app_user_id: bootstrappedUser.appUserIdentifier }
const res = await bootstrapped.appUserManager.PayInvoice(ctx, { invoice: invoice.payRequest, amount: 0 })
const userBalance = await bootstrapped.appUserManager.GetUserInfo(ctx)
T.expect(userBalance.balance).to.equal(986) // 2000 - (1000 + 6(x2) + 2)
const providerBalance = await bootstrapped.liquidProvider.CheckUserState()
if (!providerBalance) {
throw new Error("provider balance not found")
}
T.expect(providerBalance.balance).to.equal(992) // 2000 - (1000 + 6 +2)
T.d("testOutboundPaymentFromProvider done")
}

View file

@ -1,65 +1,65 @@
import { LoadTestSettingsFromEnv } from "../services/main/settings.js"
import { BitcoinCoreWrapper } from "./bitcoinCore.js"
import LND from '../services/lnd/lnd.js'
import { LiquidityProvider } from "../services/lnd/liquidityProvider.js"
export const setupNetwork = async () => {
const settings = LoadTestSettingsFromEnv()
const core = new BitcoinCoreWrapper(settings)
await core.InitAddress()
await core.Mine(1)
const alice = new LND(settings.lndSettings, new LiquidityProvider("", () => { }), () => { }, () => { }, () => { }, () => { })
const bob = new LND({ ...settings.lndSettings, mainNode: settings.lndSettings.otherNode }, new LiquidityProvider("", () => { }), () => { }, () => { }, () => { }, () => { })
await tryUntil<void>(async i => {
const peers = await alice.ListPeers()
if (peers.peers.length > 0) {
return
}
await alice.ConnectPeer({ pubkey: '0232842d81b2423df97aa8a264f8c0811610a736af65afe2e145279f285625c1e4', host: "carol:9735" })
await alice.ConnectPeer({ pubkey: '027c50fde118af534ff27e59da722422d2f3e06505c31e94c1b40c112c48a83b1c', host: "dave:9735" })
}, 15, 2000)
await tryUntil<void>(async i => {
const peers = await bob.ListPeers()
if (peers.peers.length > 0) {
return
}
await bob.ConnectPeer({ pubkey: '0232842d81b2423df97aa8a264f8c0811610a736af65afe2e145279f285625c1e4', host: "carol:9735" })
}, 15, 2000)
await tryUntil<void>(async i => {
const info = await alice.GetInfo()
if (!info.syncedToChain) {
throw new Error("alice not synced to chain")
}
if (!info.syncedToGraph) {
//await lnd.ConnectPeer({})
throw new Error("alice not synced to graph")
}
}, 15, 2000)
await tryUntil<void>(async i => {
const info = await bob.GetInfo()
if (!info.syncedToChain) {
throw new Error("bob not synced to chain")
}
if (!info.syncedToGraph) {
//await lnd.ConnectPeer({})
throw new Error("bob not synced to graph")
}
}, 15, 2000)
alice.Stop()
bob.Stop()
}
const tryUntil = async <T>(fn: (attempt: number) => Promise<T>, maxTries: number, interval: number) => {
for (let i = 0; i < maxTries; i++) {
try {
return await fn(i)
} catch (e) {
console.log("tryUntil error", e)
await new Promise(resolve => setTimeout(resolve, interval))
}
}
throw new Error("tryUntil failed")
import { LoadTestSettingsFromEnv } from "../services/main/settings.js"
import { BitcoinCoreWrapper } from "./bitcoinCore.js"
import LND from '../services/lnd/lnd.js'
import { LiquidityProvider } from "../services/lnd/liquidityProvider.js"
export const setupNetwork = async () => {
const settings = LoadTestSettingsFromEnv()
const core = new BitcoinCoreWrapper(settings)
await core.InitAddress()
await core.Mine(1)
const alice = new LND(settings.lndSettings, { liquidProvider: new LiquidityProvider("", () => { }) }, () => { }, () => { }, () => { }, () => { })
const bob = new LND({ ...settings.lndSettings, mainNode: settings.lndSettings.otherNode }, { liquidProvider: new LiquidityProvider("", () => { }) }, () => { }, () => { }, () => { }, () => { })
await tryUntil<void>(async i => {
const peers = await alice.ListPeers()
if (peers.peers.length > 0) {
return
}
await alice.ConnectPeer({ pubkey: '0232842d81b2423df97aa8a264f8c0811610a736af65afe2e145279f285625c1e4', host: "carol:9735" })
await alice.ConnectPeer({ pubkey: '027c50fde118af534ff27e59da722422d2f3e06505c31e94c1b40c112c48a83b1c', host: "dave:9735" })
}, 15, 2000)
await tryUntil<void>(async i => {
const peers = await bob.ListPeers()
if (peers.peers.length > 0) {
return
}
await bob.ConnectPeer({ pubkey: '0232842d81b2423df97aa8a264f8c0811610a736af65afe2e145279f285625c1e4', host: "carol:9735" })
}, 15, 2000)
await tryUntil<void>(async i => {
const info = await alice.GetInfo()
if (!info.syncedToChain) {
throw new Error("alice not synced to chain")
}
if (!info.syncedToGraph) {
//await lnd.ConnectPeer({})
throw new Error("alice not synced to graph")
}
}, 15, 2000)
await tryUntil<void>(async i => {
const info = await bob.GetInfo()
if (!info.syncedToChain) {
throw new Error("bob not synced to chain")
}
if (!info.syncedToGraph) {
//await lnd.ConnectPeer({})
throw new Error("bob not synced to graph")
}
}, 15, 2000)
alice.Stop()
bob.Stop()
}
const tryUntil = async <T>(fn: (attempt: number) => Promise<T>, maxTries: number, interval: number) => {
for (let i = 0; i < maxTries; i++) {
try {
return await fn(i)
} catch (e) {
console.log("tryUntil error", e)
await new Promise(resolve => setTimeout(resolve, interval))
}
}
throw new Error("tryUntil failed")
}

View file

@ -1,92 +1,92 @@
import { getLogger } from '../services/helpers/logger.js'
import { initMainHandler } from '../services/main/init.js'
import { LoadTestSettingsFromEnv } from '../services/main/settings.js'
import { SendData } from '../services/nostr/handler.js'
import { TestBase, TestUserData } from './testBase.js'
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.mainNode = settings.lndSettings.thirdNode
const initialized = await initMainHandler(getLogger({ component: "bootstrapped" }), settings)
if (!initialized) {
throw new Error("failed to initialize bootstrapped main handler")
}
const { mainHandler: bootstrapped, liquidityProviderInfo, liquidityProviderApp } = initialized
T.main.attachNostrSend(async (_, data, r) => {
if (data.type === 'event') {
throw new Error("unsupported event type")
}
if (data.pub !== liquidityProviderInfo.publicKey) {
throw new Error("invalid pub " + data.pub + " expected " + liquidityProviderInfo.publicKey)
}
const j = JSON.parse(data.content) as { requestId: string }
console.log("sending new operation to provider")
bootstrapped.liquidProvider.onEvent(j, T.app.publicKey)
})
bootstrapped.liquidProvider.attachNostrSend(async (_, data, r) => {
const res = await handleSend(T, data)
if (data.type === 'event') {
throw new Error("unsupported event type")
}
if (!res) {
return
}
bootstrapped.liquidProvider.onEvent(res, data.pub)
})
bootstrapped.liquidProvider.setNostrInfo({ clientId: liquidityProviderInfo.clientId, myPub: liquidityProviderInfo.publicKey })
await new Promise<void>(res => {
const interval = setInterval(() => {
if (bootstrapped.liquidProvider.CanProviderHandle({ action: 'receive', amount: 2000 })) {
clearInterval(interval)
res()
} else {
console.log("waiting for provider to be able to handle the request")
}
}, 500)
})
const bUser = await bootstrapped.applicationManager.AddAppUser(liquidityProviderApp.appId, { identifier: "user1_bootstrapped", balance: 0, fail_if_exists: true })
const bootstrappedUser: TestUserData = { userId: bUser.info.userId, appUserIdentifier: bUser.identifier, appId: liquidityProviderApp.appId }
return {
bootstrapped, liquidityProviderInfo, liquidityProviderApp, bootstrappedUser, stop: () => {
bootstrapped.Stop()
}
}
}
type TransportRequest = { requestId: string, authIdentifier: string } & (
{ rpcName: 'GetUserInfo' } |
{ rpcName: 'NewInvoice', body: Types.NewInvoiceRequest } |
{ rpcName: 'PayInvoice', body: Types.PayInvoiceRequest } |
{ rpcName: 'GetLiveUserOperations' } |
{ rpcName: "" }
)
const handleSend = async (T: TestBase, data: SendData) => {
if (data.type === 'event') {
throw new Error("unsupported event type")
}
if (data.pub !== T.app.publicKey) {
throw new Error("invalid pub")
}
const j = JSON.parse(data.content) as TransportRequest
const app = await T.main.storage.applicationStorage.GetApplication(T.app.appId)
const nostrUser = await T.main.storage.applicationStorage.GetOrCreateNostrAppUser(app, j.authIdentifier)
const userCtx = { app_id: app.app_id, user_id: nostrUser.user.user_id, app_user_id: nostrUser.identifier }
switch (j.rpcName) {
case 'GetUserInfo':
const infoRes = await T.main.appUserManager.GetUserInfo(userCtx)
return { ...infoRes, status: "OK", requestId: j.requestId }
case 'NewInvoice':
const genInvoiceRes = await T.main.appUserManager.NewInvoice(userCtx, j.body)
return { ...genInvoiceRes, status: "OK", requestId: j.requestId }
case 'PayInvoice':
const payRes = await T.main.appUserManager.PayInvoice(userCtx, j.body)
return { ...payRes, status: "OK", requestId: j.requestId }
case 'GetLiveUserOperations':
return
default:
console.log(data)
throw new Error("unsupported rpcName " + j.rpcName)
}
import { getLogger } from '../services/helpers/logger.js'
import { initMainHandler } from '../services/main/init.js'
import { LoadTestSettingsFromEnv } from '../services/main/settings.js'
import { SendData } from '../services/nostr/handler.js'
import { TestBase, TestUserData } from './testBase.js'
import * as Types from '../../proto/autogenerated/ts/types.js'
export const initBootstrappedInstance = async (T: TestBase) => {
const settings = LoadTestSettingsFromEnv()
settings.liquiditySettings.useOnlyLiquidityProvider = true
settings.liquiditySettings.liquidityProviderPub = T.app.publicKey
settings.lndSettings.mainNode = settings.lndSettings.thirdNode
const initialized = await initMainHandler(getLogger({ component: "bootstrapped" }), settings)
if (!initialized) {
throw new Error("failed to initialize bootstrapped main handler")
}
const { mainHandler: bootstrapped, liquidityProviderInfo, liquidityProviderApp } = initialized
T.main.attachNostrSend(async (_, data, r) => {
if (data.type === 'event') {
throw new Error("unsupported event type")
}
if (data.pub !== liquidityProviderInfo.publicKey) {
throw new Error("invalid pub " + data.pub + " expected " + liquidityProviderInfo.publicKey)
}
const j = JSON.parse(data.content) as { requestId: string }
console.log("sending new operation to provider")
bootstrapped.liquidProvider.onEvent(j, T.app.publicKey)
})
bootstrapped.liquidProvider.attachNostrSend(async (_, data, r) => {
const res = await handleSend(T, data)
if (data.type === 'event') {
throw new Error("unsupported event type")
}
if (!res) {
return
}
bootstrapped.liquidProvider.onEvent(res, data.pub)
})
bootstrapped.liquidProvider.setNostrInfo({ clientId: liquidityProviderInfo.clientId, myPub: liquidityProviderInfo.publicKey })
await new Promise<void>(res => {
const interval = setInterval(() => {
if (bootstrapped.liquidProvider.CanProviderHandle({ action: 'receive', amount: 2000 })) {
clearInterval(interval)
res()
} else {
console.log("waiting for provider to be able to handle the request")
}
}, 500)
})
const bUser = await bootstrapped.applicationManager.AddAppUser(liquidityProviderApp.appId, { identifier: "user1_bootstrapped", balance: 0, fail_if_exists: true })
const bootstrappedUser: TestUserData = { userId: bUser.info.userId, appUserIdentifier: bUser.identifier, appId: liquidityProviderApp.appId }
return {
bootstrapped, liquidityProviderInfo, liquidityProviderApp, bootstrappedUser, stop: () => {
bootstrapped.Stop()
}
}
}
type TransportRequest = { requestId: string, authIdentifier: string } & (
{ rpcName: 'GetUserInfo' } |
{ rpcName: 'NewInvoice', body: Types.NewInvoiceRequest } |
{ rpcName: 'PayInvoice', body: Types.PayInvoiceRequest } |
{ rpcName: 'GetLiveUserOperations' } |
{ rpcName: "" }
)
const handleSend = async (T: TestBase, data: SendData) => {
if (data.type === 'event') {
throw new Error("unsupported event type")
}
if (data.pub !== T.app.publicKey) {
throw new Error("invalid pub")
}
const j = JSON.parse(data.content) as TransportRequest
const app = await T.main.storage.applicationStorage.GetApplication(T.app.appId)
const nostrUser = await T.main.storage.applicationStorage.GetOrCreateNostrAppUser(app, j.authIdentifier)
const userCtx = { app_id: app.app_id, user_id: nostrUser.user.user_id, app_user_id: nostrUser.identifier }
switch (j.rpcName) {
case 'GetUserInfo':
const infoRes = await T.main.appUserManager.GetUserInfo(userCtx)
return { ...infoRes, status: "OK", requestId: j.requestId }
case 'NewInvoice':
const genInvoiceRes = await T.main.appUserManager.NewInvoice(userCtx, j.body)
return { ...genInvoiceRes, status: "OK", requestId: j.requestId }
case 'PayInvoice':
const payRes = await T.main.appUserManager.PayInvoice(userCtx, j.body)
return { ...payRes, status: "OK", requestId: j.requestId }
case 'GetLiveUserOperations':
return
default:
console.log(data)
throw new Error("unsupported rpcName " + j.rpcName)
}
}

View file

@ -1,105 +1,105 @@
import 'dotenv/config' // TODO - test env
import chai from 'chai'
import { AppData, initMainHandler } from '../services/main/init.js'
import Main from '../services/main/index.js'
import Storage from '../services/storage/index.js'
import { User } from '../services/storage/entity/User.js'
import { LoadMainSettingsFromEnv, LoadTestSettingsFromEnv, MainSettings } from '../services/main/settings.js'
import chaiString from 'chai-string'
import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js'
import SanityChecker from '../services/main/sanityChecker.js'
import LND from '../services/lnd/lnd.js'
import { getLogger, resetDisabledLoggers } from '../services/helpers/logger.js'
import { LiquidityProvider } from '../services/lnd/liquidityProvider.js'
chai.use(chaiString)
export const expect = chai.expect
export type Describe = (message: string, failure?: boolean) => void
export type TestUserData = {
userId: string;
appUserIdentifier: string;
appId: string;
}
export type TestBase = {
expect: Chai.ExpectStatic;
main: Main
app: AppData
user1: TestUserData
user2: TestUserData
externalAccessToMainLnd: LND
externalAccessToOtherLnd: LND
externalAccessToThirdLnd: LND
d: Describe
}
export const SetupTest = async (d: Describe): Promise<TestBase> => {
const settings = LoadTestSettingsFromEnv()
const initialized = await initMainHandler(getLogger({ component: "mainForTest" }), settings)
if (!initialized) {
throw new Error("failed to initialize main handler")
}
const main = initialized.mainHandler
const app = initialized.apps[0]
const u1 = await main.applicationManager.AddAppUser(app.appId, { identifier: "user1", balance: 0, fail_if_exists: true })
const u2 = await main.applicationManager.AddAppUser(app.appId, { identifier: "user2", balance: 0, fail_if_exists: true })
const user1 = { userId: u1.info.userId, appUserIdentifier: u1.identifier, appId: app.appId }
const user2 = { userId: u2.info.userId, appUserIdentifier: u2.identifier, appId: app.appId }
const externalAccessToMainLnd = new LND(settings.lndSettings, new LiquidityProvider("", () => { }), console.log, console.log, () => { }, () => { })
await externalAccessToMainLnd.Warmup()
const otherLndSetting = { ...settings.lndSettings, mainNode: settings.lndSettings.otherNode }
const externalAccessToOtherLnd = new LND(otherLndSetting, new LiquidityProvider("", () => { }), console.log, console.log, () => { }, () => { })
await externalAccessToOtherLnd.Warmup()
const thirdLndSetting = { ...settings.lndSettings, mainNode: settings.lndSettings.thirdNode }
const externalAccessToThirdLnd = new LND(thirdLndSetting, new LiquidityProvider("", () => { }), console.log, console.log, () => { }, () => { })
await externalAccessToThirdLnd.Warmup()
return {
expect, main, app,
user1, user2,
externalAccessToMainLnd, externalAccessToOtherLnd, externalAccessToThirdLnd,
d
}
}
export const teardown = async (T: TestBase) => {
T.main.Stop()
T.externalAccessToMainLnd.Stop()
T.externalAccessToOtherLnd.Stop()
T.externalAccessToThirdLnd.Stop()
resetDisabledLoggers()
console.log("teardown")
}
export const safelySetUserBalance = async (T: TestBase, user: TestUserData, amount: number) => {
const app = await T.main.storage.applicationStorage.GetApplication(user.appId)
const invoice = await T.main.paymentManager.NewInvoice(user.userId, { amountSats: amount, memo: "test" }, { linkedApplication: app, expiry: defaultInvoiceExpiry })
await T.externalAccessToOtherLnd.PayInvoice(invoice.invoice, 0, 100)
const u = await T.main.storage.userStorage.GetUser(user.userId)
expect(u.balance_sats).to.be.equal(amount)
T.d(`user ${user.appUserIdentifier} balance is now ${amount}`)
}
export const runSanityCheck = async (T: TestBase) => {
const sanityChecker = new SanityChecker(T.main.storage, T.main.lnd)
await sanityChecker.VerifyEventsLog()
}
export const expectThrowsAsync = async (promise: Promise<any>, errorMessage?: string) => {
let error: Error | null = null
try {
await promise
}
catch (err: any) {
error = err as Error
}
expect(error).to.be.an('Error')
console.log(error!.message)
if (errorMessage) {
expect(error!.message).to.equal(errorMessage)
}
import 'dotenv/config' // TODO - test env
import chai from 'chai'
import { AppData, initMainHandler } from '../services/main/init.js'
import Main from '../services/main/index.js'
import Storage from '../services/storage/index.js'
import { User } from '../services/storage/entity/User.js'
import { LoadMainSettingsFromEnv, LoadTestSettingsFromEnv, MainSettings } from '../services/main/settings.js'
import chaiString from 'chai-string'
import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js'
import SanityChecker from '../services/main/sanityChecker.js'
import LND from '../services/lnd/lnd.js'
import { getLogger, resetDisabledLoggers } from '../services/helpers/logger.js'
import { LiquidityProvider } from '../services/lnd/liquidityProvider.js'
chai.use(chaiString)
export const expect = chai.expect
export type Describe = (message: string, failure?: boolean) => void
export type TestUserData = {
userId: string;
appUserIdentifier: string;
appId: string;
}
export type TestBase = {
expect: Chai.ExpectStatic;
main: Main
app: AppData
user1: TestUserData
user2: TestUserData
externalAccessToMainLnd: LND
externalAccessToOtherLnd: LND
externalAccessToThirdLnd: LND
d: Describe
}
export const SetupTest = async (d: Describe): Promise<TestBase> => {
const settings = LoadTestSettingsFromEnv()
const initialized = await initMainHandler(getLogger({ component: "mainForTest" }), settings)
if (!initialized) {
throw new Error("failed to initialize main handler")
}
const main = initialized.mainHandler
const app = initialized.apps[0]
const u1 = await main.applicationManager.AddAppUser(app.appId, { identifier: "user1", balance: 0, fail_if_exists: true })
const u2 = await main.applicationManager.AddAppUser(app.appId, { identifier: "user2", balance: 0, fail_if_exists: true })
const user1 = { userId: u1.info.userId, appUserIdentifier: u1.identifier, appId: app.appId }
const user2 = { userId: u2.info.userId, appUserIdentifier: u2.identifier, appId: app.appId }
const externalAccessToMainLnd = new LND(settings.lndSettings, { liquidProvider: new LiquidityProvider("", () => { }) }, console.log, console.log, () => { }, () => { })
await externalAccessToMainLnd.Warmup()
const otherLndSetting = { ...settings.lndSettings, mainNode: settings.lndSettings.otherNode }
const externalAccessToOtherLnd = new LND(otherLndSetting, { liquidProvider: new LiquidityProvider("", () => { }) }, console.log, console.log, () => { }, () => { })
await externalAccessToOtherLnd.Warmup()
const thirdLndSetting = { ...settings.lndSettings, mainNode: settings.lndSettings.thirdNode }
const externalAccessToThirdLnd = new LND(thirdLndSetting, { liquidProvider: new LiquidityProvider("", () => { }) }, console.log, console.log, () => { }, () => { })
await externalAccessToThirdLnd.Warmup()
return {
expect, main, app,
user1, user2,
externalAccessToMainLnd, externalAccessToOtherLnd, externalAccessToThirdLnd,
d
}
}
export const teardown = async (T: TestBase) => {
T.main.Stop()
T.externalAccessToMainLnd.Stop()
T.externalAccessToOtherLnd.Stop()
T.externalAccessToThirdLnd.Stop()
resetDisabledLoggers()
console.log("teardown")
}
export const safelySetUserBalance = async (T: TestBase, user: TestUserData, amount: number) => {
const app = await T.main.storage.applicationStorage.GetApplication(user.appId)
const invoice = await T.main.paymentManager.NewInvoice(user.userId, { amountSats: amount, memo: "test" }, { linkedApplication: app, expiry: defaultInvoiceExpiry })
await T.externalAccessToOtherLnd.PayInvoice(invoice.invoice, 0, 100)
const u = await T.main.storage.userStorage.GetUser(user.userId)
expect(u.balance_sats).to.be.equal(amount)
T.d(`user ${user.appUserIdentifier} balance is now ${amount}`)
}
export const runSanityCheck = async (T: TestBase) => {
const sanityChecker = new SanityChecker(T.main.storage, T.main.lnd)
await sanityChecker.VerifyEventsLog()
}
export const expectThrowsAsync = async (promise: Promise<any>, errorMessage?: string) => {
let error: Error | null = null
try {
await promise
}
catch (err: any) {
error = err as Error
}
expect(error).to.be.an('Error')
console.log(error!.message)
if (errorMessage) {
expect(error!.message).to.equal(errorMessage)
}
}