From c8ede119d6cc17074ca2642fb779123d19bb7a68 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 19 Nov 2025 15:46:35 +0000 Subject: [PATCH] payment stream + unreachable provider + fess calc fix --- proto/autogenerated/client.md | 17 ++++ proto/autogenerated/go/http_client.go | 2 + proto/autogenerated/go/types.go | 23 +++++- proto/autogenerated/ts/http_client.ts | 1 + proto/autogenerated/ts/nostr_client.ts | 16 ++++ proto/autogenerated/ts/nostr_transport.ts | 16 ++++ proto/autogenerated/ts/types.ts | 78 +++++++++++++++++- proto/service/methods.proto | 7 ++ proto/service/structs.proto | 9 +++ src/services/lnd/lnd.ts | 8 +- src/services/main/appUserManager.ts | 23 ++++-- src/services/main/applicationManager.ts | 28 ++++--- src/services/main/liquidityManager.ts | 25 ++++-- src/services/main/liquidityProvider.ts | 98 ++++++++++++++++++----- src/services/main/paymentManager.ts | 71 +++++++++++++--- src/services/serverMethods/index.ts | 7 ++ 16 files changed, 365 insertions(+), 64 deletions(-) diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index a4116e6c..5bad6c03 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -275,6 +275,11 @@ The nostr server will send back a message response, and inside the body there wi - input: [PayInvoiceRequest](#PayInvoiceRequest) - output: [PayInvoiceResponse](#PayInvoiceResponse) +- PayInvoiceStream + - auth type: __User__ + - input: [PayInvoiceRequest](#PayInvoiceRequest) + - output: [InvoicePaymentStream](#InvoicePaymentStream) + - PingSubProcesses - auth type: __Metrics__ - This methods has an __empty__ __request__ body @@ -860,6 +865,13 @@ The nostr server will send back a message response, and inside the body there wi - input: [PayInvoiceRequest](#PayInvoiceRequest) - output: [PayInvoiceResponse](#PayInvoiceResponse) +- PayInvoiceStream + - auth type: __User__ + - http method: __post__ + - http route: __/api/user/invoice/pay/stream__ + - input: [PayInvoiceRequest](#PayInvoiceRequest) + - output: [InvoicePaymentStream](#InvoicePaymentStream) + - PingSubProcesses - auth type: __Metrics__ - http method: __post__ @@ -1256,6 +1268,9 @@ The nostr server will send back a message response, and inside the body there wi - __token__: _string_ - __url__: _string_ +### InvoicePaymentStream + - __update__: _[InvoicePaymentStream_update](#InvoicePaymentStream_update)_ + ### LatestBundleMetricReq - __limit__: _number_ *this field is optional @@ -1460,12 +1475,14 @@ The nostr server will send back a message response, and inside the body there wi ### PayAppUserInvoiceRequest - __amount__: _number_ - __debit_npub__: _string_ *this field is optional + - __fee_limit_sats__: _number_ *this field is optional - __invoice__: _string_ - __user_identifier__: _string_ ### PayInvoiceRequest - __amount__: _number_ - __debit_npub__: _string_ *this field is optional + - __fee_limit_sats__: _number_ *this field is optional - __invoice__: _string_ ### PayInvoiceResponse diff --git a/proto/autogenerated/go/http_client.go b/proto/autogenerated/go/http_client.go index 8c20a0a6..7072488f 100644 --- a/proto/autogenerated/go/http_client.go +++ b/proto/autogenerated/go/http_client.go @@ -121,6 +121,7 @@ type Client struct { PayAddress func(req PayAddressRequest) (*PayAddressResponse, error) PayAppUserInvoice func(req PayAppUserInvoiceRequest) (*PayInvoiceResponse, error) PayInvoice func(req PayInvoiceRequest) (*PayInvoiceResponse, error) + PayInvoiceStream func(req PayInvoiceRequest) (*InvoicePaymentStream, error) PingSubProcesses func() error RequestNPubLinkingToken func(req RequestNPubLinkingTokenRequest) (*RequestNPubLinkingTokenResponse, error) ResetDebit func(req DebitOperation) error @@ -1835,6 +1836,7 @@ func NewClient(params ClientParams) *Client { } return &res, nil }, + // server streaming method: PayInvoiceStream not implemented PingSubProcesses: func() error { auth, err := params.RetrieveMetricsAuth() if err != nil { diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index e8e90df5..7a744c79 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -341,6 +341,9 @@ type HttpCreds struct { Token string `json:"token"` Url string `json:"url"` } +type InvoicePaymentStream struct { + Update *InvoicePaymentStream_update `json:"update"` +} type LatestBundleMetricReq struct { Limit int64 `json:"limit"` } @@ -545,13 +548,15 @@ type PayAddressResponse struct { type PayAppUserInvoiceRequest struct { Amount int64 `json:"amount"` Debit_npub string `json:"debit_npub"` + Fee_limit_sats int64 `json:"fee_limit_sats"` Invoice string `json:"invoice"` User_identifier string `json:"user_identifier"` } type PayInvoiceRequest struct { - Amount int64 `json:"amount"` - Debit_npub string `json:"debit_npub"` - Invoice string `json:"invoice"` + Amount int64 `json:"amount"` + Debit_npub string `json:"debit_npub"` + Fee_limit_sats int64 `json:"fee_limit_sats"` + Invoice string `json:"invoice"` } type PayInvoiceResponse struct { Amount_paid int64 `json:"amount_paid"` @@ -751,6 +756,18 @@ type DebitRule_rule struct { Expiration_rule *DebitExpirationRule `json:"expiration_rule"` Frequency_rule *FrequencyRule `json:"frequency_rule"` } +type InvoicePaymentStream_update_type string + +const ( + ACK InvoicePaymentStream_update_type = "ack" + DONE InvoicePaymentStream_update_type = "done" +) + +type InvoicePaymentStream_update struct { + Type InvoicePaymentStream_update_type `json:"type"` + Ack *Empty `json:"ack"` + Done *PayInvoiceResponse `json:"done"` +} type LiveDebitRequest_debit_type string const ( diff --git a/proto/autogenerated/ts/http_client.ts b/proto/autogenerated/ts/http_client.ts index 9c37e1b3..66f27b92 100644 --- a/proto/autogenerated/ts/http_client.ts +++ b/proto/autogenerated/ts/http_client.ts @@ -881,6 +881,7 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, + PayInvoiceStream: async (request: Types.PayInvoiceRequest, cb: (v:ResultError | ({ status: 'OK' }& Types.InvoicePaymentStream)) => void): Promise => { throw new Error('http streams are not supported')}, PingSubProcesses: async (): Promise => { const auth = await params.retrieveMetricsAuth() if (auth === null) throw new Error('retrieveMetricsAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_client.ts b/proto/autogenerated/ts/nostr_client.ts index 38795d01..002561bb 100644 --- a/proto/autogenerated/ts/nostr_client.ts +++ b/proto/autogenerated/ts/nostr_client.ts @@ -755,6 +755,22 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, + PayInvoiceStream: async (request: Types.PayInvoiceRequest, cb: (res:ResultError | ({ status: 'OK' }& Types.InvoicePaymentStream)) => void): Promise => { + const auth = await params.retrieveNostrUserAuth() + if (auth === null) throw new Error('retrieveNostrUserAuth() returned null') + const nostrRequest: NostrRequest = {} + nostrRequest.body = request + subscribe(params.pubDestination, {rpcName:'PayInvoiceStream',authIdentifier:auth, ...nostrRequest }, (data) => { + if (data.status === 'ERROR' && typeof data.reason === 'string') return cb(data) + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return cb({ status: 'OK', ...result }) + const error = Types.InvoicePaymentStreamValidate(result) + if (error === null) { return cb({ status: 'OK', ...result }) } else return cb({ status: 'ERROR', reason: error.message }) + } + return cb({ status: 'ERROR', reason: 'invalid response' }) + }) + }, PingSubProcesses: async (): Promise => { const auth = await params.retrieveNostrMetricsAuth() if (auth === null) throw new Error('retrieveNostrMetricsAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_transport.ts b/proto/autogenerated/ts/nostr_transport.ts index cd9e708f..c0d08bd0 100644 --- a/proto/autogenerated/ts/nostr_transport.ts +++ b/proto/autogenerated/ts/nostr_transport.ts @@ -1190,6 +1190,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => { opts.metricsCallback([{ ...info, ...stats, ...authContext }]) }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } break + case 'PayInvoiceStream': + try { + if (!methods.PayInvoiceStream) throw new Error('method: PayInvoiceStream is not implemented') + const authContext = await opts.NostrUserAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + const request = req.body + const error = Types.PayInvoiceRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback) + methods.PayInvoiceStream({rpcName:'PayInvoiceStream', ctx:authContext , req: request ,cb: (response, err) => { + stats.handle = process.hrtime.bigint() + if (err) { logErrorAndReturnResponse(err, err.message, res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback)} else { res({status: 'OK', ...response});opts.metricsCallback([{ ...info, ...stats, ...authContext }])} + }}) + }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } + break case 'PingSubProcesses': try { if (!methods.PingSubProcesses) throw new Error('method: PingSubProcesses is not implemented') diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index 239d0686..921f9a82 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -262,6 +262,9 @@ export type PayAppUserInvoice_Output = ResultError | ({ status: 'OK' } & PayInvo export type PayInvoice_Input = {rpcName:'PayInvoice', req: PayInvoiceRequest} export type PayInvoice_Output = ResultError | ({ status: 'OK' } & PayInvoiceResponse) +export type PayInvoiceStream_Input = {rpcName:'PayInvoiceStream', req: PayInvoiceRequest, cb:(res: InvoicePaymentStream, err:Error|null)=> void} +export type PayInvoiceStream_Output = ResultError | { status: 'OK' } + export type PingSubProcesses_Input = {rpcName:'PingSubProcesses'} export type PingSubProcesses_Output = ResultError | { status: 'OK' } @@ -389,6 +392,7 @@ export type ServerMethods = { PayAddress?: (req: PayAddress_Input & {ctx: UserContext }) => Promise PayAppUserInvoice?: (req: PayAppUserInvoice_Input & {ctx: AppContext }) => Promise PayInvoice?: (req: PayInvoice_Input & {ctx: UserContext }) => Promise + PayInvoiceStream?: (req: PayInvoiceStream_Input & {ctx: UserContext }) => Promise PingSubProcesses?: (req: PingSubProcesses_Input & {ctx: MetricsContext }) => Promise RequestNPubLinkingToken?: (req: RequestNPubLinkingToken_Input & {ctx: AppContext }) => Promise ResetDebit?: (req: ResetDebit_Input & {ctx: UserContext }) => Promise @@ -1980,6 +1984,25 @@ export const HttpCredsValidate = (o?: HttpCreds, opts: HttpCredsOptions = {}, pa return null } +export type InvoicePaymentStream = { + update: InvoicePaymentStream_update +} +export const InvoicePaymentStreamOptionalFields: [] = [] +export type InvoicePaymentStreamOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + update_Options?: InvoicePaymentStream_updateOptions +} +export const InvoicePaymentStreamValidate = (o?: InvoicePaymentStream, opts: InvoicePaymentStreamOptions = {}, path: string = 'InvoicePaymentStream::root.'): Error | null => { + if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') + if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + + const updateErr = InvoicePaymentStream_updateValidate(o.update, opts.update_Options, `${path}.update`) + if (updateErr !== null) return updateErr + + + return null +} + export type LatestBundleMetricReq = { limit?: number } @@ -3192,15 +3215,17 @@ export const PayAddressResponseValidate = (o?: PayAddressResponse, opts: PayAddr export type PayAppUserInvoiceRequest = { amount: number debit_npub?: string + fee_limit_sats?: number invoice: string user_identifier: string } -export type PayAppUserInvoiceRequestOptionalField = 'debit_npub' -export const PayAppUserInvoiceRequestOptionalFields: PayAppUserInvoiceRequestOptionalField[] = ['debit_npub'] +export type PayAppUserInvoiceRequestOptionalField = 'debit_npub' | 'fee_limit_sats' +export const PayAppUserInvoiceRequestOptionalFields: PayAppUserInvoiceRequestOptionalField[] = ['debit_npub', 'fee_limit_sats'] export type PayAppUserInvoiceRequestOptions = OptionsBaseMessage & { checkOptionalsAreSet?: PayAppUserInvoiceRequestOptionalField[] amount_CustomCheck?: (v: number) => boolean debit_npub_CustomCheck?: (v?: string) => boolean + fee_limit_sats_CustomCheck?: (v?: number) => boolean invoice_CustomCheck?: (v: string) => boolean user_identifier_CustomCheck?: (v: string) => boolean } @@ -3214,6 +3239,9 @@ export const PayAppUserInvoiceRequestValidate = (o?: PayAppUserInvoiceRequest, o if ((o.debit_npub || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('debit_npub')) && typeof o.debit_npub !== 'string') return new Error(`${path}.debit_npub: is not a string`) if (opts.debit_npub_CustomCheck && !opts.debit_npub_CustomCheck(o.debit_npub)) return new Error(`${path}.debit_npub: custom check failed`) + if ((o.fee_limit_sats || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('fee_limit_sats')) && typeof o.fee_limit_sats !== 'number') return new Error(`${path}.fee_limit_sats: is not a number`) + if (opts.fee_limit_sats_CustomCheck && !opts.fee_limit_sats_CustomCheck(o.fee_limit_sats)) return new Error(`${path}.fee_limit_sats: custom check failed`) + if (typeof o.invoice !== 'string') return new Error(`${path}.invoice: is not a string`) if (opts.invoice_CustomCheck && !opts.invoice_CustomCheck(o.invoice)) return new Error(`${path}.invoice: custom check failed`) @@ -3226,14 +3254,16 @@ export const PayAppUserInvoiceRequestValidate = (o?: PayAppUserInvoiceRequest, o export type PayInvoiceRequest = { amount: number debit_npub?: string + fee_limit_sats?: number invoice: string } -export type PayInvoiceRequestOptionalField = 'debit_npub' -export const PayInvoiceRequestOptionalFields: PayInvoiceRequestOptionalField[] = ['debit_npub'] +export type PayInvoiceRequestOptionalField = 'debit_npub' | 'fee_limit_sats' +export const PayInvoiceRequestOptionalFields: PayInvoiceRequestOptionalField[] = ['debit_npub', 'fee_limit_sats'] export type PayInvoiceRequestOptions = OptionsBaseMessage & { checkOptionalsAreSet?: PayInvoiceRequestOptionalField[] amount_CustomCheck?: (v: number) => boolean debit_npub_CustomCheck?: (v?: string) => boolean + fee_limit_sats_CustomCheck?: (v?: number) => boolean invoice_CustomCheck?: (v: string) => boolean } export const PayInvoiceRequestValidate = (o?: PayInvoiceRequest, opts: PayInvoiceRequestOptions = {}, path: string = 'PayInvoiceRequest::root.'): Error | null => { @@ -3246,6 +3276,9 @@ export const PayInvoiceRequestValidate = (o?: PayInvoiceRequest, opts: PayInvoic if ((o.debit_npub || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('debit_npub')) && typeof o.debit_npub !== 'string') return new Error(`${path}.debit_npub: is not a string`) if (opts.debit_npub_CustomCheck && !opts.debit_npub_CustomCheck(o.debit_npub)) return new Error(`${path}.debit_npub: custom check failed`) + if ((o.fee_limit_sats || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('fee_limit_sats')) && typeof o.fee_limit_sats !== 'number') return new Error(`${path}.fee_limit_sats: is not a number`) + if (opts.fee_limit_sats_CustomCheck && !opts.fee_limit_sats_CustomCheck(o.fee_limit_sats)) return new Error(`${path}.fee_limit_sats: custom check failed`) + if (typeof o.invoice !== 'string') return new Error(`${path}.invoice: is not a string`) if (opts.invoice_CustomCheck && !opts.invoice_CustomCheck(o.invoice)) return new Error(`${path}.invoice: custom check failed`) @@ -4322,6 +4355,43 @@ export const DebitRule_ruleValidate = (o?: DebitRule_rule, opts:DebitRule_ruleOp if (frequency_ruleErr !== null) return frequency_ruleErr + break + default: + return new Error(path + ': unknown type '+ stringType) + } + return null +} +export enum InvoicePaymentStream_update_type { + ACK = 'ack', + DONE = 'done', +} +export const enumCheckInvoicePaymentStream_update_type = (e?: InvoicePaymentStream_update_type): boolean => { + for (const v in InvoicePaymentStream_update_type) if (e === v) return true + return false +} +export type InvoicePaymentStream_update = + {type:InvoicePaymentStream_update_type.ACK, ack:Empty}| + {type:InvoicePaymentStream_update_type.DONE, done:PayInvoiceResponse} + +export type InvoicePaymentStream_updateOptions = { + ack_Options?: EmptyOptions + done_Options?: PayInvoiceResponseOptions +} +export const InvoicePaymentStream_updateValidate = (o?: InvoicePaymentStream_update, opts:InvoicePaymentStream_updateOptions = {}, path: string = 'InvoicePaymentStream_update::root.'): Error | null => { + if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + const stringType: string = o.type + switch (o.type) { + case InvoicePaymentStream_update_type.ACK: + const ackErr = EmptyValidate(o.ack, opts.ack_Options, `${path}.ack`) + if (ackErr !== null) return ackErr + + + break + case InvoicePaymentStream_update_type.DONE: + const doneErr = PayInvoiceResponseValidate(o.done, opts.done_Options, `${path}.done`) + if (doneErr !== null) return doneErr + + break default: return new Error(path + ': unknown type '+ stringType) diff --git a/proto/service/methods.proto b/proto/service/methods.proto index 4cff7379..eb253572 100644 --- a/proto/service/methods.proto +++ b/proto/service/methods.proto @@ -517,6 +517,13 @@ service LightningPub { option (nostr) = true; } + rpc PayInvoiceStream(structs.PayInvoiceRequest) returns (stream structs.InvoicePaymentStream){ + option (auth_type) = "User"; + option (http_method) = "post"; + option (http_route) = "/api/user/invoice/pay/stream"; + option (nostr) = true; + } + rpc GetPaymentState(structs.GetPaymentStateRequest) returns (structs.PaymentState){ option (auth_type) = "User"; option (http_method) = "post"; diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 201fd30b..0676a461 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -390,6 +390,7 @@ message PayAppUserInvoiceRequest { string invoice = 2; int64 amount = 3; optional string debit_npub = 4; + optional int64 fee_limit_sats = 5; } message SendAppUserToAppUserPaymentRequest { @@ -466,6 +467,7 @@ message PayInvoiceRequest{ string invoice = 1; int64 amount = 2; optional string debit_npub = 3; + optional int64 fee_limit_sats = 4; } message PayInvoiceResponse{ @@ -476,6 +478,13 @@ message PayInvoiceResponse{ int64 network_fee = 5; } +message InvoicePaymentStream { + oneof update { + Empty ack = 1; + PayInvoiceResponse done = 2; + } +} + message GetPaymentStateRequest{ string invoice = 1; diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index e64035f2..eda107e2 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -346,9 +346,9 @@ export default class { return Math.ceil(amount * this.getSettings().lndSettings.feeRateLimit + this.getSettings().lndSettings.feeFixedLimit); } - GetMaxWithinLimit(amount: number): number { - return Math.max(0, Math.floor(amount * (1 - this.getSettings().lndSettings.feeRateLimit) - this.getSettings().lndSettings.feeFixedLimit)) - } + /* GetMaxWithinLimit(amount: number): number { + return Math.max(0, Math.floor(amount * (1 - this.getSettings().lndSettings.feeRateLimit) - this.getSettings().lndSettings.feeFixedLimit)) + } */ async ChannelBalance(): Promise<{ local: number, remote: number }> { // console.log("Getting channel balance") @@ -363,7 +363,7 @@ export default class { throw new Error("lnd node is currently out of sync") } if (useProvider) { - const res = await this.liquidProvider.PayInvoice(invoice, decodedAmount, from) + const res = await this.liquidProvider.PayInvoice(invoice, decodedAmount, from, feeLimit) const providerDst = this.liquidProvider.GetProviderDestination() return { feeSat: res.network_fee + res.service_fee, valueSat: res.amount_paid, paymentPreimage: res.preimage, providerDst } } diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index 5f78570e..07b84c16 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -69,14 +69,15 @@ export default class { throw new Error(`app user ${ctx.user_id} not found`) // TODO: fix logs doxing } const nostrSettings = this.settings.getSettings().nostrRelaySettings + const { max, networkFeeBps, networkFeeFixed, serviceFeeBps } = this.applicationManager.paymentManager.GetMaxPayableInvoice(user.balance_sats, true) return { userId: ctx.user_id, balance: user.balance_sats, - max_withdrawable: this.applicationManager.paymentManager.GetMaxPayableInvoice(user.balance_sats, true), + max_withdrawable: max, user_identifier: appUser.identifier, - network_max_fee_bps: this.settings.getSettings().lndSettings.feeRateBps, - network_max_fee_fixed: this.settings.getSettings().lndSettings.feeFixedLimit, - service_fee_bps: this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFeeBps, + network_max_fee_bps: networkFeeBps, + network_max_fee_fixed: networkFeeFixed, + service_fee_bps: serviceFeeBps, noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: appUser.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: appUser.identifier, relay: nostrSettings.relays[0] }), nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: appUser.identifier, relay: nostrSettings.relays[0] }), @@ -104,9 +105,21 @@ export default class { return this.applicationManager.PayAppUserInvoice(ctx.app_id, { amount: req.amount, invoice: req.invoice, - user_identifier: ctx.app_user_id + user_identifier: ctx.app_user_id, + debit_npub: req.debit_npub, + fee_limit_sats: req.fee_limit_sats }) } + + async PayInvoiceStream(ctx: Types.UserContext, req: Types.PayInvoiceRequest, cb: (res: Types.InvoicePaymentStream, err: Error | null) => void) { + return await this.applicationManager.PayAppUserInvoiceStream(ctx.app_id, { + amount: req.amount, + invoice: req.invoice, + user_identifier: ctx.app_user_id, + debit_npub: req.debit_npub, + fee_limit_sats: req.fee_limit_sats + }, cb) + } async PayAddress(ctx: Types.UserContext, req: Types.PayInvoiceRequest): Promise { return this.applicationManager.PayAppUserInvoice(ctx.app_id, { amount: req.amount, diff --git a/src/services/main/applicationManager.ts b/src/services/main/applicationManager.ts index 76ae76db..011533f5 100644 --- a/src/services/main/applicationManager.ts +++ b/src/services/main/applicationManager.ts @@ -154,17 +154,17 @@ export default class { const ndebitString = ndebitEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }) log("🔗 [DEBUG] Generated ndebit for user", { userId: u.user.user_id, ndebit: ndebitString }) - + const { max, networkFeeBps, networkFeeFixed, serviceFeeBps } = this.paymentManager.GetMaxPayableInvoice(u.user.balance_sats, true) return { identifier: u.identifier, info: { userId: u.user.user_id, balance: u.user.balance_sats, - max_withdrawable: this.paymentManager.GetMaxPayableInvoice(u.user.balance_sats, true), + max_withdrawable: max, user_identifier: u.identifier, - network_max_fee_bps: this.settings.getSettings().lndSettings.feeRateBps, - network_max_fee_fixed: this.settings.getSettings().lndSettings.feeFixedLimit, - service_fee_bps: this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFeeBps, + network_max_fee_bps: networkFeeBps, + network_max_fee_fixed: networkFeeFixed, + service_fee_bps: serviceFeeBps, noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: u.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }), nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }), @@ -172,7 +172,7 @@ export default class { bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl }, - max_withdrawable: this.paymentManager.GetMaxPayableInvoice(u.user.balance_sats, true) + max_withdrawable: max } } @@ -211,16 +211,16 @@ export default class { async GetAppUser(appId: string, req: Types.GetAppUserRequest): Promise { const app = await this.storage.applicationStorage.GetApplication(appId) const user = await this.storage.applicationStorage.GetApplicationUser(app, req.user_identifier) - const max = this.paymentManager.GetMaxPayableInvoice(user.user.balance_sats, true) const nostrSettings = this.settings.getSettings().nostrRelaySettings + const { max, networkFeeBps, networkFeeFixed, serviceFeeBps } = this.paymentManager.GetMaxPayableInvoice(user.user.balance_sats, true) return { max_withdrawable: max, identifier: req.user_identifier, info: { userId: user.user.user_id, balance: user.user.balance_sats, - max_withdrawable: this.paymentManager.GetMaxPayableInvoice(user.user.balance_sats, true), + max_withdrawable: max, user_identifier: user.identifier, - network_max_fee_bps: this.settings.getSettings().lndSettings.feeRateBps, - network_max_fee_fixed: this.settings.getSettings().lndSettings.feeFixedLimit, - service_fee_bps: this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFeeBps, + network_max_fee_bps: networkFeeBps, + network_max_fee_fixed: networkFeeFixed, + service_fee_bps: serviceFeeBps, noffer: nofferEncode({ pubkey: app.nostr_public_key!, offer: user.identifier, priceType: OfferPriceType.Spontaneous, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: user.identifier, relay: nostrSettings.relays[0] }), nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: user.identifier, relay: nostrSettings.relays[0] }), @@ -238,6 +238,12 @@ export default class { return paid } + async PayAppUserInvoiceStream(appId: string, req: Types.PayAppUserInvoiceRequest, cb: (res: Types.InvoicePaymentStream, err: Error | null) => void) { + const app = await this.storage.applicationStorage.GetApplication(appId) + const appUser = await this.storage.applicationStorage.GetApplicationUser(app, req.user_identifier) + return this.paymentManager.PayInvoiceStream(appUser.user.user_id, req, app, cb) + } + async SendAppUserToAppUserPayment(appId: string, req: Types.SendAppUserToAppUserPaymentRequest): Promise { const app = await this.storage.applicationStorage.GetApplication(appId) const fromUser = await this.storage.applicationStorage.GetApplicationUser(app, req.from_user_identifier) diff --git a/src/services/main/liquidityManager.ts b/src/services/main/liquidityManager.ts index ac72975e..8637952c 100644 --- a/src/services/main/liquidityManager.ts +++ b/src/services/main/liquidityManager.ts @@ -50,8 +50,11 @@ export class LiquidityManager { } beforeInvoiceCreation = async (amount: number): Promise<'lnd' | 'provider'> => { - + const providerReady = this.liquidityProvider.IsReady() if (this.settings.getSettings().liquiditySettings.useOnlyLiquidityProvider) { + if (!providerReady) { + throw new Error("cannot use liquidity provider, it is not ready") + } return 'provider' } @@ -78,16 +81,26 @@ export class LiquidityManager { } } - beforeOutInvoicePayment = async (amount: number): Promise<'lnd' | 'provider'> => { + beforeOutInvoicePayment = async (amount: number): Promise<{ use: 'lnd' } | { use: 'provider', feeLimit: number }> => { + const providerReady = this.liquidityProvider.IsReady() if (this.settings.getSettings().liquiditySettings.useOnlyLiquidityProvider) { - return 'provider' + if (!providerReady) { + throw new Error("cannot use liquidity provider, it is not ready") + } + const feeLimit = await this.liquidityProvider.GetExpectedFeeLimit(amount) + return { use: 'provider', feeLimit } + } + if (!providerReady) { + return { use: 'lnd' } } const canHandle = await this.liquidityProvider.CanProviderHandle({ action: 'spend', amount }) - if (canHandle) { - return 'provider' + if (!canHandle) { + return { use: 'lnd' } } - return 'lnd' + const feeLimit = this.liquidityProvider.CalculateExpectedFeeLimit(amount, canHandle) + return { use: 'provider', feeLimit } } + afterOutInvoicePaid = async () => { } shouldDrainProvider = async () => { diff --git a/src/services/main/liquidityProvider.ts b/src/services/main/liquidityProvider.ts index 10f9bfad..0e65be90 100644 --- a/src/services/main/liquidityProvider.ts +++ b/src/services/main/liquidityProvider.ts @@ -28,6 +28,9 @@ export class LiquidityProvider { queue: ((state: 'ready') => void)[] = [] utils: Utils pendingPayments: Record = {} + stateCache: Types.UserInfo | null = null + unreachableSince: number | null = null + reconnecting = false incrementProviderBalance: (balance: number) => Promise // make the sub process accept client constructor(getSettings: () => LiquiditySettings, utils: Utils, invoicePaidCb: InvoicePaidCb, incrementProviderBalance: (balance: number) => Promise) { @@ -68,7 +71,11 @@ export class LiquidityProvider { } IsReady = () => { - return this.ready && !this.getSettings().disableLiquidityProvider + const elapsed = this.unreachableSince ? Date.now() - this.unreachableSince : 0 + if (!this.reconnecting && elapsed > 1000 * 60 * 5) { + this.GetUserState().then(() => this.reconnecting = false) + } + return this.ready && !this.getSettings().disableLiquidityProvider && !this.unreachableSince } AwaitProviderReady = async (): Promise<'inactive' | 'ready'> => { @@ -119,14 +126,29 @@ export class LiquidityProvider { if (res.status === 'ERROR') { if (res.reason !== 'timeout') { this.log("error getting user info", res.reason) + if (!this.unreachableSince) this.unreachableSince = Date.now() } return res } + this.unreachableSince = null + this.stateCache = res this.utils.stateBundler.AddBalancePoint('providerBalance', res.balance) this.utils.stateBundler.AddBalancePoint('providerMaxWithdrawable', res.max_withdrawable) return res } + GetFees = () => { + if (!this.stateCache) { + throw new Error("user state not cached") + } + return { + serviceFeeBps: this.stateCache.service_fee_bps, + networkFeeBps: this.stateCache.network_max_fee_bps, + networkFeeFixed: this.stateCache.network_max_fee_fixed, + + } + } + GetLatestMaxWithdrawable = async () => { if (!this.IsReady()) { return 0 @@ -163,21 +185,37 @@ export class LiquidityProvider { return serviceFee + networkFeeLimit } - CanProviderHandle = async (req: LiquidityRequest) => { + GetExpectedFeeLimit = async (amount: number) => { + if (!this.IsReady()) { + throw new Error("liquidity provider is not ready yet, disabled or unreachable") + } + const state = await this.GetUserState() + if (state.status === 'ERROR') { + throw new Error(state.reason) + } + return this.CalculateExpectedFeeLimit(amount, state) + } + + CanProviderHandle = async (req: LiquidityRequest): Promise => { if (!this.IsReady()) { return false } - const maxW = await this.GetLatestMaxWithdrawable() - if (req.action === 'spend') { - return maxW > req.amount + const state = await this.GetUserState() + if (state.status === 'ERROR') { + this.log("error getting user state", state.reason) + return false } - return true + const maxW = state.max_withdrawable + if (req.action === 'spend' && maxW < req.amount) { + return false + } + return state } AddInvoice = async (amount: number, memo: string, from: 'user' | 'system', expiry: number) => { try { if (!this.IsReady()) { - throw new Error("liquidity provider is not ready yet or disabled") + throw new Error("liquidity provider is not ready yet, disabled or unreachable") } const res = await this.client.NewInvoice({ amountSats: amount, memo, expiry }) if (res.status === 'ERROR') { @@ -193,21 +231,43 @@ export class LiquidityProvider { } - PayInvoice = async (invoice: string, decodedAmount: number, from: 'user' | 'system') => { + PayInvoice = async (invoice: string, decodedAmount: number, from: 'user' | 'system', feeLimit?: number) => { try { if (!this.IsReady()) { - throw new Error("liquidity provider is not ready yet or disabled") + throw new Error("liquidity provider is not ready yet, disabled or unreachable") } - const userInfo = await this.GetUserState() - if (userInfo.status === 'ERROR') { - throw new Error(userInfo.reason) - } - this.pendingPayments[invoice] = decodedAmount + this.CalculateExpectedFeeLimit(decodedAmount, userInfo) - const res = await this.client.PayInvoice({ invoice, amount: 0 }) - if (res.status === 'ERROR') { + /* const userInfo = await this.GetUserState() + if (userInfo.status === 'ERROR') { + throw new Error(userInfo.reason) + } */ + const feeLimitToUse = feeLimit ? feeLimit : await this.GetExpectedFeeLimit(decodedAmount) + this.pendingPayments[invoice] = decodedAmount + feeLimitToUse //this.CalculateExpectedFeeLimit(decodedAmount, userInfo) + let acked = false + const timeout = setTimeout(() => { + this.log("10 seconds passed, still waiting for ack") + this.GetUserState() + }, 1000 * 10) + const res = await new Promise((resolve, reject) => { + this.client.PayInvoiceStream({ invoice, amount: 0, fee_limit_sats: feeLimitToUse }, (resp) => { + if (resp.status === 'ERROR') { + this.log("error paying invoice", resp.reason) + reject(new Error(resp.reason)) + return + } + if (resp.update.type === Types.InvoicePaymentStream_update_type.ACK) { + this.log("acked") + clearTimeout(timeout) + acked = true + return + } + resolve(resp.update.done) + }) + }) + //const res = await this.client.PayInvoice({ invoice, amount: 0, fee_limit_sats: feeLimitToUse }) + /* if (res.status === 'ERROR') { this.log("error paying invoice", res.reason) throw new Error(res.reason) - } + } */ const totalPaid = res.amount_paid + res.network_fee + res.service_fee this.incrementProviderBalance(-totalPaid).then(() => { delete this.pendingPayments[invoice] }) this.utils.stateBundler.AddTxPoint('paidAnInvoice', decodedAmount, { used: 'provider', from, timeDiscount: true }) @@ -221,7 +281,7 @@ export class LiquidityProvider { GetPaymentState = async (invoice: string) => { if (!this.IsReady()) { - throw new Error("liquidity provider is not ready yet or disabled") + throw new Error("liquidity provider is not ready yet, disabled or unreachable") } const res = await this.client.GetPaymentState({ invoice }) if (res.status === 'ERROR') { @@ -233,7 +293,7 @@ export class LiquidityProvider { GetOperations = async () => { if (!this.IsReady()) { - throw new Error("liquidity provider is not ready yet or disabled") + throw new Error("liquidity provider is not ready yet, disabled or unreachable") } const res = await this.client.GetUserOperations({ latestIncomingInvoice: { ts: 0, id: 0 }, latestOutgoingInvoice: { ts: 0, id: 0 }, diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 4dcd0b6e..752c1821 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -232,14 +232,29 @@ export default class { } } - GetMaxPayableInvoice(balance: number, appUser: boolean): number { - let maxWithinServiceFee = 0 - if (appUser) { - maxWithinServiceFee = Math.max(0, Math.floor(balance * (1 - this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFee))) - } else { - maxWithinServiceFee = Math.max(0, Math.floor(balance * (1 - this.settings.getSettings().serviceFeeSettings.outgoingAppInvoiceFee))) + GetMaxPayableInvoice(balance: number, appUser: boolean): { max: number, serviceFeeBps: number, networkFeeBps: number, networkFeeFixed: number } { + const { outgoingAppInvoiceFee, outgoingAppUserInvoiceFee, outgoingAppUserInvoiceFeeBps } = this.settings.getSettings().serviceFeeSettings + const serviceFee = appUser ? outgoingAppUserInvoiceFee : outgoingAppInvoiceFee + if (this.lnd.liquidProvider.IsReady()) { + const fees = this.lnd.liquidProvider.GetFees() + const providerServiceFee = fees.serviceFeeBps / 10000 + const providerNetworkFee = fees.networkFeeBps / 10000 + const div = 1 + serviceFee + providerServiceFee + providerNetworkFee + const max = Math.floor((balance - fees.networkFeeFixed) / div) + const networkFeeBps = fees.networkFeeBps + fees.serviceFeeBps + return { max, serviceFeeBps: outgoingAppUserInvoiceFeeBps, networkFeeBps, networkFeeFixed: fees.networkFeeFixed } } - return this.lnd.GetMaxWithinLimit(maxWithinServiceFee) + const { feeFixedLimit, feeRateLimit, feeRateBps } = this.settings.getSettings().lndSettings + const div = 1 + serviceFee + feeRateLimit + const max = Math.floor((balance - feeFixedLimit) / div) + return { max, serviceFeeBps: outgoingAppUserInvoiceFeeBps, networkFeeBps: feeRateBps, networkFeeFixed: feeFixedLimit } + /* let maxWithinServiceFee = 0 + if (appUser) { + maxWithinServiceFee = Math.max(0, Math.floor(balance * (1 - this.settings.getSettings().serviceFeeSettings.outgoingAppUserInvoiceFee))) + } else { + maxWithinServiceFee = Math.max(0, Math.floor(balance * (1 - this.settings.getSettings().serviceFeeSettings.outgoingAppInvoiceFee))) + } + return this.lnd.GetMaxWithinLimit(maxWithinServiceFee) */ } async DecodeInvoice(req: Types.DecodeInvoiceRequest): Promise { const decoded = await this.lnd.DecodeInvoice(req.invoice) @@ -248,7 +263,17 @@ export default class { } } - async PayInvoice(userId: string, req: Types.PayInvoiceRequest, linkedApplication: Application): Promise { + async PayInvoiceStream(userId: string, req: Types.PayInvoiceRequest, linkedApplication: Application, cb: (res: Types.InvoicePaymentStream, err: Error | null) => void) { + const ack = () => cb({ update: { type: Types.InvoicePaymentStream_update_type.ACK, ack: {} } }, null) + try { + const paid = await this.PayInvoice(userId, req, linkedApplication, ack) + cb({ update: { type: Types.InvoicePaymentStream_update_type.DONE, done: paid } }, null) + } catch (err: any) { + cb({ update: { type: Types.InvoicePaymentStream_update_type.ACK, ack: {} } }, err) + } + } + + async PayInvoice(userId: string, req: Types.PayInvoiceRequest, linkedApplication: Application, ack?: () => void): Promise { await this.watchDog.PaymentRequested() const maybeBanned = await this.storage.userStorage.GetUser(userId) if (maybeBanned.locked) { @@ -264,6 +289,9 @@ export default class { const payAmount = req.amount !== 0 ? req.amount : Number(decoded.numSatoshis) const isAppUserPayment = userId !== linkedApplication.owner.user_id const serviceFee = this.getServiceFee(Types.UserOperationType.OUTGOING_INVOICE, payAmount, isAppUserPayment) + if (req.fee_limit_sats && req.fee_limit_sats < serviceFee) { + throw new Error("fee limit provided is too low to cover service fees") + } const internalInvoice = await this.storage.paymentStorage.GetInvoiceOwner(req.invoice) if (internalInvoice && internalInvoice.paid_at_unix > 0) { throw new Error("this invoice was already paid") @@ -276,7 +304,7 @@ export default class { if (internalInvoice) { paymentInfo = await this.PayInternalInvoice(userId, internalInvoice, { payAmount, serviceFee }, linkedApplication, req.debit_npub) } else { - paymentInfo = await this.PayExternalInvoice(userId, req.invoice, { payAmount, serviceFee, amountForLnd: req.amount }, linkedApplication, req.debit_npub) + paymentInfo = await this.PayExternalInvoice(userId, req.invoice, { payAmount, serviceFee, amountForLnd: req.amount, feeLimit: req.fee_limit_sats }, linkedApplication, req.debit_npub, ack) } if (isAppUserPayment && serviceFee > 0) { await this.storage.userStorage.IncrementUserBalance(linkedApplication.owner.user_id, serviceFee, "fees") @@ -292,7 +320,22 @@ export default class { } } - async PayExternalInvoice(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, amountForLnd: number }, linkedApplication: Application, debitNpub?: string) { + getUse = async (payAmount: number, inputLimit: number | undefined): Promise<{ use: 'lnd' | 'provider', feeLimit: number }> => { + const use = await this.liquidityManager.beforeOutInvoicePayment(payAmount) + if (use.use === 'lnd') { + const lndFeeLimit = this.lnd.GetFeeLimitAmount(payAmount) + if (inputLimit && inputLimit < lndFeeLimit) { + this.log("WARNING requested fee limit is lower than suggested, payment might fail") + } + return { use: 'lnd', feeLimit: inputLimit || lndFeeLimit } + } + if (inputLimit && inputLimit < use.feeLimit) { + this.log("WARNING requested fee limit is lower than suggested by provider, payment might fail") + } + return { use: 'provider', feeLimit: inputLimit || use.feeLimit } + } + + async PayExternalInvoice(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, amountForLnd: number, feeLimit?: number }, linkedApplication: Application, debitNpub?: string, ack?: () => void) { if (this.settings.getSettings().serviceSettings.disableExternalPayments) { throw new Error("something went wrong sending payment, please try again later") } @@ -305,16 +348,20 @@ export default class { } throw new Error("payment already in progress") } + const { amountForLnd, payAmount, serviceFee } = amounts const totalAmountToDecrement = payAmount + serviceFee - const routingFeeLimit = this.lnd.GetFeeLimitAmount(payAmount) - const use = await this.liquidityManager.beforeOutInvoicePayment(payAmount) + /* const routingFeeLimit = this.lnd.GetFeeLimitAmount(payAmount) + const use = await this.liquidityManager.beforeOutInvoicePayment(payAmount) */ + const remainingLimit = amounts.feeLimit ? amounts.feeLimit - serviceFee : undefined + const { use, feeLimit: routingFeeLimit } = await this.getUse(payAmount, remainingLimit) const provider = use === 'provider' ? this.lnd.liquidProvider.GetProviderDestination() : undefined const pendingPayment = await this.storage.StartTransaction(async tx => { await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement + routingFeeLimit, invoice, tx) return await this.storage.paymentStorage.AddPendingExternalPayment(userId, invoice, { payAmount, serviceFee, networkFee: routingFeeLimit }, linkedApplication, provider, tx, debitNpub) }, "payment started") this.log("ready to pay") + ack?.() try { const payment = await this.lnd.PayInvoice(invoice, amountForLnd, routingFeeLimit, payAmount, { useProvider: use === 'provider', from: 'user' }, index => { this.storage.paymentStorage.SetExternalPaymentIndex(pendingPayment.serial_id, index) diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index c9f92932..16051c68 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -158,6 +158,13 @@ export default (mainHandler: Main): Types.ServerMethods => { if (err != null) throw new Error(err.message) return mainHandler.appUserManager.PayInvoice(ctx, req) }, + PayInvoiceStream: async ({ ctx, req, cb }) => { + const err = Types.PayInvoiceRequestValidate(req, { + invoice_CustomCheck: invoice => invoice !== '' + }) + if (err != null) throw new Error(err.message) + mainHandler.appUserManager.PayInvoiceStream(ctx, req, cb) + }, GetLnurlWithdrawLink: ({ ctx }) => mainHandler.paymentManager.GetLnurlWithdrawLink(ctx), GetLnurlWithdrawInfo: async ({ ctx, query }) => { if (!query.k1) {