From a596e186fe48bd57ed53591c2ef09adb5c2dd326 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 21 Jan 2026 16:04:33 +0000 Subject: [PATCH 01/49] admin swaps --- src/services/lnd/swaps.ts | 657 --------------------- src/services/lnd/swaps/reverseSwaps.ts | 280 +++++++++ src/services/lnd/swaps/submarineSwaps.ts | 167 ++++++ src/services/lnd/swaps/swapHelpers.ts | 50 ++ src/services/lnd/swaps/swaps.ts | 226 +++++++ src/services/main/adminManager.ts | 2 +- src/services/main/init.ts | 2 +- src/services/main/paymentManager.ts | 6 +- src/services/storage/db/db.ts | 4 +- src/services/storage/entity/InvoiceSwap.ts | 74 +++ src/services/storage/paymentStorage.ts | 14 +- 11 files changed, 817 insertions(+), 665 deletions(-) delete mode 100644 src/services/lnd/swaps.ts create mode 100644 src/services/lnd/swaps/reverseSwaps.ts create mode 100644 src/services/lnd/swaps/submarineSwaps.ts create mode 100644 src/services/lnd/swaps/swapHelpers.ts create mode 100644 src/services/lnd/swaps/swaps.ts create mode 100644 src/services/storage/entity/InvoiceSwap.ts diff --git a/src/services/lnd/swaps.ts b/src/services/lnd/swaps.ts deleted file mode 100644 index 727f0e88..00000000 --- a/src/services/lnd/swaps.ts +++ /dev/null @@ -1,657 +0,0 @@ -import zkpInit from '@vulpemventures/secp256k1-zkp'; -import axios from 'axios'; -import { crypto, initEccLib, Transaction, address, Network } from 'bitcoinjs-lib'; -// import bolt11 from 'bolt11'; -import { - Musig, SwapTreeSerializer, TaprootUtils, detectSwap, - constructClaimTransaction, targetFee, OutputType, - Networks, -} from 'boltz-core'; -import { randomBytes, createHash } from 'crypto'; -import { ECPairFactory, ECPairInterface } from 'ecpair'; -import * as ecc from 'tiny-secp256k1'; -import ws from 'ws'; -import { getLogger, PubLogger, ERROR } from '../helpers/logger.js'; -import SettingsManager from '../main/settingsManager.js'; -import * as Types from '../../../proto/autogenerated/ts/types.js'; -import { BTCNetwork } from '../main/settings.js'; -import Storage from '../storage/index.js'; -import LND from './lnd.js'; -import { UserInvoicePayment } from '../storage/entity/UserInvoicePayment.js'; -type InvoiceSwapResponse = { id: string, claimPublicKey: string, swapTree: string } -type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface } -type InvoiceSwapData = { createdResponse: InvoiceSwapResponse, info: InvoiceSwapInfo } - -type TransactionSwapFees = { - percentage: number, - minerFees: { - claim: number, - lockup: number, - } -} - -type TransactionSwapFeesRes = { - BTC?: { - BTC?: { - fees: TransactionSwapFees - } - } -} - - -type TransactionSwapResponse = { - id: string, refundPublicKey: string, swapTree: string, - timeoutBlockHeight: number, lockupAddress: string, invoice: string, - onchainAmount?: number -} -type TransactionSwapInfo = { destinationAddress: string, preimage: Buffer, keys: ECPairInterface, chainFee: number } -export type TransactionSwapData = { createdResponse: TransactionSwapResponse, info: TransactionSwapInfo } -export class Swaps { - settings: SettingsManager - revSwappers: Record - // submarineSwaps: SubmarineSwaps - storage: Storage - lnd: LND - log = getLogger({ component: 'swaps' }) - constructor(settings: SettingsManager, storage: Storage) { - this.settings = settings - this.revSwappers = {} - const network = settings.getSettings().lndSettings.network - const { boltzHttpUrl, boltzWebSocketUrl, boltsHttpUrlAlt, boltsWebSocketUrlAlt } = settings.getSettings().swapsSettings - if (boltzHttpUrl && boltzWebSocketUrl) { - this.revSwappers[boltzHttpUrl] = new ReverseSwaps({ httpUrl: boltzHttpUrl, wsUrl: boltzWebSocketUrl, network }) - } - if (boltsHttpUrlAlt && boltsWebSocketUrlAlt) { - this.revSwappers[boltsHttpUrlAlt] = new ReverseSwaps({ httpUrl: boltsHttpUrlAlt, wsUrl: boltsWebSocketUrlAlt, network }) - } - this.storage = storage - } - - SetLnd = (lnd: LND) => { - this.lnd = lnd - } - - Stop = () => { } - - GetKeys = (privateKey: string) => { - const keys = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKey, 'hex')) - return keys - } - - ListSwaps = async (appUserId: string, payments: UserInvoicePayment[], newOp: (p: UserInvoicePayment) => Types.UserOperation | undefined, getServiceFee: (amt: number) => number): Promise => { - const completedSwaps = await this.storage.paymentStorage.ListCompletedSwaps(appUserId, payments) - const pendingSwaps = await this.storage.paymentStorage.ListPendingTransactionSwaps(appUserId) - return { - swaps: completedSwaps.map(s => { - const p = s.payment - const op = p ? newOp(p) : undefined - return { - operation_payment: op, - swap_operation_id: s.swap.swap_operation_id, - address_paid: s.swap.address_paid, - failure_reason: s.swap.failure_reason, - } - }), - quotes: pendingSwaps.map(s => { - const serviceFee = getServiceFee(s.invoice_amount) - return { - swap_operation_id: s.swap_operation_id, - invoice_amount_sats: s.invoice_amount, - transaction_amount_sats: s.transaction_amount, - chain_fee_sats: s.chain_fee_sats, - service_fee_sats: serviceFee, - swap_fee_sats: s.swap_fee_sats, - service_url: s.service_url, - } - }) - } - } - GetTxSwapQuotes = async (appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise => { - if (!this.settings.getSettings().swapsSettings.enableSwaps) { - throw new Error("Swaps are not enabled") - } - const swappers = Object.values(this.revSwappers) - if (swappers.length === 0) { - throw new Error("No swap services available") - } - const res = await Promise.allSettled(swappers.map(sw => this.getTxSwapQuote(sw, appUserId, amt, getServiceFee))) - const failures: string[] = [] - const success: Types.TransactionSwapQuote[] = [] - for (const r of res) { - if (r.status === 'fulfilled') { - success.push(r.value) - } else { - failures.push(r.reason.message ? r.reason.message : r.reason.toString()) - } - } - if (success.length === 0) { - throw new Error(failures.join("\n")) - } - return success - } - - private async getTxSwapQuote(swapper: ReverseSwaps, appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise { - this.log("getting transaction swap quote") - const feesRes = await swapper.GetFees() - if (!feesRes.ok) { - throw new Error(feesRes.error) - } - const { claim, lockup } = feesRes.fees.minerFees - const minerFee = claim + lockup - const chainTotal = amt + minerFee - const res = await swapper.SwapTransaction(chainTotal) - if (!res.ok) { - throw new Error(res.error) - } - const decoded = await this.lnd.DecodeInvoice(res.createdResponse.invoice) - const swapFee = decoded.numSatoshis - chainTotal - const serviceFee = getServiceFee(decoded.numSatoshis) - const newSwap = await this.storage.paymentStorage.AddTransactionSwap({ - app_user_id: appUserId, - swap_quote_id: res.createdResponse.id, - swap_tree: JSON.stringify(res.createdResponse.swapTree), - lockup_address: res.createdResponse.lockupAddress, - refund_public_key: res.createdResponse.refundPublicKey, - timeout_block_height: res.createdResponse.timeoutBlockHeight, - invoice: res.createdResponse.invoice, - invoice_amount: decoded.numSatoshis, - transaction_amount: chainTotal, - swap_fee_sats: swapFee, - chain_fee_sats: minerFee, - preimage: res.preimage, - ephemeral_private_key: res.privKey, - ephemeral_public_key: res.pubkey, - service_url: swapper.getHttpUrl(), - }) - return { - swap_operation_id: newSwap.swap_operation_id, - swap_fee_sats: swapFee, - invoice_amount_sats: decoded.numSatoshis, - transaction_amount_sats: amt, - chain_fee_sats: minerFee, - service_fee_sats: serviceFee, - service_url: swapper.getHttpUrl(), - } - } - - async PayAddrWithSwap(appUserId: string, swapOpId: string, address: string, payInvoice: (invoice: string, amt: number) => Promise) { - if (!this.settings.getSettings().swapsSettings.enableSwaps) { - throw new Error("Swaps are not enabled") - } - this.log("paying address with swap", { appUserId, swapOpId, address }) - if (!swapOpId) { - throw new Error("request a swap quote before paying an external address") - } - const txSwap = await this.storage.paymentStorage.GetTransactionSwap(swapOpId, appUserId) - if (!txSwap) { - throw new Error("swap quote not found") - } - const info = await this.lnd.GetInfo() - if (info.blockHeight >= txSwap.timeout_block_height) { - throw new Error("swap timeout") - } - const swapper = this.revSwappers[txSwap.service_url] - if (!swapper) { - throw new Error("swapper service not found") - } - const keys = this.GetKeys(txSwap.ephemeral_private_key) - const data: TransactionSwapData = { - createdResponse: { - id: txSwap.swap_quote_id, - invoice: txSwap.invoice, - lockupAddress: txSwap.lockup_address, - refundPublicKey: txSwap.refund_public_key, - swapTree: txSwap.swap_tree, - timeoutBlockHeight: txSwap.timeout_block_height, - onchainAmount: txSwap.transaction_amount, - }, - info: { - destinationAddress: address, - keys, - chainFee: txSwap.chain_fee_sats, - preimage: Buffer.from(txSwap.preimage, 'hex'), - } - } - // the swap and the invoice payment are linked, swap will not start until the invoice payment is started, and will not complete once the invoice payment is completed - let swapResult = { ok: false, error: "swap never completed" } as { ok: true, txId: string } | { ok: false, error: string } - swapper.SubscribeToTransactionSwap(data, result => { - swapResult = result - }) - try { - await payInvoice(txSwap.invoice, txSwap.invoice_amount) - if (!swapResult.ok) { - this.log("invoice payment successful, but swap failed") - await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, swapResult.error) - throw new Error(swapResult.error) - } - this.log("swap completed successfully") - await this.storage.paymentStorage.FinalizeTransactionSwap(swapOpId, address, swapResult.txId) - } catch (err: any) { - if (swapResult.ok) { - this.log("failed to pay swap invoice, but swap completed successfully", swapResult.txId) - await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, err.message) - } else { - this.log("failed to pay swap invoice and swap failed", swapResult.error) - await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, swapResult.error) - } - throw err - } - const networkFeesTotal = txSwap.chain_fee_sats + txSwap.swap_fee_sats - return { - txId: swapResult.txId, - network_fee: networkFeesTotal - } - } -} - - - -export class ReverseSwaps { - // settings: SettingsManager - private httpUrl: string - private wsUrl: string - log: PubLogger - private network: BTCNetwork - constructor({ httpUrl, wsUrl, network }: { httpUrl: string, wsUrl: string, network: BTCNetwork }) { - this.httpUrl = httpUrl - this.wsUrl = wsUrl - this.network = network - this.log = getLogger({ component: 'ReverseSwaps' }) - initEccLib(ecc) - } - - getHttpUrl = () => { - return this.httpUrl - } - getWsUrl = () => { - return this.wsUrl - } - - calculateFees = (fees: TransactionSwapFees, receiveAmount: number) => { - const pct = fees.percentage / 100 - const minerFee = fees.minerFees.claim + fees.minerFees.lockup - - const preFee = receiveAmount + minerFee - const fee = Math.ceil(preFee * pct) - const total = preFee + fee - return { total, fee, minerFee } - } - - GetFees = async (): Promise<{ ok: true, fees: TransactionSwapFees, } | { ok: false, error: string }> => { - const url = `${this.httpUrl}/v2/swap/reverse` - const feesRes = await loggedGet(this.log, url) - if (!feesRes.ok) { - return { ok: false, error: feesRes.error } - } - if (!feesRes.data.BTC?.BTC?.fees) { - return { ok: false, error: 'No fees found for BTC to BTC swap' } - } - - return { ok: true, fees: feesRes.data.BTC.BTC.fees } - } - - SwapTransaction = async (txAmount: number): Promise<{ ok: true, createdResponse: TransactionSwapResponse, preimage: string, pubkey: string, privKey: string } | { ok: false, error: string }> => { - const preimage = randomBytes(32); - const keys = ECPairFactory(ecc).makeRandom() - if (!keys.privateKey) { - return { ok: false, error: 'Failed to generate keys' } - } - const url = `${this.httpUrl}/v2/swap/reverse` - const req: any = { - onchainAmount: txAmount, - to: 'BTC', - from: 'BTC', - claimPublicKey: Buffer.from(keys.publicKey).toString('hex'), - preimageHash: createHash('sha256').update(preimage).digest('hex'), - } - const createdResponseRes = await loggedPost(this.log, url, req) - if (!createdResponseRes.ok) { - return createdResponseRes - } - const createdResponse = createdResponseRes.data - this.log('Created transaction swap'); - this.log(createdResponse); - return { - ok: true, createdResponse, - preimage: Buffer.from(preimage).toString('hex'), - pubkey: Buffer.from(keys.publicKey).toString('hex'), - privKey: Buffer.from(keys.privateKey).toString('hex') - } - } - - SubscribeToTransactionSwap = async (data: TransactionSwapData, swapDone: (result: { ok: true, txId: string } | { ok: false, error: string }) => void) => { - const webSocket = new ws(`${this.wsUrl}/v2/ws`) - const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] } - webSocket.on('open', () => { - webSocket.send(JSON.stringify(subReq)) - }) - let txId = "", isDone = false - const done = () => { - isDone = true - webSocket.close() - swapDone({ ok: true, txId }) - } - webSocket.on('error', (err) => { - this.log(ERROR, 'Error in WebSocket', err.message) - }) - webSocket.on('close', () => { - if (!isDone) { - this.log(ERROR, 'WebSocket closed before swap was done'); - swapDone({ ok: false, error: 'WebSocket closed before swap was done' }) - } - }) - webSocket.on('message', async (rawMsg) => { - try { - const result = await this.handleSwapTransactionMessage(rawMsg, data, done) - if (result) { - txId = result - } - } catch (err: any) { - this.log(ERROR, 'Error handling transaction WebSocket message', err.message) - isDone = true - webSocket.close() - swapDone({ ok: false, error: err.message }) - return - } - }) - } - - handleSwapTransactionMessage = async (rawMsg: ws.RawData, data: TransactionSwapData, done: () => void) => { - const msg = JSON.parse(rawMsg.toString('utf-8')); - if (msg.event !== 'update') { - return; - } - - this.log('Got WebSocket update'); - this.log(msg); - switch (msg.args[0].status) { - // "swap.created" means Boltz is waiting for the invoice to be paid - case 'swap.created': - this.log('Waiting invoice to be paid'); - return; - - // "transaction.mempool" means that Boltz sent an onchain transaction - case 'transaction.mempool': - const txIdRes = await this.handleTransactionMempool(data, msg.args[0].transaction.hex) - if (!txIdRes.ok) { - throw new Error(txIdRes.error) - } - return txIdRes.txId - case 'invoice.settled': - this.log('Transaction swap successful'); - done() - return; - } - } - - handleTransactionMempool = async (data: TransactionSwapData, txHex: string): Promise<{ ok: true, txId: string } | { ok: false, error: string }> => { - this.log('Creating claim transaction'); - const { createdResponse, info } = data - const { destinationAddress, keys, preimage, chainFee } = info - const boltzPublicKey = Buffer.from( - createdResponse.refundPublicKey, - 'hex', - ); - - // Create a musig signing session and tweak it with the Taptree of the swap scripts - const musig = new Musig(await zkpInit(), keys, randomBytes(32), [ - boltzPublicKey, - Buffer.from(keys.publicKey), - ]); - const tweakedKey = TaprootUtils.tweakMusig( - musig, - // swap tree can either be a string or an object - SwapTreeSerializer.deserializeSwapTree(createdResponse.swapTree).tree, - ); - - // Parse the lockup transaction and find the output relevant for the swap - const lockupTx = Transaction.fromHex(txHex); - const swapOutput = detectSwap(tweakedKey, lockupTx); - if (swapOutput === undefined) { - this.log(ERROR, 'No swap output found in lockup transaction'); - return { ok: false, error: 'No swap output found in lockup transaction' } - } - const network = getNetwork(this.network) - // Create a claim transaction to be signed cooperatively via a key path spend - const claimTx = constructClaimTransaction( - [ - { - ...swapOutput, - keys, - preimage, - cooperative: true, - type: OutputType.Taproot, - txHash: lockupTx.getHash(), - }, - ], - address.toOutputScript(destinationAddress, network), - chainFee, - ) - // Get the partial signature from Boltz - const claimUrl = `${this.httpUrl}/v2/swap/reverse/${createdResponse.id}/claim` - const claimReq = { - index: 0, - transaction: claimTx.toHex(), - preimage: preimage.toString('hex'), - pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'), - } - const boltzSigRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq) - if (!boltzSigRes.ok) { - return boltzSigRes - } - const boltzSig = boltzSigRes.data - - // Aggregate the nonces - musig.aggregateNonces([ - [boltzPublicKey, Buffer.from(boltzSig.pubNonce, 'hex')], - ]); - - // Initialize the session to sign the claim transaction - musig.initializeSession( - claimTx.hashForWitnessV1( - 0, - [swapOutput.script], - [swapOutput.value], - Transaction.SIGHASH_DEFAULT, - ), - ); - - // Add the partial signature from Boltz - musig.addPartial( - boltzPublicKey, - Buffer.from(boltzSig.partialSignature, 'hex'), - ); - - // Create our partial signature - musig.signPartial(); - - // Witness of the input to the aggregated signature - claimTx.ins[0].witness = [musig.aggregatePartials()]; - - // Broadcast the finalized transaction - const broadcastUrl = `${this.httpUrl}/v2/chain/BTC/transaction` - const broadcastReq = { - hex: claimTx.toHex(), - } - - const broadcastResponse = await loggedPost(this.log, broadcastUrl, broadcastReq) - if (!broadcastResponse.ok) { - return broadcastResponse - } - this.log('Transaction broadcasted', broadcastResponse.data) - const txId = claimTx.getId() - return { ok: true, txId } - } -} - -const loggedPost = async (log: PubLogger, url: string, req: any): Promise<{ ok: true, data: T } | { ok: false, error: string }> => { - try { - const { data } = await axios.post(url, req) - return { ok: true, data: data as T } - } catch (err: any) { - if (err.response?.data) { - log(ERROR, 'Error sending request', err.response.data) - return { ok: false, error: JSON.stringify(err.response.data) } - } - log(ERROR, 'Error sending request', err.message) - return { ok: false, error: err.message } - } -} - -const loggedGet = async (log: PubLogger, url: string): Promise<{ ok: true, data: T } | { ok: false, error: string }> => { - try { - const { data } = await axios.get(url) - return { ok: true, data: data as T } - } catch (err: any) { - if (err.response?.data) { - log(ERROR, 'Error getting request', err.response.data) - return { ok: false, error: err.response.data } - } - log(ERROR, 'Error getting request', err.message) - return { ok: false, error: err.message } - } -} - -const getNetwork = (network: BTCNetwork): Network => { - switch (network) { - case 'mainnet': - return Networks.bitcoinMainnet - case 'testnet': - return Networks.bitcoinTestnet - case 'regtest': - return Networks.bitcoinRegtest - default: - throw new Error(`Invalid network: ${network}`) - } -} - -// Submarine swaps currently not supported, keeping the code for future reference -/* -export class SubmarineSwaps { - settings: SettingsManager - log: PubLogger - constructor(settings: SettingsManager) { - this.settings = settings - this.log = getLogger({ component: 'SubmarineSwaps' }) - } - - SwapInvoice = async (invoice: string, paymentHash: string) => { - if (!this.settings.getSettings().swapsSettings.enableSwaps) { - this.log(ERROR, 'Swaps are not enabled'); - return; - } - const keys = ECPairFactory(ecc).makeRandom() - const refundPublicKey = Buffer.from(keys.publicKey).toString('hex') - const req = { invoice, to: 'BTC', from: 'BTC', refundPublicKey } - const url = `${this.settings.getSettings().swapsSettings.boltzHttpUrl}/v2/swap/submarine` - this.log('Sending invoice swap request to', url); - const createdResponseRes = await loggedPost(this.log, url, req) - if (!createdResponseRes.ok) { - return createdResponseRes - } - const createdResponse = createdResponseRes.data - this.log('Created invoice swap'); - this.log(createdResponse); - - const webSocket = new ws(`${this.settings.getSettings().swapsSettings.boltzWebSocketUrl}/v2/ws`) - const subReq = { op: 'subscribe', channel: 'swap.update', args: [createdResponse.id] } - webSocket.on('open', () => { - webSocket.send(JSON.stringify(subReq)) - }) - - webSocket.on('message', async (rawMsg) => { - try { - await this.handleSwapInvoiceMessage(rawMsg, { createdResponse, info: { paymentHash, keys } }, () => webSocket.close()) - } catch (err: any) { - this.log(ERROR, 'Error handling invoice WebSocket message', err.message) - webSocket.close() - return - } - }); - } - - handleSwapInvoiceMessage = async (rawMsg: ws.RawData, data: InvoiceSwapData, closeWebSocket: () => void) => { - const msg = JSON.parse(rawMsg.toString('utf-8')); - if (msg.event !== 'update') { - return; - } - - this.log('Got invoice WebSocket update'); - this.log(msg); - switch (msg.args[0].status) { - // "invoice.set" means Boltz is waiting for an onchain transaction to be sent - case 'invoice.set': - this.log('Waiting for onchain transaction'); - return; - // Create a partial signature to allow Boltz to do a key path spend to claim the mainchain coins - case 'transaction.claim.pending': - await this.handleInvoiceClaimPending(data) - return; - - case 'transaction.claimed': - this.log('Invoice swap successful'); - closeWebSocket() - return; - } - - } - - handleInvoiceClaimPending = async (data: InvoiceSwapData) => { - this.log('Creating cooperative claim transaction'); - const { createdResponse, info } = data - const { paymentHash, keys } = info - const { boltzHttpUrl } = this.settings.getSettings().swapsSettings - // Get the information request to create a partial signature - const url = `${boltzHttpUrl}/v2/swap/submarine/${createdResponse.id}/claim` - const claimTxDetailsRes = await loggedGet<{ preimage: string, transactionHash: string, pubNonce: string }>(this.log, url) - if (!claimTxDetailsRes.ok) { - return claimTxDetailsRes - } - const claimTxDetails = claimTxDetailsRes.data - // Verify that Boltz actually paid the invoice by comparing the preimage hash - // of the invoice to the SHA256 hash of the preimage from the response - const claimTxPreimageHash = createHash('sha256').update(Buffer.from(claimTxDetails.preimage, 'hex')).digest() - const invoicePreimageHash = Buffer.from(paymentHash, 'hex') - - if (!claimTxPreimageHash.equals(invoicePreimageHash)) { - this.log(ERROR, 'Boltz provided invalid preimage'); - return; - } - - const boltzPublicKey = Buffer.from(createdResponse.claimPublicKey, 'hex') - - // Create a musig signing instance - const musig = new Musig(await zkpInit(), keys, randomBytes(32), [ - boltzPublicKey, - Buffer.from(keys.publicKey), - ]); - // Tweak that musig with the Taptree of the swap scripts - TaprootUtils.tweakMusig( - musig, - SwapTreeSerializer.deserializeSwapTree(createdResponse.swapTree).tree, - ); - - // Aggregate the nonces - musig.aggregateNonces([ - [boltzPublicKey, Buffer.from(claimTxDetails.pubNonce, 'hex')], - ]); - // Initialize the session to sign the transaction hash from the response - musig.initializeSession( - Buffer.from(claimTxDetails.transactionHash, 'hex'), - ); - - // Give our public nonce and the partial signature to Boltz - const claimUrl = `${boltzHttpUrl}/v2/swap/submarine/${createdResponse.id}/claim` - const claimReq = { - pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'), - partialSignature: Buffer.from(musig.signPartial()).toString('hex'), - } - const claimResponseRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq) - if (!claimResponseRes.ok) { - return claimResponseRes - } - const claimResponse = claimResponseRes.data - this.log('Claim response', claimResponse) - } -} -*/ \ No newline at end of file diff --git a/src/services/lnd/swaps/reverseSwaps.ts b/src/services/lnd/swaps/reverseSwaps.ts new file mode 100644 index 00000000..4510389b --- /dev/null +++ b/src/services/lnd/swaps/reverseSwaps.ts @@ -0,0 +1,280 @@ +import zkpInit from '@vulpemventures/secp256k1-zkp'; +import { initEccLib, Transaction, address } from 'bitcoinjs-lib'; +// import bolt11 from 'bolt11'; +import { + Musig, SwapTreeSerializer, TaprootUtils, detectSwap, + constructClaimTransaction, OutputType, +} from 'boltz-core'; +import { randomBytes, createHash } from 'crypto'; +import { ECPairFactory, ECPairInterface } from 'ecpair'; +import * as ecc from 'tiny-secp256k1'; +import ws from 'ws'; +import { getLogger, PubLogger, ERROR } from '../../helpers/logger.js'; +import { BTCNetwork } from '../../main/settings.js'; +import { loggedGet, loggedPost, getNetwork } from './swapHelpers.js'; + + +type TransactionSwapFees = { + percentage: number, + minerFees: { + claim: number, + lockup: number, + } +} + +type TransactionSwapFeesRes = { + BTC?: { + BTC?: { + fees: TransactionSwapFees + } + } +} + + +type TransactionSwapResponse = { + id: string, refundPublicKey: string, swapTree: string, + timeoutBlockHeight: number, lockupAddress: string, invoice: string, + onchainAmount?: number +} +type TransactionSwapInfo = { destinationAddress: string, preimage: Buffer, keys: ECPairInterface, chainFee: number } +export type TransactionSwapData = { createdResponse: TransactionSwapResponse, info: TransactionSwapInfo } + + + +export class ReverseSwaps { + // settings: SettingsManager + private httpUrl: string + private wsUrl: string + log: PubLogger + private network: BTCNetwork + constructor({ httpUrl, wsUrl, network }: { httpUrl: string, wsUrl: string, network: BTCNetwork }) { + this.httpUrl = httpUrl + this.wsUrl = wsUrl + this.network = network + this.log = getLogger({ component: 'ReverseSwaps' }) + initEccLib(ecc) + } + + getHttpUrl = () => { + return this.httpUrl + } + getWsUrl = () => { + return this.wsUrl + } + + calculateFees = (fees: TransactionSwapFees, receiveAmount: number) => { + const pct = fees.percentage / 100 + const minerFee = fees.minerFees.claim + fees.minerFees.lockup + + const preFee = receiveAmount + minerFee + const fee = Math.ceil(preFee * pct) + const total = preFee + fee + return { total, fee, minerFee } + } + + GetFees = async (): Promise<{ ok: true, fees: TransactionSwapFees, } | { ok: false, error: string }> => { + const url = `${this.httpUrl}/v2/swap/reverse` + const feesRes = await loggedGet(this.log, url) + if (!feesRes.ok) { + return { ok: false, error: feesRes.error } + } + if (!feesRes.data.BTC?.BTC?.fees) { + return { ok: false, error: 'No fees found for BTC to BTC swap' } + } + + return { ok: true, fees: feesRes.data.BTC.BTC.fees } + } + + SwapTransaction = async (txAmount: number): Promise<{ ok: true, createdResponse: TransactionSwapResponse, preimage: string, pubkey: string, privKey: string } | { ok: false, error: string }> => { + const preimage = randomBytes(32); + const keys = ECPairFactory(ecc).makeRandom() + if (!keys.privateKey) { + return { ok: false, error: 'Failed to generate keys' } + } + const url = `${this.httpUrl}/v2/swap/reverse` + const req: any = { + onchainAmount: txAmount, + to: 'BTC', + from: 'BTC', + claimPublicKey: Buffer.from(keys.publicKey).toString('hex'), + preimageHash: createHash('sha256').update(preimage).digest('hex'), + } + const createdResponseRes = await loggedPost(this.log, url, req) + if (!createdResponseRes.ok) { + return createdResponseRes + } + const createdResponse = createdResponseRes.data + this.log('Created transaction swap'); + this.log(createdResponse); + return { + ok: true, createdResponse, + preimage: Buffer.from(preimage).toString('hex'), + pubkey: Buffer.from(keys.publicKey).toString('hex'), + privKey: Buffer.from(keys.privateKey).toString('hex') + } + } + + SubscribeToTransactionSwap = async (data: TransactionSwapData, swapDone: (result: { ok: true, txId: string } | { ok: false, error: string }) => void) => { + const webSocket = new ws(`${this.wsUrl}/v2/ws`) + const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] } + webSocket.on('open', () => { + webSocket.send(JSON.stringify(subReq)) + }) + let txId = "", isDone = false + const done = () => { + isDone = true + webSocket.close() + swapDone({ ok: true, txId }) + } + webSocket.on('error', (err) => { + this.log(ERROR, 'Error in WebSocket', err.message) + }) + webSocket.on('close', () => { + if (!isDone) { + this.log(ERROR, 'WebSocket closed before swap was done'); + swapDone({ ok: false, error: 'WebSocket closed before swap was done' }) + } + }) + webSocket.on('message', async (rawMsg) => { + try { + const result = await this.handleSwapTransactionMessage(rawMsg, data, done) + if (result) { + txId = result + } + } catch (err: any) { + this.log(ERROR, 'Error handling transaction WebSocket message', err.message) + isDone = true + webSocket.close() + swapDone({ ok: false, error: err.message }) + return + } + }) + } + + handleSwapTransactionMessage = async (rawMsg: ws.RawData, data: TransactionSwapData, done: () => void) => { + const msg = JSON.parse(rawMsg.toString('utf-8')); + if (msg.event !== 'update') { + return; + } + + this.log('Got WebSocket update'); + this.log(msg); + switch (msg.args[0].status) { + // "swap.created" means Boltz is waiting for the invoice to be paid + case 'swap.created': + this.log('Waiting invoice to be paid'); + return; + + // "transaction.mempool" means that Boltz sent an onchain transaction + case 'transaction.mempool': + const txIdRes = await this.handleTransactionMempool(data, msg.args[0].transaction.hex) + if (!txIdRes.ok) { + throw new Error(txIdRes.error) + } + return txIdRes.txId + case 'invoice.settled': + this.log('Transaction swap successful'); + done() + return; + } + } + + handleTransactionMempool = async (data: TransactionSwapData, txHex: string): Promise<{ ok: true, txId: string } | { ok: false, error: string }> => { + this.log('Creating claim transaction'); + const { createdResponse, info } = data + const { destinationAddress, keys, preimage, chainFee } = info + const boltzPublicKey = Buffer.from( + createdResponse.refundPublicKey, + 'hex', + ); + + // Create a musig signing session and tweak it with the Taptree of the swap scripts + const musig = new Musig(await zkpInit(), keys, randomBytes(32), [ + boltzPublicKey, + Buffer.from(keys.publicKey), + ]); + const tweakedKey = TaprootUtils.tweakMusig( + musig, + // swap tree can either be a string or an object + SwapTreeSerializer.deserializeSwapTree(createdResponse.swapTree).tree, + ); + + // Parse the lockup transaction and find the output relevant for the swap + const lockupTx = Transaction.fromHex(txHex); + const swapOutput = detectSwap(tweakedKey, lockupTx); + if (swapOutput === undefined) { + this.log(ERROR, 'No swap output found in lockup transaction'); + return { ok: false, error: 'No swap output found in lockup transaction' } + } + const network = getNetwork(this.network) + // Create a claim transaction to be signed cooperatively via a key path spend + const claimTx = constructClaimTransaction( + [ + { + ...swapOutput, + keys, + preimage, + cooperative: true, + type: OutputType.Taproot, + txHash: lockupTx.getHash(), + }, + ], + address.toOutputScript(destinationAddress, network), + chainFee, + ) + // Get the partial signature from Boltz + const claimUrl = `${this.httpUrl}/v2/swap/reverse/${createdResponse.id}/claim` + const claimReq = { + index: 0, + transaction: claimTx.toHex(), + preimage: preimage.toString('hex'), + pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'), + } + const boltzSigRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq) + if (!boltzSigRes.ok) { + return boltzSigRes + } + const boltzSig = boltzSigRes.data + + // Aggregate the nonces + musig.aggregateNonces([ + [boltzPublicKey, Buffer.from(boltzSig.pubNonce, 'hex')], + ]); + + // Initialize the session to sign the claim transaction + musig.initializeSession( + claimTx.hashForWitnessV1( + 0, + [swapOutput.script], + [swapOutput.value], + Transaction.SIGHASH_DEFAULT, + ), + ); + + // Add the partial signature from Boltz + musig.addPartial( + boltzPublicKey, + Buffer.from(boltzSig.partialSignature, 'hex'), + ); + + // Create our partial signature + musig.signPartial(); + + // Witness of the input to the aggregated signature + claimTx.ins[0].witness = [musig.aggregatePartials()]; + + // Broadcast the finalized transaction + const broadcastUrl = `${this.httpUrl}/v2/chain/BTC/transaction` + const broadcastReq = { + hex: claimTx.toHex(), + } + + const broadcastResponse = await loggedPost(this.log, broadcastUrl, broadcastReq) + if (!broadcastResponse.ok) { + return broadcastResponse + } + this.log('Transaction broadcasted', broadcastResponse.data) + const txId = claimTx.getId() + return { ok: true, txId } + } +} \ No newline at end of file diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts new file mode 100644 index 00000000..0c7de08c --- /dev/null +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -0,0 +1,167 @@ +import zkpInit from '@vulpemventures/secp256k1-zkp'; +// import bolt11 from 'bolt11'; +import { + Musig, SwapTreeSerializer, TaprootUtils +} from 'boltz-core'; +import { randomBytes, createHash } from 'crypto'; +import { ECPairFactory, ECPairInterface } from 'ecpair'; +import * as ecc from 'tiny-secp256k1'; +import ws from 'ws'; +import { getLogger, PubLogger, ERROR } from '../../helpers/logger.js'; +import { loggedGet, loggedPost } from './swapHelpers.js'; +import { BTCNetwork } from '../../main/settings.js'; + +type InvoiceSwapResponse = { id: string, claimPublicKey: string, swapTree: string } +type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface } +type InvoiceSwapData = { createdResponse: InvoiceSwapResponse, info: InvoiceSwapInfo } + + +export class SubmarineSwaps { + private httpUrl: string + private wsUrl: string + log: PubLogger + constructor({ httpUrl, wsUrl }: { httpUrl: string, wsUrl: string, network: BTCNetwork }) { + this.httpUrl = httpUrl + this.wsUrl = wsUrl + this.log = getLogger({ component: 'SubmarineSwaps' }) + } + + getHttpUrl = () => { + return this.httpUrl + } + getWsUrl = () => { + return this.wsUrl + } + + SwapInvoice = async (invoice: string, paymentHash: string) => { + const keys = ECPairFactory(ecc).makeRandom() + const refundPublicKey = Buffer.from(keys.publicKey).toString('hex') + const req = { invoice, to: 'BTC', from: 'BTC', refundPublicKey } + const url = `${this.httpUrl}/v2/swap/submarine` + this.log('Sending invoice swap request to', url); + const createdResponseRes = await loggedPost(this.log, url, req) + if (!createdResponseRes.ok) { + return createdResponseRes + } + const createdResponse = createdResponseRes.data + this.log('Created invoice swap'); + this.log(createdResponse); + + + } + + SubscribeToInvoiceSwap = async (data: InvoiceSwapData, swapDone: (result: { ok: true, txId: string } | { ok: false, error: string }) => void) => { + const webSocket = new ws(`${this.wsUrl}/v2/ws`) + const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] } + webSocket.on('open', () => { + webSocket.send(JSON.stringify(subReq)) + }) + let txId = "", isDone = false + const done = () => { + isDone = true + webSocket.close() + swapDone({ ok: true, txId }) + } + webSocket.on('error', (err) => { + this.log(ERROR, 'Error in WebSocket', err.message) + }) + webSocket.on('close', () => { + if (!isDone) { + this.log(ERROR, 'WebSocket closed before swap was done'); + swapDone({ ok: false, error: 'WebSocket closed before swap was done' }) + } + }) + webSocket.on('message', async (rawMsg) => { + try { + await this.handleSwapInvoiceMessage(rawMsg, data, done) + } catch (err: any) { + this.log(ERROR, 'Error handling invoice WebSocket message', err.message) + webSocket.close() + return + } + }); + } + + handleSwapInvoiceMessage = async (rawMsg: ws.RawData, data: InvoiceSwapData, closeWebSocket: () => void) => { + const msg = JSON.parse(rawMsg.toString('utf-8')); + if (msg.event !== 'update') { + return; + } + + this.log('Got invoice WebSocket update'); + this.log(msg); + switch (msg.args[0].status) { + // "invoice.set" means Boltz is waiting for an onchain transaction to be sent + case 'invoice.set': + this.log('Waiting for onchain transaction'); + return; + // Create a partial signature to allow Boltz to do a key path spend to claim the mainchain coins + case 'transaction.claim.pending': + await this.handleInvoiceClaimPending(data) + return; + + case 'transaction.claimed': + this.log('Invoice swap successful'); + closeWebSocket() + return; + } + + } + + handleInvoiceClaimPending = async (data: InvoiceSwapData) => { + this.log('Creating cooperative claim transaction'); + const { createdResponse, info } = data + const { paymentHash, keys } = info + // Get the information request to create a partial signature + const url = `${this.httpUrl}/v2/swap/submarine/${createdResponse.id}/claim` + const claimTxDetailsRes = await loggedGet<{ preimage: string, transactionHash: string, pubNonce: string }>(this.log, url) + if (!claimTxDetailsRes.ok) { + return claimTxDetailsRes + } + const claimTxDetails = claimTxDetailsRes.data + // Verify that Boltz actually paid the invoice by comparing the preimage hash + // of the invoice to the SHA256 hash of the preimage from the response + const claimTxPreimageHash = createHash('sha256').update(Buffer.from(claimTxDetails.preimage, 'hex')).digest() + const invoicePreimageHash = Buffer.from(paymentHash, 'hex') + + if (!claimTxPreimageHash.equals(invoicePreimageHash)) { + this.log(ERROR, 'Boltz provided invalid preimage'); + return; + } + + const boltzPublicKey = Buffer.from(createdResponse.claimPublicKey, 'hex') + + // Create a musig signing instance + const musig = new Musig(await zkpInit(), keys, randomBytes(32), [ + boltzPublicKey, + Buffer.from(keys.publicKey), + ]); + // Tweak that musig with the Taptree of the swap scripts + TaprootUtils.tweakMusig( + musig, + SwapTreeSerializer.deserializeSwapTree(createdResponse.swapTree).tree, + ); + + // Aggregate the nonces + musig.aggregateNonces([ + [boltzPublicKey, Buffer.from(claimTxDetails.pubNonce, 'hex')], + ]); + // Initialize the session to sign the transaction hash from the response + musig.initializeSession( + Buffer.from(claimTxDetails.transactionHash, 'hex'), + ); + + // Give our public nonce and the partial signature to Boltz + const claimUrl = `${this.httpUrl}/v2/swap/submarine/${createdResponse.id}/claim` + const claimReq = { + pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'), + partialSignature: Buffer.from(musig.signPartial()).toString('hex'), + } + const claimResponseRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq) + if (!claimResponseRes.ok) { + return claimResponseRes + } + const claimResponse = claimResponseRes.data + this.log('Claim response', claimResponse) + } +} diff --git a/src/services/lnd/swaps/swapHelpers.ts b/src/services/lnd/swaps/swapHelpers.ts new file mode 100644 index 00000000..8c34f4fd --- /dev/null +++ b/src/services/lnd/swaps/swapHelpers.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import { Network } from 'bitcoinjs-lib'; +// import bolt11 from 'bolt11'; +import { + Networks, +} from 'boltz-core'; +import { PubLogger, ERROR } from '../../helpers/logger.js'; +import { BTCNetwork } from '../../main/settings.js'; + + +export const loggedPost = async (log: PubLogger, url: string, req: any): Promise<{ ok: true, data: T } | { ok: false, error: string }> => { + try { + const { data } = await axios.post(url, req) + return { ok: true, data: data as T } + } catch (err: any) { + if (err.response?.data) { + log(ERROR, 'Error sending request', err.response.data) + return { ok: false, error: JSON.stringify(err.response.data) } + } + log(ERROR, 'Error sending request', err.message) + return { ok: false, error: err.message } + } +} + +export const loggedGet = async (log: PubLogger, url: string): Promise<{ ok: true, data: T } | { ok: false, error: string }> => { + try { + const { data } = await axios.get(url) + return { ok: true, data: data as T } + } catch (err: any) { + if (err.response?.data) { + log(ERROR, 'Error getting request', err.response.data) + return { ok: false, error: err.response.data } + } + log(ERROR, 'Error getting request', err.message) + return { ok: false, error: err.message } + } +} + +export const getNetwork = (network: BTCNetwork): Network => { + switch (network) { + case 'mainnet': + return Networks.bitcoinMainnet + case 'testnet': + return Networks.bitcoinTestnet + case 'regtest': + return Networks.bitcoinRegtest + default: + throw new Error(`Invalid network: ${network}`) + } +} \ No newline at end of file diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts new file mode 100644 index 00000000..e53c8751 --- /dev/null +++ b/src/services/lnd/swaps/swaps.ts @@ -0,0 +1,226 @@ +import { ECPairFactory } from 'ecpair'; +import * as ecc from 'tiny-secp256k1'; +import { getLogger } from '../../helpers/logger.js'; +import SettingsManager from '../../main/settingsManager.js'; +import * as Types from '../../../../proto/autogenerated/ts/types.js'; +import Storage from '../../storage/index.js'; +import LND from '../lnd.js'; +import { UserInvoicePayment } from '../../storage/entity/UserInvoicePayment.js'; +import { ReverseSwaps, TransactionSwapData } from './reverseSwaps.js'; +import { SubmarineSwaps } from './submarineSwaps.js'; + + +export class Swaps { + settings: SettingsManager + revSwappers: Record + subSwappers: Record + storage: Storage + lnd: LND + log = getLogger({ component: 'swaps' }) + constructor(settings: SettingsManager, storage: Storage) { + this.settings = settings + this.revSwappers = {} + this.subSwappers = {} + const network = settings.getSettings().lndSettings.network + const { boltzHttpUrl, boltzWebSocketUrl, boltsHttpUrlAlt, boltsWebSocketUrlAlt } = settings.getSettings().swapsSettings + if (boltzHttpUrl && boltzWebSocketUrl) { + this.revSwappers[boltzHttpUrl] = new ReverseSwaps({ httpUrl: boltzHttpUrl, wsUrl: boltzWebSocketUrl, network }) + this.subSwappers[boltzHttpUrl] = new SubmarineSwaps({ httpUrl: boltzHttpUrl, wsUrl: boltzWebSocketUrl, network }) + } + if (boltsHttpUrlAlt && boltsWebSocketUrlAlt) { + this.revSwappers[boltsHttpUrlAlt] = new ReverseSwaps({ httpUrl: boltsHttpUrlAlt, wsUrl: boltsWebSocketUrlAlt, network }) + this.subSwappers[boltsHttpUrlAlt] = new SubmarineSwaps({ httpUrl: boltsHttpUrlAlt, wsUrl: boltsWebSocketUrlAlt, network }) + } + this.storage = storage + } + + SetLnd = (lnd: LND) => { + this.lnd = lnd + } + + Stop = () => { } + + GetKeys = (privateKey: string) => { + const keys = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKey, 'hex')) + return keys + } + + GetInvoiceSwapQuotes = async (appUserId: string, payments: UserInvoicePayment[], getServiceFee: (amt: number) => number): Promise => { + if (!this.settings.getSettings().swapsSettings.enableSwaps) { + throw new Error("Swaps are not enabled") + } + const swappers = Object.values(this.subSwappers) + if (swappers.length === 0) { + throw new Error("No swap services available") + } + const res = await Promise.allSettled(swappers.map(sw => this.getInvoiceSwapQuote(sw, appUserId, payments, getServiceFee))) + const failures: string[] = [] + const success: Types.InvoiceSwapQuote[] = [] + for (const r of res) { } + } + + ListTxSwaps = async (appUserId: string, payments: UserInvoicePayment[], newOp: (p: UserInvoicePayment) => Types.UserOperation | undefined, getServiceFee: (amt: number) => number): Promise => { + const completedSwaps = await this.storage.paymentStorage.ListCompletedTxSwaps(appUserId, payments) + const pendingSwaps = await this.storage.paymentStorage.ListPendingTransactionSwaps(appUserId) + return { + swaps: completedSwaps.map(s => { + const p = s.payment + const op = p ? newOp(p) : undefined + return { + operation_payment: op, + swap_operation_id: s.swap.swap_operation_id, + address_paid: s.swap.address_paid, + failure_reason: s.swap.failure_reason, + } + }), + quotes: pendingSwaps.map(s => { + const serviceFee = getServiceFee(s.invoice_amount) + return { + swap_operation_id: s.swap_operation_id, + invoice_amount_sats: s.invoice_amount, + transaction_amount_sats: s.transaction_amount, + chain_fee_sats: s.chain_fee_sats, + service_fee_sats: serviceFee, + swap_fee_sats: s.swap_fee_sats, + service_url: s.service_url, + } + }) + } + } + GetTxSwapQuotes = async (appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise => { + if (!this.settings.getSettings().swapsSettings.enableSwaps) { + throw new Error("Swaps are not enabled") + } + const swappers = Object.values(this.revSwappers) + if (swappers.length === 0) { + throw new Error("No swap services available") + } + const res = await Promise.allSettled(swappers.map(sw => this.getTxSwapQuote(sw, appUserId, amt, getServiceFee))) + const failures: string[] = [] + const success: Types.TransactionSwapQuote[] = [] + for (const r of res) { + if (r.status === 'fulfilled') { + success.push(r.value) + } else { + failures.push(r.reason.message ? r.reason.message : r.reason.toString()) + } + } + if (success.length === 0) { + throw new Error(failures.join("\n")) + } + return success + } + + private async getTxSwapQuote(swapper: ReverseSwaps, appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise { + this.log("getting transaction swap quote") + const feesRes = await swapper.GetFees() + if (!feesRes.ok) { + throw new Error(feesRes.error) + } + const { claim, lockup } = feesRes.fees.minerFees + const minerFee = claim + lockup + const chainTotal = amt + minerFee + const res = await swapper.SwapTransaction(chainTotal) + if (!res.ok) { + throw new Error(res.error) + } + const decoded = await this.lnd.DecodeInvoice(res.createdResponse.invoice) + const swapFee = decoded.numSatoshis - chainTotal + const serviceFee = getServiceFee(decoded.numSatoshis) + const newSwap = await this.storage.paymentStorage.AddTransactionSwap({ + app_user_id: appUserId, + swap_quote_id: res.createdResponse.id, + swap_tree: JSON.stringify(res.createdResponse.swapTree), + lockup_address: res.createdResponse.lockupAddress, + refund_public_key: res.createdResponse.refundPublicKey, + timeout_block_height: res.createdResponse.timeoutBlockHeight, + invoice: res.createdResponse.invoice, + invoice_amount: decoded.numSatoshis, + transaction_amount: chainTotal, + swap_fee_sats: swapFee, + chain_fee_sats: minerFee, + preimage: res.preimage, + ephemeral_private_key: res.privKey, + ephemeral_public_key: res.pubkey, + service_url: swapper.getHttpUrl(), + }) + return { + swap_operation_id: newSwap.swap_operation_id, + swap_fee_sats: swapFee, + invoice_amount_sats: decoded.numSatoshis, + transaction_amount_sats: amt, + chain_fee_sats: minerFee, + service_fee_sats: serviceFee, + service_url: swapper.getHttpUrl(), + } + } + + async PayAddrWithSwap(appUserId: string, swapOpId: string, address: string, payInvoice: (invoice: string, amt: number) => Promise) { + if (!this.settings.getSettings().swapsSettings.enableSwaps) { + throw new Error("Swaps are not enabled") + } + this.log("paying address with swap", { appUserId, swapOpId, address }) + if (!swapOpId) { + throw new Error("request a swap quote before paying an external address") + } + const txSwap = await this.storage.paymentStorage.GetTransactionSwap(swapOpId, appUserId) + if (!txSwap) { + throw new Error("swap quote not found") + } + const info = await this.lnd.GetInfo() + if (info.blockHeight >= txSwap.timeout_block_height) { + throw new Error("swap timeout") + } + const swapper = this.revSwappers[txSwap.service_url] + if (!swapper) { + throw new Error("swapper service not found") + } + const keys = this.GetKeys(txSwap.ephemeral_private_key) + const data: TransactionSwapData = { + createdResponse: { + id: txSwap.swap_quote_id, + invoice: txSwap.invoice, + lockupAddress: txSwap.lockup_address, + refundPublicKey: txSwap.refund_public_key, + swapTree: txSwap.swap_tree, + timeoutBlockHeight: txSwap.timeout_block_height, + onchainAmount: txSwap.transaction_amount, + }, + info: { + destinationAddress: address, + keys, + chainFee: txSwap.chain_fee_sats, + preimage: Buffer.from(txSwap.preimage, 'hex'), + } + } + // the swap and the invoice payment are linked, swap will not start until the invoice payment is started, and will not complete once the invoice payment is completed + let swapResult = { ok: false, error: "swap never completed" } as { ok: true, txId: string } | { ok: false, error: string } + swapper.SubscribeToTransactionSwap(data, result => { + swapResult = result + }) + try { + await payInvoice(txSwap.invoice, txSwap.invoice_amount) + if (!swapResult.ok) { + this.log("invoice payment successful, but swap failed") + await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, swapResult.error) + throw new Error(swapResult.error) + } + this.log("swap completed successfully") + await this.storage.paymentStorage.FinalizeTransactionSwap(swapOpId, address, swapResult.txId) + } catch (err: any) { + if (swapResult.ok) { + this.log("failed to pay swap invoice, but swap completed successfully", swapResult.txId) + await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, err.message) + } else { + this.log("failed to pay swap invoice and swap failed", swapResult.error) + await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, swapResult.error) + } + throw err + } + const networkFeesTotal = txSwap.chain_fee_sats + txSwap.swap_fee_sats + return { + txId: swapResult.txId, + network_fee: networkFeesTotal + } + } +} \ No newline at end of file diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 294635ed..1474707a 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -5,7 +5,7 @@ import Storage from "../storage/index.js"; import * as Types from '../../../proto/autogenerated/ts/types.js' import LND from "../lnd/lnd.js"; import SettingsManager from "./settingsManager.js"; -import { Swaps } from "../lnd/swaps.js"; +import { Swaps } from "../lnd/swaps/swaps.js"; export class AdminManager { settings: SettingsManager storage: Storage diff --git a/src/services/main/init.ts b/src/services/main/init.ts index 3cbba602..121750e3 100644 --- a/src/services/main/init.ts +++ b/src/services/main/init.ts @@ -11,7 +11,7 @@ import { AdminManager } from "./adminManager.js" import SettingsManager from "./settingsManager.js" import { LoadStorageSettingsFromEnv } from "../storage/index.js" import { NostrSender } from "../nostr/sender.js" -import { Swaps } from "../lnd/swaps.js" +import { Swaps } from "../lnd/swaps/swaps.js" export type AppData = { privateKey: string; publicKey: string; diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 3c7812db..5fd04022 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -18,7 +18,7 @@ import { LiquidityManager } from './liquidityManager.js' import { Utils } from '../helpers/utilsWrapper.js' import { UserInvoicePayment } from '../storage/entity/UserInvoicePayment.js' import SettingsManager from './settingsManager.js' -import { Swaps, TransactionSwapData } from '../lnd/swaps.js' +import { Swaps, TransactionSwapData } from '../lnd/swaps/swaps.js' import { Transaction, OutputDetail } from '../../../proto/lnd/lightning.js' import { LndAddress } from '../lnd/lnd.js' import Metrics from '../metrics/index.js' @@ -618,10 +618,10 @@ export default class { } async ListSwaps(ctx: Types.UserContext): Promise { - const payments = await this.storage.paymentStorage.ListSwapPayments(ctx.app_user_id) + const payments = await this.storage.paymentStorage.ListTxSwapPayments(ctx.app_user_id) const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const isManagedUser = ctx.user_id !== app.owner.user_id - return this.swaps.ListSwaps(ctx.app_user_id, payments, p => { + return this.swaps.ListTxSwaps(ctx.app_user_id, payments, p => { const opId = `${Types.UserOperationType.OUTGOING_TX}-${p.serial_id}` return this.newInvoicePaymentOperation({ amount: p.paid_amount, confirmed: p.paid_at_unix !== 0, invoice: p.invoice, opId, networkFee: p.routing_fees, serviceFee: p.service_fees, paidAtUnix: p.paid_at_unix }) }, amt => this.getSendServiceFee(Types.UserOperationType.OUTGOING_INVOICE, amt, isManagedUser)) diff --git a/src/services/storage/db/db.ts b/src/services/storage/db/db.ts index 335f6848..63397c58 100644 --- a/src/services/storage/db/db.ts +++ b/src/services/storage/db/db.ts @@ -30,6 +30,7 @@ import * as fs from 'fs' import { UserAccess } from "../entity/UserAccess.js" import { AdminSettings } from "../entity/AdminSettings.js" import { TransactionSwap } from "../entity/TransactionSwap.js" +import { InvoiceSwap } from "../entity/InvoiceSwap.js" export type DbSettings = { @@ -76,7 +77,8 @@ export const MainDbEntities = { 'AppUserDevice': AppUserDevice, 'UserAccess': UserAccess, 'AdminSettings': AdminSettings, - 'TransactionSwap': TransactionSwap + 'TransactionSwap': TransactionSwap, + 'InvoiceSwap': InvoiceSwap } export type MainDbNames = keyof typeof MainDbEntities export const MainDbEntitiesNames = Object.keys(MainDbEntities) diff --git a/src/services/storage/entity/InvoiceSwap.ts b/src/services/storage/entity/InvoiceSwap.ts new file mode 100644 index 00000000..4548f042 --- /dev/null +++ b/src/services/storage/entity/InvoiceSwap.ts @@ -0,0 +1,74 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, UpdateDateColumn } from "typeorm"; +import { User } from "./User"; + +@Entity() +export class InvoiceSwap { + @PrimaryGeneratedColumn('uuid') + swap_operation_id: string + + @Column() + app_user_id: string + + @Column() + swap_quote_id: string + + @Column() + swap_tree: string + + @Column() + lockup_address: string + + @Column() + refund_public_key: string + + @Column() + timeout_block_height: number + + @Column() + invoice: string + + @Column() + invoice_amount: number + + @Column() + transaction_amount: number + + @Column() + swap_fee_sats: number + + @Column() + chain_fee_sats: number + + @Column() + preimage: string + + @Column() + ephemeral_public_key: string + + // the private key is used on to perform a swap, it does not hold any funds once the swap is completed + // the swap should only last a few seconds, so it is not a security risk to store the private key in the database + // the key is stored here mostly for recovery purposes, in case something goes wrong with the swap + @Column() + ephemeral_private_key: string + + @Column({ default: false }) + used: boolean + + @Column({ default: "" }) + failure_reason: string + + @Column({ default: "" }) + tx_id: string + + @Column({ default: "" }) + address_paid: string + + @Column({ default: "" }) + service_url: string + + @CreateDateColumn() + created_at: Date + + @UpdateDateColumn() + updated_at: Date +} \ No newline at end of file diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index ed3f84dd..090ae271 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -15,6 +15,7 @@ import TransactionsQueue from "./db/transactionsQueue.js"; import { LoggedEvent } from './eventsLog.js'; import { StorageInterface } from './db/storageInterface.js'; import { TransactionSwap } from './entity/TransactionSwap.js'; +import { InvoiceSwap } from './entity/InvoiceSwap.js'; export type InboundOptionals = { product?: Product, callbackUrl?: string, expiry: number, expectedPayer?: User, linkedApplication?: Application, zapInfo?: ZapInfo, offerId?: string, payerData?: Record, rejectUnauthorized?: boolean, token?: string, blind?: boolean, clinkRequesterPub?: string, clinkRequesterEventId?: string } export const defaultInvoiceExpiry = 60 * 60 export default class { @@ -500,11 +501,11 @@ export default class { return this.dbs.Find('TransactionSwap', { where: { used: false, app_user_id: appUserId } }, txId) } - async ListSwapPayments(userId: string, txId?: string) { + async ListTxSwapPayments(userId: string, txId?: string) { return this.dbs.Find('UserInvoicePayment', { where: { swap_operation_id: Not(IsNull()), user: { user_id: userId } } }, txId) } - async ListCompletedSwaps(appUserId: string, payments: UserInvoicePayment[], txId?: string) { + async ListCompletedTxSwaps(appUserId: string, payments: UserInvoicePayment[], txId?: string) { const completed = await this.dbs.Find('TransactionSwap', { where: { used: true, app_user_id: appUserId } }, txId) // const payments = await this.dbs.Find('UserInvoicePayment', { where: { swap_operation_id: Not(IsNull()), } }, txId) const paymentsMap = new Map() @@ -515,6 +516,15 @@ export default class { swap: c, payment: paymentsMap.get(c.swap_operation_id) })) } + + async AddInvoiceSwap(swap: Partial) { + return this.dbs.CreateAndSave('InvoiceSwap', swap) + } + + async GetInvoiceSwap(swapOperationId: string, appUserId: string, txId?: string) { + return this.dbs.FindOne('InvoiceSwap', { where: { swap_operation_id: swapOperationId, used: false, app_user_id: appUserId } }, txId) + } + } const orFail = async (resultPromise: Promise) => { From 03792f311117bd160bd9592b981e62b67373d7be Mon Sep 17 00:00:00 2001 From: "Sergey B." Date: Fri, 23 Jan 2026 15:30:52 +0300 Subject: [PATCH 02/49] fix: removing user ID from logs --- src/services/main/appUserManager.ts | 4 ++-- src/services/storage/userStorage.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index 9db8b5e6..168258f1 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -66,7 +66,7 @@ export default class { const appUser = await this.storage.applicationStorage.GetAppUserFromUser(app, user.user_id) if (!appUser) { - throw new Error(`app user ${ctx.user_id} not found`) // TODO: fix logs doxing + throw new Error("app user not found") } const nostrSettings = this.settings.getSettings().nostrRelaySettings const { max, serviceFeeFloor, serviceFeeBps } = this.applicationManager.paymentManager.GetMaxPayableInvoice(user.balance_sats) @@ -183,4 +183,4 @@ export default class { } this.log("Cleaned up inactive users") } -} \ No newline at end of file +} diff --git a/src/services/storage/userStorage.ts b/src/services/storage/userStorage.ts index 58dd6cf5..d3453346 100644 --- a/src/services/storage/userStorage.ts +++ b/src/services/storage/userStorage.ts @@ -42,7 +42,7 @@ export default class { async GetUser(userId: string, txId?: string): Promise { const user = await this.FindUser(userId, txId) if (!user) { - throw new Error(`user ${userId} not found`) // TODO: fix logs doxing + throw new Error(`user not found`) } return user } @@ -50,7 +50,7 @@ export default class { async UnbanUser(userId: string, txId?: string) { const affected = await this.dbs.Update('User', { user_id: userId }, { locked: false }, txId) if (!affected) { - throw new Error("unaffected user unlock for " + userId) // TODO: fix logs doxing + throw new Error("unaffected user unlock") } } @@ -58,7 +58,7 @@ export default class { const user = await this.GetUser(userId, txId) const affected = await this.dbs.Update('User', { user_id: userId }, { balance_sats: 0, locked: true }, txId) if (!affected) { - throw new Error("unaffected ban user for " + userId) // TODO: fix logs doxing + throw new Error("unaffected ban user") } if (user.balance_sats > 0) { this.eventsLog.LogEvent({ type: 'balance_decrement', userId, appId: "", appUserId: "", balance: user.balance_sats, data: 'ban', amount: user.balance_sats }) @@ -80,7 +80,7 @@ export default class { const affected = await this.dbs.Increment('User', { user_id: userId }, "balance_sats", increment, txId) if (!affected) { getLogger({ userId: userId, component: "balanceUpdates" })("user unaffected by increment") - throw new Error("unaffected balance increment for " + userId) // TODO: fix logs doxing + throw new Error("unaffected balance increment") } getLogger({ userId: userId, component: "balanceUpdates" })("incremented balance from", user.balance_sats, "sats, by", increment, "sats") this.eventsLog.LogEvent({ type: 'balance_increment', userId, appId: "", appUserId: "", balance: user.balance_sats, data: reason, amount: increment }) @@ -105,7 +105,7 @@ export default class { const affected = await this.dbs.Decrement('User', { user_id: userId }, "balance_sats", decrement, txId) if (!affected) { getLogger({ userId: userId, component: "balanceUpdates" })("user unaffected by decrement") - throw new Error("unaffected balance decrement for " + userId) // TODO: fix logs doxing + throw new Error("unaffected balance decrement") } getLogger({ userId: userId, component: "balanceUpdates" })("decremented balance from", user.balance_sats, "sats, by", decrement, "sats") this.eventsLog.LogEvent({ type: 'balance_decrement', userId, appId: "", appUserId: "", balance: user.balance_sats, data: reason, amount: decrement }) @@ -126,4 +126,4 @@ export default class { const lastSeenAtUnix = now - seconds return this.dbs.Find('UserAccess', { where: { last_seen_at_unix: LessThan(lastSeenAtUnix) } }) } -} \ No newline at end of file +} From d120ad7f998b2d7f6e27ca5c00608b18e9447823 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Tue, 27 Jan 2026 16:08:52 +0000 Subject: [PATCH 03/49] admin swaps --- datasource.js | 8 +- proto/autogenerated/client.md | 120 ++++-- proto/autogenerated/go/http_client.go | 111 ++++- proto/autogenerated/go/types.go | 58 ++- proto/autogenerated/ts/express_server.ts | 93 +++- proto/autogenerated/ts/http_client.ts | 60 ++- proto/autogenerated/ts/nostr_client.ts | 60 ++- proto/autogenerated/ts/nostr_transport.ts | 65 ++- proto/autogenerated/ts/types.ts | 404 ++++++++++++++---- proto/service/methods.proto | 33 +- proto/service/structs.proto | 51 ++- src/services/lnd/swaps/submarineSwaps.ts | 71 ++- src/services/lnd/swaps/swaps.ts | 177 +++++++- src/services/main/adminManager.ts | 27 +- src/services/main/index.ts | 1 + src/services/main/init.ts | 1 + src/services/main/paymentManager.ts | 4 +- src/services/serverMethods/index.ts | 28 +- src/services/storage/entity/InvoiceSwap.ts | 23 +- .../migrations/1769529793283-invoice_swaps.ts | 30 ++ src/services/storage/migrations/runner.ts | 3 +- src/services/storage/paymentStorage.ts | 56 ++- 22 files changed, 1258 insertions(+), 226 deletions(-) create mode 100644 src/services/storage/migrations/1769529793283-invoice_swaps.ts diff --git a/datasource.js b/datasource.js index dfae53bf..ae243ada 100644 --- a/datasource.js +++ b/datasource.js @@ -22,6 +22,7 @@ import { AppUserDevice } from "./build/src/services/storage/entity/AppUserDevice import { UserAccess } from "./build/src/services/storage/entity/UserAccess.js" import { AdminSettings } from "./build/src/services/storage/entity/AdminSettings.js" import { TransactionSwap } from "./build/src/services/storage/entity/TransactionSwap.js" +import { InvoiceSwap } from "./build/src/services/storage/entity/InvoiceSwap.js" import { Initial1703170309875 } from './build/src/services/storage/migrations/1703170309875-initial.js' import { LspOrder1718387847693 } from './build/src/services/storage/migrations/1718387847693-lsp_order.js' @@ -47,6 +48,7 @@ import { TxSwap1762890527098 } from './build/src/services/storage/migrations/176 import { TxSwapAddress1764779178945 } from './build/src/services/storage/migrations/1764779178945-tx_swap_address.js' import { ClinkRequester1765497600000 } from './build/src/services/storage/migrations/1765497600000-clink_requester.js' import { TrackedProviderHeight1766504040000 } from './build/src/services/storage/migrations/1766504040000-tracked_provider_height.js' +import { SwapsServiceUrl1768413055036 } from './build/src/services/storage/migrations/1768413055036-swaps_service_url.js' export default new DataSource({ type: "better-sqlite3", @@ -56,11 +58,11 @@ export default new DataSource({ PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000], + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036], entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment, UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo, - TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings, TransactionSwap], + TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings, TransactionSwap, InvoiceSwap], // synchronize: true, }) -//npx typeorm migration:generate ./src/services/storage/migrations/swaps_service_url -d ./datasource.js \ No newline at end of file +//npx typeorm migration:generate ./src/services/storage/migrations/invoice_swaps -d ./datasource.js \ No newline at end of file diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index 99023c69..774ed9cd 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -93,6 +93,11 @@ The nostr server will send back a message response, and inside the body there wi - input: [MessagingToken](#MessagingToken) - This methods has an __empty__ __response__ body +- GetAdminInvoiceSwapQuotes + - auth type: __Admin__ + - input: [InvoiceSwapRequest](#InvoiceSwapRequest) + - output: [InvoiceSwapQuoteList](#InvoiceSwapQuoteList) + - GetAdminTransactionSwapQuotes - auth type: __Admin__ - input: [TransactionSwapRequest](#TransactionSwapRequest) @@ -243,20 +248,25 @@ The nostr server will send back a message response, and inside the body there wi - input: [LinkNPubThroughTokenRequest](#LinkNPubThroughTokenRequest) - This methods has an __empty__ __response__ body -- ListAdminSwaps +- ListAdminInvoiceSwaps - auth type: __Admin__ - This methods has an __empty__ __request__ body - - output: [SwapsList](#SwapsList) + - output: [InvoiceSwapsList](#InvoiceSwapsList) + +- ListAdminTxSwaps + - auth type: __Admin__ + - This methods has an __empty__ __request__ body + - output: [TxSwapsList](#TxSwapsList) - ListChannels - auth type: __Admin__ - This methods has an __empty__ __request__ body - output: [LndChannels](#LndChannels) -- ListSwaps +- ListTxSwaps - auth type: __User__ - This methods has an __empty__ __request__ body - - output: [SwapsList](#SwapsList) + - output: [TxSwapsList](#TxSwapsList) - LndGetInfo - auth type: __Admin__ @@ -290,10 +300,15 @@ The nostr server will send back a message response, and inside the body there wi - input: [PayAddressRequest](#PayAddressRequest) - output: [PayAddressResponse](#PayAddressResponse) +- PayAdminInvoiceSwap + - auth type: __Admin__ + - input: [PayAdminInvoiceSwapRequest](#PayAdminInvoiceSwapRequest) + - output: [AdminInvoiceSwapResponse](#AdminInvoiceSwapResponse) + - PayAdminTransactionSwap - auth type: __Admin__ - input: [PayAdminTransactionSwapRequest](#PayAdminTransactionSwapRequest) - - output: [AdminSwapResponse](#AdminSwapResponse) + - output: [AdminTxSwapResponse](#AdminTxSwapResponse) - PayInvoice - auth type: __User__ @@ -540,6 +555,13 @@ The nostr server will send back a message response, and inside the body there wi - input: [MessagingToken](#MessagingToken) - This methods has an __empty__ __response__ body +- GetAdminInvoiceSwapQuotes + - auth type: __Admin__ + - http method: __post__ + - http route: __/api/admin/swap/invoice/quote__ + - input: [InvoiceSwapRequest](#InvoiceSwapRequest) + - output: [InvoiceSwapQuoteList](#InvoiceSwapQuoteList) + - GetAdminTransactionSwapQuotes - auth type: __Admin__ - http method: __post__ @@ -743,7 +765,7 @@ The nostr server will send back a message response, and inside the body there wi - GetTransactionSwapQuotes - auth type: __User__ - http method: __post__ - - http route: __/api/user/swap/quote__ + - http route: __/api/user/swap/transaction/quote__ - input: [TransactionSwapRequest](#TransactionSwapRequest) - output: [TransactionSwapQuoteList](#TransactionSwapQuoteList) @@ -834,12 +856,19 @@ The nostr server will send back a message response, and inside the body there wi - input: [LinkNPubThroughTokenRequest](#LinkNPubThroughTokenRequest) - This methods has an __empty__ __response__ body -- ListAdminSwaps +- ListAdminInvoiceSwaps - auth type: __Admin__ - http method: __post__ - - http route: __/api/admin/swap/list__ + - http route: __/api/admin/swap/invoice/list__ - This methods has an __empty__ __request__ body - - output: [SwapsList](#SwapsList) + - output: [InvoiceSwapsList](#InvoiceSwapsList) + +- ListAdminTxSwaps + - auth type: __Admin__ + - http method: __post__ + - http route: __/api/admin/swap/transaction/list__ + - This methods has an __empty__ __request__ body + - output: [TxSwapsList](#TxSwapsList) - ListChannels - auth type: __Admin__ @@ -848,12 +877,12 @@ The nostr server will send back a message response, and inside the body there wi - This methods has an __empty__ __request__ body - output: [LndChannels](#LndChannels) -- ListSwaps +- ListTxSwaps - auth type: __User__ - http method: __post__ - - http route: __/api/user/swap/list__ + - http route: __/api/user/swap/transaction/list__ - This methods has an __empty__ __request__ body - - output: [SwapsList](#SwapsList) + - output: [TxSwapsList](#TxSwapsList) - LndGetInfo - auth type: __Admin__ @@ -899,12 +928,19 @@ The nostr server will send back a message response, and inside the body there wi - input: [PayAddressRequest](#PayAddressRequest) - output: [PayAddressResponse](#PayAddressResponse) +- PayAdminInvoiceSwap + - auth type: __Admin__ + - http method: __post__ + - http route: __/api/admin/swap/invoice/pay__ + - input: [PayAdminInvoiceSwapRequest](#PayAdminInvoiceSwapRequest) + - output: [AdminInvoiceSwapResponse](#AdminInvoiceSwapResponse) + - PayAdminTransactionSwap - auth type: __Admin__ - http method: __post__ - http route: __/api/admin/swap/transaction/pay__ - input: [PayAdminTransactionSwapRequest](#PayAdminTransactionSwapRequest) - - output: [AdminSwapResponse](#AdminSwapResponse) + - output: [AdminTxSwapResponse](#AdminTxSwapResponse) - PayAppUserInvoice - auth type: __App__ @@ -1098,7 +1134,10 @@ The nostr server will send back a message response, and inside the body there wi - __name__: _string_ - __price_sats__: _number_ -### AdminSwapResponse +### AdminInvoiceSwapResponse + - __tx_id__: _string_ + +### AdminTxSwapResponse - __network_fee__: _number_ - __tx_id__: _string_ @@ -1331,6 +1370,35 @@ The nostr server will send back a message response, and inside the body there wi - __token__: _string_ - __url__: _string_ +### InvoiceSwapOperation + - __failure_reason__: _string_ *this field is optional + - __invoice_paid__: _string_ + - __operation_payment__: _[UserOperation](#UserOperation)_ *this field is optional + - __swap_operation_id__: _string_ + - __tx_id__: _string_ + +### InvoiceSwapQuote + - __address__: _string_ + - __chain_fee_sats__: _number_ + - __invoice__: _string_ + - __invoice_amount_sats__: _number_ + - __service_fee_sats__: _number_ + - __service_url__: _string_ + - __swap_fee_sats__: _number_ + - __swap_operation_id__: _string_ + - __transaction_amount_sats__: _number_ + - __tx_id__: _string_ + +### InvoiceSwapQuoteList + - __quotes__: ARRAY of: _[InvoiceSwapQuote](#InvoiceSwapQuote)_ + +### InvoiceSwapRequest + - __invoice__: _string_ + +### InvoiceSwapsList + - __quotes__: ARRAY of: _[InvoiceSwapQuote](#InvoiceSwapQuote)_ + - __swaps__: ARRAY of: _[InvoiceSwapOperation](#InvoiceSwapOperation)_ + ### LatestBundleMetricReq - __limit__: _number_ *this field is optional @@ -1534,6 +1602,10 @@ The nostr server will send back a message response, and inside the body there wi - __service_fee__: _number_ - __txId__: _string_ +### PayAdminInvoiceSwapRequest + - __sat_per_v_byte__: _number_ + - __swap_operation_id__: _string_ + ### PayAdminTransactionSwapRequest - __address__: _string_ - __swap_operation_id__: _string_ @@ -1640,16 +1712,6 @@ The nostr server will send back a message response, and inside the body there wi - __page__: _number_ - __request_id__: _number_ *this field is optional -### SwapOperation - - __address_paid__: _string_ - - __failure_reason__: _string_ *this field is optional - - __operation_payment__: _[UserOperation](#UserOperation)_ *this field is optional - - __swap_operation_id__: _string_ - -### SwapsList - - __quotes__: ARRAY of: _[TransactionSwapQuote](#TransactionSwapQuote)_ - - __swaps__: ARRAY of: _[SwapOperation](#SwapOperation)_ - ### TransactionSwapQuote - __chain_fee_sats__: _number_ - __invoice_amount_sats__: _number_ @@ -1665,6 +1727,16 @@ The nostr server will send back a message response, and inside the body there wi ### TransactionSwapRequest - __transaction_amount_sats__: _number_ +### TxSwapOperation + - __address_paid__: _string_ + - __failure_reason__: _string_ *this field is optional + - __operation_payment__: _[UserOperation](#UserOperation)_ *this field is optional + - __swap_operation_id__: _string_ + +### TxSwapsList + - __quotes__: ARRAY of: _[TransactionSwapQuote](#TransactionSwapQuote)_ + - __swaps__: ARRAY of: _[TxSwapOperation](#TxSwapOperation)_ + ### UpdateChannelPolicyRequest - __policy__: _[ChannelPolicy](#ChannelPolicy)_ - __update__: _[UpdateChannelPolicyRequest_update](#UpdateChannelPolicyRequest_update)_ diff --git a/proto/autogenerated/go/http_client.go b/proto/autogenerated/go/http_client.go index a2b0ecb0..a010fd82 100644 --- a/proto/autogenerated/go/http_client.go +++ b/proto/autogenerated/go/http_client.go @@ -74,6 +74,7 @@ type Client struct { EncryptionExchange func(req EncryptionExchangeRequest) error EnrollAdminToken func(req EnrollAdminTokenRequest) error EnrollMessagingToken func(req MessagingToken) error + GetAdminInvoiceSwapQuotes func(req InvoiceSwapRequest) (*InvoiceSwapQuoteList, error) GetAdminTransactionSwapQuotes func(req TransactionSwapRequest) (*TransactionSwapQuoteList, error) GetApp func() (*Application, error) GetAppUser func(req GetAppUserRequest) (*AppUser, error) @@ -114,16 +115,18 @@ type Client struct { HandleLnurlWithdraw func(query HandleLnurlWithdraw_Query) error Health func() error LinkNPubThroughToken func(req LinkNPubThroughTokenRequest) error - ListAdminSwaps func() (*SwapsList, error) + ListAdminInvoiceSwaps func() (*InvoiceSwapsList, error) + ListAdminTxSwaps func() (*TxSwapsList, error) ListChannels func() (*LndChannels, error) - ListSwaps func() (*SwapsList, error) + ListTxSwaps func() (*TxSwapsList, error) LndGetInfo func(req LndGetInfoRequest) (*LndGetInfoResponse, error) NewAddress func(req NewAddressRequest) (*NewAddressResponse, error) NewInvoice func(req NewInvoiceRequest) (*NewInvoiceResponse, error) NewProductInvoice func(query NewProductInvoice_Query) (*NewInvoiceResponse, error) OpenChannel func(req OpenChannelRequest) (*OpenChannelResponse, error) PayAddress func(req PayAddressRequest) (*PayAddressResponse, error) - PayAdminTransactionSwap func(req PayAdminTransactionSwapRequest) (*AdminSwapResponse, error) + PayAdminInvoiceSwap func(req PayAdminInvoiceSwapRequest) (*AdminInvoiceSwapResponse, error) + PayAdminTransactionSwap func(req PayAdminTransactionSwapRequest) (*AdminTxSwapResponse, error) PayAppUserInvoice func(req PayAppUserInvoiceRequest) (*PayInvoiceResponse, error) PayInvoice func(req PayInvoiceRequest) (*PayInvoiceResponse, error) PingSubProcesses func() error @@ -667,6 +670,35 @@ func NewClient(params ClientParams) *Client { } return nil }, + GetAdminInvoiceSwapQuotes: func(req InvoiceSwapRequest) (*InvoiceSwapQuoteList, error) { + auth, err := params.RetrieveAdminAuth() + if err != nil { + return nil, err + } + finalRoute := "/api/admin/swap/invoice/quote" + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) + if err != nil { + return nil, err + } + result := ResultError{} + err = json.Unmarshal(resBody, &result) + if err != nil { + return nil, err + } + if result.Status == "ERROR" { + return nil, fmt.Errorf(result.Reason) + } + res := InvoiceSwapQuoteList{} + err = json.Unmarshal(resBody, &res) + if err != nil { + return nil, err + } + return &res, nil + }, GetAdminTransactionSwapQuotes: func(req TransactionSwapRequest) (*TransactionSwapQuoteList, error) { auth, err := params.RetrieveAdminAuth() if err != nil { @@ -1324,7 +1356,7 @@ func NewClient(params ClientParams) *Client { if err != nil { return nil, err } - finalRoute := "/api/user/swap/quote" + finalRoute := "/api/user/swap/transaction/quote" body, err := json.Marshal(req) if err != nil { return nil, err @@ -1643,12 +1675,12 @@ func NewClient(params ClientParams) *Client { } return nil }, - ListAdminSwaps: func() (*SwapsList, error) { + ListAdminInvoiceSwaps: func() (*InvoiceSwapsList, error) { auth, err := params.RetrieveAdminAuth() if err != nil { return nil, err } - finalRoute := "/api/admin/swap/list" + finalRoute := "/api/admin/swap/invoice/list" body := []byte{} resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) if err != nil { @@ -1662,7 +1694,33 @@ func NewClient(params ClientParams) *Client { if result.Status == "ERROR" { return nil, fmt.Errorf(result.Reason) } - res := SwapsList{} + res := InvoiceSwapsList{} + err = json.Unmarshal(resBody, &res) + if err != nil { + return nil, err + } + return &res, nil + }, + ListAdminTxSwaps: func() (*TxSwapsList, error) { + auth, err := params.RetrieveAdminAuth() + if err != nil { + return nil, err + } + finalRoute := "/api/admin/swap/transaction/list" + body := []byte{} + resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) + if err != nil { + return nil, err + } + result := ResultError{} + err = json.Unmarshal(resBody, &result) + if err != nil { + return nil, err + } + if result.Status == "ERROR" { + return nil, fmt.Errorf(result.Reason) + } + res := TxSwapsList{} err = json.Unmarshal(resBody, &res) if err != nil { return nil, err @@ -1691,12 +1749,12 @@ func NewClient(params ClientParams) *Client { } return &res, nil }, - ListSwaps: func() (*SwapsList, error) { + ListTxSwaps: func() (*TxSwapsList, error) { auth, err := params.RetrieveUserAuth() if err != nil { return nil, err } - finalRoute := "/api/user/swap/list" + finalRoute := "/api/user/swap/transaction/list" body := []byte{} resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) if err != nil { @@ -1710,7 +1768,7 @@ func NewClient(params ClientParams) *Client { if result.Status == "ERROR" { return nil, fmt.Errorf(result.Reason) } - res := SwapsList{} + res := TxSwapsList{} err = json.Unmarshal(resBody, &res) if err != nil { return nil, err @@ -1892,7 +1950,36 @@ func NewClient(params ClientParams) *Client { } return &res, nil }, - PayAdminTransactionSwap: func(req PayAdminTransactionSwapRequest) (*AdminSwapResponse, error) { + PayAdminInvoiceSwap: func(req PayAdminInvoiceSwapRequest) (*AdminInvoiceSwapResponse, error) { + auth, err := params.RetrieveAdminAuth() + if err != nil { + return nil, err + } + finalRoute := "/api/admin/swap/invoice/pay" + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) + if err != nil { + return nil, err + } + result := ResultError{} + err = json.Unmarshal(resBody, &result) + if err != nil { + return nil, err + } + if result.Status == "ERROR" { + return nil, fmt.Errorf(result.Reason) + } + res := AdminInvoiceSwapResponse{} + err = json.Unmarshal(resBody, &res) + if err != nil { + return nil, err + } + return &res, nil + }, + PayAdminTransactionSwap: func(req PayAdminTransactionSwapRequest) (*AdminTxSwapResponse, error) { auth, err := params.RetrieveAdminAuth() if err != nil { return nil, err @@ -1914,7 +2001,7 @@ func NewClient(params ClientParams) *Client { if result.Status == "ERROR" { return nil, fmt.Errorf(result.Reason) } - res := AdminSwapResponse{} + res := AdminTxSwapResponse{} err = json.Unmarshal(resBody, &res) if err != nil { return nil, err diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index 841ced1d..f1d45300 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -123,7 +123,10 @@ type AddProductRequest struct { Name string `json:"name"` Price_sats int64 `json:"price_sats"` } -type AdminSwapResponse struct { +type AdminInvoiceSwapResponse struct { + Tx_id string `json:"tx_id"` +} +type AdminTxSwapResponse struct { Network_fee int64 `json:"network_fee"` Tx_id string `json:"tx_id"` } @@ -356,6 +359,35 @@ type HttpCreds struct { Token string `json:"token"` Url string `json:"url"` } +type InvoiceSwapOperation struct { + Failure_reason string `json:"failure_reason"` + Invoice_paid string `json:"invoice_paid"` + Operation_payment *UserOperation `json:"operation_payment"` + Swap_operation_id string `json:"swap_operation_id"` + Tx_id string `json:"tx_id"` +} +type InvoiceSwapQuote struct { + Address string `json:"address"` + Chain_fee_sats int64 `json:"chain_fee_sats"` + Invoice string `json:"invoice"` + Invoice_amount_sats int64 `json:"invoice_amount_sats"` + Service_fee_sats int64 `json:"service_fee_sats"` + Service_url string `json:"service_url"` + Swap_fee_sats int64 `json:"swap_fee_sats"` + Swap_operation_id string `json:"swap_operation_id"` + Transaction_amount_sats int64 `json:"transaction_amount_sats"` + Tx_id string `json:"tx_id"` +} +type InvoiceSwapQuoteList struct { + Quotes []InvoiceSwapQuote `json:"quotes"` +} +type InvoiceSwapRequest struct { + Invoice string `json:"invoice"` +} +type InvoiceSwapsList struct { + Quotes []InvoiceSwapQuote `json:"quotes"` + Swaps []InvoiceSwapOperation `json:"swaps"` +} type LatestBundleMetricReq struct { Limit int64 `json:"limit"` } @@ -559,6 +591,10 @@ type PayAddressResponse struct { Service_fee int64 `json:"service_fee"` Txid string `json:"txId"` } +type PayAdminInvoiceSwapRequest struct { + Sat_per_v_byte int64 `json:"sat_per_v_byte"` + Swap_operation_id string `json:"swap_operation_id"` +} type PayAdminTransactionSwapRequest struct { Address string `json:"address"` Swap_operation_id string `json:"swap_operation_id"` @@ -665,16 +701,6 @@ type SingleMetricReq struct { Page int64 `json:"page"` Request_id int64 `json:"request_id"` } -type SwapOperation struct { - Address_paid string `json:"address_paid"` - Failure_reason string `json:"failure_reason"` - Operation_payment *UserOperation `json:"operation_payment"` - Swap_operation_id string `json:"swap_operation_id"` -} -type SwapsList struct { - Quotes []TransactionSwapQuote `json:"quotes"` - Swaps []SwapOperation `json:"swaps"` -} type TransactionSwapQuote struct { Chain_fee_sats int64 `json:"chain_fee_sats"` Invoice_amount_sats int64 `json:"invoice_amount_sats"` @@ -690,6 +716,16 @@ type TransactionSwapQuoteList struct { type TransactionSwapRequest struct { Transaction_amount_sats int64 `json:"transaction_amount_sats"` } +type TxSwapOperation struct { + Address_paid string `json:"address_paid"` + Failure_reason string `json:"failure_reason"` + Operation_payment *UserOperation `json:"operation_payment"` + Swap_operation_id string `json:"swap_operation_id"` +} +type TxSwapsList struct { + Quotes []TransactionSwapQuote `json:"quotes"` + Swaps []TxSwapOperation `json:"swaps"` +} type UpdateChannelPolicyRequest struct { Policy *ChannelPolicy `json:"policy"` Update *UpdateChannelPolicyRequest_update `json:"update"` diff --git a/proto/autogenerated/ts/express_server.ts b/proto/autogenerated/ts/express_server.ts index c1740acd..8ce0aa96 100644 --- a/proto/autogenerated/ts/express_server.ts +++ b/proto/autogenerated/ts/express_server.ts @@ -545,12 +545,12 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) } break - case 'ListSwaps': - if (!methods.ListSwaps) { - throw new Error('method ListSwaps not found' ) + case 'ListTxSwaps': + if (!methods.ListTxSwaps) { + throw new Error('method ListTxSwaps not found' ) } else { opStats.validate = opStats.guard - const res = await methods.ListSwaps({...operation, ctx}); responses.push({ status: 'OK', ...res }) + const res = await methods.ListTxSwaps({...operation, ctx}); responses.push({ status: 'OK', ...res }) opStats.handle = process.hrtime.bigint() callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) } @@ -869,6 +869,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { 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 } }) + if (!opts.allowNotImplementedMethods && !methods.GetAdminInvoiceSwapQuotes) throw new Error('method: GetAdminInvoiceSwapQuotes is not implemented') + app.post('/api/admin/swap/invoice/quote', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'GetAdminInvoiceSwapQuotes', batch: false, nostr: false, batchSize: 0} + const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } + let authCtx: Types.AuthContext = {} + try { + if (!methods.GetAdminInvoiceSwapQuotes) throw new Error('method: GetAdminInvoiceSwapQuotes is not implemented') + const authContext = await opts.AdminAuthGuard(req.headers['authorization']) + authCtx = authContext + stats.guard = process.hrtime.bigint() + const request = req.body + const error = Types.InvoiceSwapRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback) + const query = req.query + const params = req.params + const response = await methods.GetAdminInvoiceSwapQuotes({rpcName:'GetAdminInvoiceSwapQuotes', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res.json({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 } + }) if (!opts.allowNotImplementedMethods && !methods.GetAdminTransactionSwapQuotes) throw new Error('method: GetAdminTransactionSwapQuotes is not implemented') app.post('/api/admin/swap/transaction/quote', async (req, res) => { const info: Types.RequestInfo = { rpcName: 'GetAdminTransactionSwapQuotes', batch: false, nostr: false, batchSize: 0} @@ -1362,7 +1384,7 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }) if (!opts.allowNotImplementedMethods && !methods.GetTransactionSwapQuotes) throw new Error('method: GetTransactionSwapQuotes is not implemented') - app.post('/api/user/swap/quote', async (req, res) => { + app.post('/api/user/swap/transaction/quote', async (req, res) => { const info: Types.RequestInfo = { rpcName: 'GetTransactionSwapQuotes', batch: false, nostr: false, batchSize: 0} const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } let authCtx: Types.AuthContext = {} @@ -1607,20 +1629,39 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { 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 } }) - if (!opts.allowNotImplementedMethods && !methods.ListAdminSwaps) throw new Error('method: ListAdminSwaps is not implemented') - app.post('/api/admin/swap/list', async (req, res) => { - const info: Types.RequestInfo = { rpcName: 'ListAdminSwaps', batch: false, nostr: false, batchSize: 0} + if (!opts.allowNotImplementedMethods && !methods.ListAdminInvoiceSwaps) throw new Error('method: ListAdminInvoiceSwaps is not implemented') + app.post('/api/admin/swap/invoice/list', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'ListAdminInvoiceSwaps', batch: false, nostr: false, batchSize: 0} const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } let authCtx: Types.AuthContext = {} try { - if (!methods.ListAdminSwaps) throw new Error('method: ListAdminSwaps is not implemented') + if (!methods.ListAdminInvoiceSwaps) throw new Error('method: ListAdminInvoiceSwaps is not implemented') const authContext = await opts.AdminAuthGuard(req.headers['authorization']) authCtx = authContext stats.guard = process.hrtime.bigint() stats.validate = stats.guard const query = req.query const params = req.params - const response = await methods.ListAdminSwaps({rpcName:'ListAdminSwaps', ctx:authContext }) + const response = await methods.ListAdminInvoiceSwaps({rpcName:'ListAdminInvoiceSwaps', ctx:authContext }) + stats.handle = process.hrtime.bigint() + res.json({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 } + }) + if (!opts.allowNotImplementedMethods && !methods.ListAdminTxSwaps) throw new Error('method: ListAdminTxSwaps is not implemented') + app.post('/api/admin/swap/transaction/list', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'ListAdminTxSwaps', batch: false, nostr: false, batchSize: 0} + const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } + let authCtx: Types.AuthContext = {} + try { + if (!methods.ListAdminTxSwaps) throw new Error('method: ListAdminTxSwaps is not implemented') + const authContext = await opts.AdminAuthGuard(req.headers['authorization']) + authCtx = authContext + stats.guard = process.hrtime.bigint() + stats.validate = stats.guard + const query = req.query + const params = req.params + const response = await methods.ListAdminTxSwaps({rpcName:'ListAdminTxSwaps', ctx:authContext }) stats.handle = process.hrtime.bigint() res.json({status: 'OK', ...response}) opts.metricsCallback([{ ...info, ...stats, ...authContext }]) @@ -1645,20 +1686,20 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { 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 } }) - if (!opts.allowNotImplementedMethods && !methods.ListSwaps) throw new Error('method: ListSwaps is not implemented') - app.post('/api/user/swap/list', async (req, res) => { - const info: Types.RequestInfo = { rpcName: 'ListSwaps', batch: false, nostr: false, batchSize: 0} + if (!opts.allowNotImplementedMethods && !methods.ListTxSwaps) throw new Error('method: ListTxSwaps is not implemented') + app.post('/api/user/swap/transaction/list', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'ListTxSwaps', batch: false, nostr: false, batchSize: 0} const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } let authCtx: Types.AuthContext = {} try { - if (!methods.ListSwaps) throw new Error('method: ListSwaps is not implemented') + if (!methods.ListTxSwaps) throw new Error('method: ListTxSwaps is not implemented') const authContext = await opts.UserAuthGuard(req.headers['authorization']) authCtx = authContext stats.guard = process.hrtime.bigint() stats.validate = stats.guard const query = req.query const params = req.params - const response = await methods.ListSwaps({rpcName:'ListSwaps', ctx:authContext }) + const response = await methods.ListTxSwaps({rpcName:'ListTxSwaps', ctx:authContext }) stats.handle = process.hrtime.bigint() res.json({status: 'OK', ...response}) opts.metricsCallback([{ ...info, ...stats, ...authContext }]) @@ -1793,6 +1834,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { 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 } }) + if (!opts.allowNotImplementedMethods && !methods.PayAdminInvoiceSwap) throw new Error('method: PayAdminInvoiceSwap is not implemented') + app.post('/api/admin/swap/invoice/pay', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'PayAdminInvoiceSwap', batch: false, nostr: false, batchSize: 0} + const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } + let authCtx: Types.AuthContext = {} + try { + if (!methods.PayAdminInvoiceSwap) throw new Error('method: PayAdminInvoiceSwap is not implemented') + const authContext = await opts.AdminAuthGuard(req.headers['authorization']) + authCtx = authContext + stats.guard = process.hrtime.bigint() + const request = req.body + const error = Types.PayAdminInvoiceSwapRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback) + const query = req.query + const params = req.params + const response = await methods.PayAdminInvoiceSwap({rpcName:'PayAdminInvoiceSwap', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res.json({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 } + }) if (!opts.allowNotImplementedMethods && !methods.PayAdminTransactionSwap) throw new Error('method: PayAdminTransactionSwap is not implemented') app.post('/api/admin/swap/transaction/pay', async (req, res) => { const info: Types.RequestInfo = { rpcName: 'PayAdminTransactionSwap', batch: false, nostr: false, batchSize: 0} diff --git a/proto/autogenerated/ts/http_client.ts b/proto/autogenerated/ts/http_client.ts index a24ff3e6..e84470eb 100644 --- a/proto/autogenerated/ts/http_client.ts +++ b/proto/autogenerated/ts/http_client.ts @@ -273,6 +273,20 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, + GetAdminInvoiceSwapQuotes: async (request: Types.InvoiceSwapRequest): Promise => { + const auth = await params.retrieveAdminAuth() + if (auth === null) throw new Error('retrieveAdminAuth() returned null') + let finalRoute = '/api/admin/swap/invoice/quote' + const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.InvoiceSwapQuoteListValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, GetAdminTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise => { const auth = await params.retrieveAdminAuth() if (auth === null) throw new Error('retrieveAdminAuth() returned null') @@ -620,7 +634,7 @@ export default (params: ClientParams) => ({ GetTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise => { const auth = await params.retrieveUserAuth() if (auth === null) throw new Error('retrieveUserAuth() returned null') - let finalRoute = '/api/user/swap/quote' + let finalRoute = '/api/user/swap/transaction/quote' const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'OK') { @@ -781,16 +795,30 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, - ListAdminSwaps: async (): Promise => { + ListAdminInvoiceSwaps: async (): Promise => { const auth = await params.retrieveAdminAuth() if (auth === null) throw new Error('retrieveAdminAuth() returned null') - let finalRoute = '/api/admin/swap/list' + let finalRoute = '/api/admin/swap/invoice/list' const { data } = await axios.post(params.baseUrl + finalRoute, {}, { headers: { 'authorization': auth } }) if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'OK') { const result = data if(!params.checkResult) return { status: 'OK', ...result } - const error = Types.SwapsListValidate(result) + const error = Types.InvoiceSwapsListValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, + ListAdminTxSwaps: async (): Promise => { + const auth = await params.retrieveAdminAuth() + if (auth === null) throw new Error('retrieveAdminAuth() returned null') + let finalRoute = '/api/admin/swap/transaction/list' + const { data } = await axios.post(params.baseUrl + finalRoute, {}, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.TxSwapsListValidate(result) if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } } return { status: 'ERROR', reason: 'invalid response' } @@ -809,16 +837,16 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, - ListSwaps: async (): Promise => { + ListTxSwaps: async (): Promise => { const auth = await params.retrieveUserAuth() if (auth === null) throw new Error('retrieveUserAuth() returned null') - let finalRoute = '/api/user/swap/list' + let finalRoute = '/api/user/swap/transaction/list' const { data } = await axios.post(params.baseUrl + finalRoute, {}, { headers: { 'authorization': auth } }) if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'OK') { const result = data if(!params.checkResult) return { status: 'OK', ...result } - const error = Types.SwapsListValidate(result) + const error = Types.TxSwapsListValidate(result) if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } } return { status: 'ERROR', reason: 'invalid response' } @@ -909,7 +937,21 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, - PayAdminTransactionSwap: async (request: Types.PayAdminTransactionSwapRequest): Promise => { + PayAdminInvoiceSwap: async (request: Types.PayAdminInvoiceSwapRequest): Promise => { + const auth = await params.retrieveAdminAuth() + if (auth === null) throw new Error('retrieveAdminAuth() returned null') + let finalRoute = '/api/admin/swap/invoice/pay' + const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.AdminInvoiceSwapResponseValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, + PayAdminTransactionSwap: async (request: Types.PayAdminTransactionSwapRequest): Promise => { const auth = await params.retrieveAdminAuth() if (auth === null) throw new Error('retrieveAdminAuth() returned null') let finalRoute = '/api/admin/swap/transaction/pay' @@ -918,7 +960,7 @@ export default (params: ClientParams) => ({ if (data.status === 'OK') { const result = data if(!params.checkResult) return { status: 'OK', ...result } - const error = Types.AdminSwapResponseValidate(result) + const error = Types.AdminTxSwapResponseValidate(result) if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } } return { status: 'ERROR', reason: 'invalid response' } diff --git a/proto/autogenerated/ts/nostr_client.ts b/proto/autogenerated/ts/nostr_client.ts index df969c7b..2db7e8ce 100644 --- a/proto/autogenerated/ts/nostr_client.ts +++ b/proto/autogenerated/ts/nostr_client.ts @@ -230,6 +230,21 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, + GetAdminInvoiceSwapQuotes: async (request: Types.InvoiceSwapRequest): Promise => { + const auth = await params.retrieveNostrAdminAuth() + if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') + const nostrRequest: NostrRequest = {} + nostrRequest.body = request + const data = await send(params.pubDestination, {rpcName:'GetAdminInvoiceSwapQuotes',authIdentifier:auth, ...nostrRequest }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.InvoiceSwapQuoteListValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, GetAdminTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise => { const auth = await params.retrieveNostrAdminAuth() if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') @@ -666,16 +681,30 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, - ListAdminSwaps: async (): Promise => { + ListAdminInvoiceSwaps: async (): Promise => { const auth = await params.retrieveNostrAdminAuth() if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') const nostrRequest: NostrRequest = {} - const data = await send(params.pubDestination, {rpcName:'ListAdminSwaps',authIdentifier:auth, ...nostrRequest }) + const data = await send(params.pubDestination, {rpcName:'ListAdminInvoiceSwaps',authIdentifier:auth, ...nostrRequest }) if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'OK') { const result = data if(!params.checkResult) return { status: 'OK', ...result } - const error = Types.SwapsListValidate(result) + const error = Types.InvoiceSwapsListValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, + ListAdminTxSwaps: async (): Promise => { + const auth = await params.retrieveNostrAdminAuth() + if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') + const nostrRequest: NostrRequest = {} + const data = await send(params.pubDestination, {rpcName:'ListAdminTxSwaps',authIdentifier:auth, ...nostrRequest }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.TxSwapsListValidate(result) if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } } return { status: 'ERROR', reason: 'invalid response' } @@ -694,16 +723,16 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, - ListSwaps: async (): Promise => { + ListTxSwaps: async (): Promise => { const auth = await params.retrieveNostrUserAuth() if (auth === null) throw new Error('retrieveNostrUserAuth() returned null') const nostrRequest: NostrRequest = {} - const data = await send(params.pubDestination, {rpcName:'ListSwaps',authIdentifier:auth, ...nostrRequest }) + const data = await send(params.pubDestination, {rpcName:'ListTxSwaps',authIdentifier:auth, ...nostrRequest }) if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'OK') { const result = data if(!params.checkResult) return { status: 'OK', ...result } - const error = Types.SwapsListValidate(result) + const error = Types.TxSwapsListValidate(result) if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } } return { status: 'ERROR', reason: 'invalid response' } @@ -798,7 +827,22 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, - PayAdminTransactionSwap: async (request: Types.PayAdminTransactionSwapRequest): Promise => { + PayAdminInvoiceSwap: async (request: Types.PayAdminInvoiceSwapRequest): Promise => { + const auth = await params.retrieveNostrAdminAuth() + if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') + const nostrRequest: NostrRequest = {} + nostrRequest.body = request + const data = await send(params.pubDestination, {rpcName:'PayAdminInvoiceSwap',authIdentifier:auth, ...nostrRequest }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.AdminInvoiceSwapResponseValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, + PayAdminTransactionSwap: async (request: Types.PayAdminTransactionSwapRequest): Promise => { const auth = await params.retrieveNostrAdminAuth() if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') const nostrRequest: NostrRequest = {} @@ -808,7 +852,7 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ if (data.status === 'OK') { const result = data if(!params.checkResult) return { status: 'OK', ...result } - const error = Types.AdminSwapResponseValidate(result) + const error = Types.AdminTxSwapResponseValidate(result) if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } } return { status: 'ERROR', reason: 'invalid response' } diff --git a/proto/autogenerated/ts/nostr_transport.ts b/proto/autogenerated/ts/nostr_transport.ts index 24a96cd6..8a586d5b 100644 --- a/proto/autogenerated/ts/nostr_transport.ts +++ b/proto/autogenerated/ts/nostr_transport.ts @@ -427,12 +427,12 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => { callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) } break - case 'ListSwaps': - if (!methods.ListSwaps) { - throw new Error('method not defined: ListSwaps') + case 'ListTxSwaps': + if (!methods.ListTxSwaps) { + throw new Error('method not defined: ListTxSwaps') } else { opStats.validate = opStats.guard - const res = await methods.ListSwaps({...operation, ctx}); responses.push({ status: 'OK', ...res }) + const res = await methods.ListTxSwaps({...operation, ctx}); responses.push({ status: 'OK', ...res }) opStats.handle = process.hrtime.bigint() callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) } @@ -687,6 +687,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 'GetAdminInvoiceSwapQuotes': + try { + if (!methods.GetAdminInvoiceSwapQuotes) throw new Error('method: GetAdminInvoiceSwapQuotes is not implemented') + const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + const request = req.body + const error = Types.InvoiceSwapRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback) + const response = await methods.GetAdminInvoiceSwapQuotes({rpcName:'GetAdminInvoiceSwapQuotes', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + 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 'GetAdminTransactionSwapQuotes': try { if (!methods.GetAdminTransactionSwapQuotes) throw new Error('method: GetAdminTransactionSwapQuotes is not implemented') @@ -1122,14 +1138,27 @@ 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 'ListAdminSwaps': + case 'ListAdminInvoiceSwaps': try { - if (!methods.ListAdminSwaps) throw new Error('method: ListAdminSwaps is not implemented') + if (!methods.ListAdminInvoiceSwaps) throw new Error('method: ListAdminInvoiceSwaps is not implemented') const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier) stats.guard = process.hrtime.bigint() authCtx = authContext stats.validate = stats.guard - const response = await methods.ListAdminSwaps({rpcName:'ListAdminSwaps', ctx:authContext }) + const response = await methods.ListAdminInvoiceSwaps({rpcName:'ListAdminInvoiceSwaps', ctx:authContext }) + stats.handle = process.hrtime.bigint() + 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 'ListAdminTxSwaps': + try { + if (!methods.ListAdminTxSwaps) throw new Error('method: ListAdminTxSwaps is not implemented') + const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + stats.validate = stats.guard + const response = await methods.ListAdminTxSwaps({rpcName:'ListAdminTxSwaps', ctx:authContext }) stats.handle = process.hrtime.bigint() res({status: 'OK', ...response}) opts.metricsCallback([{ ...info, ...stats, ...authContext }]) @@ -1148,14 +1177,14 @@ 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 'ListSwaps': + case 'ListTxSwaps': try { - if (!methods.ListSwaps) throw new Error('method: ListSwaps is not implemented') + if (!methods.ListTxSwaps) throw new Error('method: ListTxSwaps is not implemented') const authContext = await opts.NostrUserAuthGuard(req.appId, req.authIdentifier) stats.guard = process.hrtime.bigint() authCtx = authContext stats.validate = stats.guard - const response = await methods.ListSwaps({rpcName:'ListSwaps', ctx:authContext }) + const response = await methods.ListTxSwaps({rpcName:'ListTxSwaps', ctx:authContext }) stats.handle = process.hrtime.bigint() res({status: 'OK', ...response}) opts.metricsCallback([{ ...info, ...stats, ...authContext }]) @@ -1254,6 +1283,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 'PayAdminInvoiceSwap': + try { + if (!methods.PayAdminInvoiceSwap) throw new Error('method: PayAdminInvoiceSwap is not implemented') + const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + const request = req.body + const error = Types.PayAdminInvoiceSwapRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback) + const response = await methods.PayAdminInvoiceSwap({rpcName:'PayAdminInvoiceSwap', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + 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 'PayAdminTransactionSwap': try { if (!methods.PayAdminTransactionSwap) throw new Error('method: PayAdminTransactionSwap is not implemented') diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index e397cd94..78add454 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -7,8 +7,8 @@ export type RequestMetric = AuthContext & RequestInfo & RequestStats & { error?: export type AdminContext = { admin_id: string } -export type AdminMethodInputs = AddApp_Input | AddPeer_Input | AuthApp_Input | BanUser_Input | CloseChannel_Input | CreateOneTimeInviteLink_Input | GetAdminTransactionSwapQuotes_Input | GetInviteLinkState_Input | GetSeed_Input | ListAdminSwaps_Input | ListChannels_Input | LndGetInfo_Input | OpenChannel_Input | PayAdminTransactionSwap_Input | UpdateChannelPolicy_Input -export type AdminMethodOutputs = AddApp_Output | AddPeer_Output | AuthApp_Output | BanUser_Output | CloseChannel_Output | CreateOneTimeInviteLink_Output | GetAdminTransactionSwapQuotes_Output | GetInviteLinkState_Output | GetSeed_Output | ListAdminSwaps_Output | ListChannels_Output | LndGetInfo_Output | OpenChannel_Output | PayAdminTransactionSwap_Output | UpdateChannelPolicy_Output +export type AdminMethodInputs = AddApp_Input | AddPeer_Input | AuthApp_Input | BanUser_Input | CloseChannel_Input | CreateOneTimeInviteLink_Input | GetAdminInvoiceSwapQuotes_Input | GetAdminTransactionSwapQuotes_Input | GetInviteLinkState_Input | GetSeed_Input | ListAdminInvoiceSwaps_Input | ListAdminTxSwaps_Input | ListChannels_Input | LndGetInfo_Input | OpenChannel_Input | PayAdminInvoiceSwap_Input | PayAdminTransactionSwap_Input | UpdateChannelPolicy_Input +export type AdminMethodOutputs = AddApp_Output | AddPeer_Output | AuthApp_Output | BanUser_Output | CloseChannel_Output | CreateOneTimeInviteLink_Output | GetAdminInvoiceSwapQuotes_Output | GetAdminTransactionSwapQuotes_Output | GetInviteLinkState_Output | GetSeed_Output | ListAdminInvoiceSwaps_Output | ListAdminTxSwaps_Output | ListChannels_Output | LndGetInfo_Output | OpenChannel_Output | PayAdminInvoiceSwap_Output | PayAdminTransactionSwap_Output | UpdateChannelPolicy_Output export type AppContext = { app_id: string } @@ -35,8 +35,8 @@ export type UserContext = { app_user_id: string user_id: string } -export type UserMethodInputs = AddProduct_Input | AddUserOffer_Input | AuthorizeManage_Input | BanDebit_Input | DecodeInvoice_Input | DeleteUserOffer_Input | EditDebit_Input | EnrollAdminToken_Input | EnrollMessagingToken_Input | GetDebitAuthorizations_Input | GetHttpCreds_Input | GetLNURLChannelLink_Input | GetLnurlPayLink_Input | GetLnurlWithdrawLink_Input | GetManageAuthorizations_Input | GetPaymentState_Input | GetTransactionSwapQuotes_Input | GetUserInfo_Input | GetUserOffer_Input | GetUserOfferInvoices_Input | GetUserOffers_Input | GetUserOperations_Input | ListSwaps_Input | NewAddress_Input | NewInvoice_Input | NewProductInvoice_Input | PayAddress_Input | PayInvoice_Input | ResetDebit_Input | ResetManage_Input | RespondToDebit_Input | UpdateCallbackUrl_Input | UpdateUserOffer_Input | UserHealth_Input -export type UserMethodOutputs = AddProduct_Output | AddUserOffer_Output | AuthorizeManage_Output | BanDebit_Output | DecodeInvoice_Output | DeleteUserOffer_Output | EditDebit_Output | EnrollAdminToken_Output | EnrollMessagingToken_Output | GetDebitAuthorizations_Output | GetHttpCreds_Output | GetLNURLChannelLink_Output | GetLnurlPayLink_Output | GetLnurlWithdrawLink_Output | GetManageAuthorizations_Output | GetPaymentState_Output | GetTransactionSwapQuotes_Output | GetUserInfo_Output | GetUserOffer_Output | GetUserOfferInvoices_Output | GetUserOffers_Output | GetUserOperations_Output | ListSwaps_Output | NewAddress_Output | NewInvoice_Output | NewProductInvoice_Output | PayAddress_Output | PayInvoice_Output | ResetDebit_Output | ResetManage_Output | RespondToDebit_Output | UpdateCallbackUrl_Output | UpdateUserOffer_Output | UserHealth_Output +export type UserMethodInputs = AddProduct_Input | AddUserOffer_Input | AuthorizeManage_Input | BanDebit_Input | DecodeInvoice_Input | DeleteUserOffer_Input | EditDebit_Input | EnrollAdminToken_Input | EnrollMessagingToken_Input | GetDebitAuthorizations_Input | GetHttpCreds_Input | GetLNURLChannelLink_Input | GetLnurlPayLink_Input | GetLnurlWithdrawLink_Input | GetManageAuthorizations_Input | GetPaymentState_Input | GetTransactionSwapQuotes_Input | GetUserInfo_Input | GetUserOffer_Input | GetUserOfferInvoices_Input | GetUserOffers_Input | GetUserOperations_Input | ListTxSwaps_Input | NewAddress_Input | NewInvoice_Input | NewProductInvoice_Input | PayAddress_Input | PayInvoice_Input | ResetDebit_Input | ResetManage_Input | RespondToDebit_Input | UpdateCallbackUrl_Input | UpdateUserOffer_Input | UserHealth_Input +export type UserMethodOutputs = AddProduct_Output | AddUserOffer_Output | AuthorizeManage_Output | BanDebit_Output | DecodeInvoice_Output | DeleteUserOffer_Output | EditDebit_Output | EnrollAdminToken_Output | EnrollMessagingToken_Output | GetDebitAuthorizations_Output | GetHttpCreds_Output | GetLNURLChannelLink_Output | GetLnurlPayLink_Output | GetLnurlWithdrawLink_Output | GetManageAuthorizations_Output | GetPaymentState_Output | GetTransactionSwapQuotes_Output | GetUserInfo_Output | GetUserOffer_Output | GetUserOfferInvoices_Output | GetUserOffers_Output | GetUserOperations_Output | ListTxSwaps_Output | NewAddress_Output | NewInvoice_Output | NewProductInvoice_Output | PayAddress_Output | PayInvoice_Output | ResetDebit_Output | ResetManage_Output | RespondToDebit_Output | UpdateCallbackUrl_Output | UpdateUserOffer_Output | UserHealth_Output export type AuthContext = AdminContext | AppContext | GuestContext | GuestWithPubContext | MetricsContext | UserContext export type AddApp_Input = {rpcName:'AddApp', req: AddAppRequest} @@ -99,6 +99,9 @@ export type EnrollAdminToken_Output = ResultError | { status: 'OK' } export type EnrollMessagingToken_Input = {rpcName:'EnrollMessagingToken', req: MessagingToken} export type EnrollMessagingToken_Output = ResultError | { status: 'OK' } +export type GetAdminInvoiceSwapQuotes_Input = {rpcName:'GetAdminInvoiceSwapQuotes', req: InvoiceSwapRequest} +export type GetAdminInvoiceSwapQuotes_Output = ResultError | ({ status: 'OK' } & InvoiceSwapQuoteList) + export type GetAdminTransactionSwapQuotes_Input = {rpcName:'GetAdminTransactionSwapQuotes', req: TransactionSwapRequest} export type GetAdminTransactionSwapQuotes_Output = ResultError | ({ status: 'OK' } & TransactionSwapQuoteList) @@ -238,14 +241,17 @@ export type Health_Output = ResultError | { status: 'OK' } export type LinkNPubThroughToken_Input = {rpcName:'LinkNPubThroughToken', req: LinkNPubThroughTokenRequest} export type LinkNPubThroughToken_Output = ResultError | { status: 'OK' } -export type ListAdminSwaps_Input = {rpcName:'ListAdminSwaps'} -export type ListAdminSwaps_Output = ResultError | ({ status: 'OK' } & SwapsList) +export type ListAdminInvoiceSwaps_Input = {rpcName:'ListAdminInvoiceSwaps'} +export type ListAdminInvoiceSwaps_Output = ResultError | ({ status: 'OK' } & InvoiceSwapsList) + +export type ListAdminTxSwaps_Input = {rpcName:'ListAdminTxSwaps'} +export type ListAdminTxSwaps_Output = ResultError | ({ status: 'OK' } & TxSwapsList) export type ListChannels_Input = {rpcName:'ListChannels'} export type ListChannels_Output = ResultError | ({ status: 'OK' } & LndChannels) -export type ListSwaps_Input = {rpcName:'ListSwaps'} -export type ListSwaps_Output = ResultError | ({ status: 'OK' } & SwapsList) +export type ListTxSwaps_Input = {rpcName:'ListTxSwaps'} +export type ListTxSwaps_Output = ResultError | ({ status: 'OK' } & TxSwapsList) export type LndGetInfo_Input = {rpcName:'LndGetInfo', req: LndGetInfoRequest} export type LndGetInfo_Output = ResultError | ({ status: 'OK' } & LndGetInfoResponse) @@ -268,8 +274,11 @@ export type OpenChannel_Output = ResultError | ({ status: 'OK' } & OpenChannelRe export type PayAddress_Input = {rpcName:'PayAddress', req: PayAddressRequest} export type PayAddress_Output = ResultError | ({ status: 'OK' } & PayAddressResponse) +export type PayAdminInvoiceSwap_Input = {rpcName:'PayAdminInvoiceSwap', req: PayAdminInvoiceSwapRequest} +export type PayAdminInvoiceSwap_Output = ResultError | ({ status: 'OK' } & AdminInvoiceSwapResponse) + export type PayAdminTransactionSwap_Input = {rpcName:'PayAdminTransactionSwap', req: PayAdminTransactionSwapRequest} -export type PayAdminTransactionSwap_Output = ResultError | ({ status: 'OK' } & AdminSwapResponse) +export type PayAdminTransactionSwap_Output = ResultError | ({ status: 'OK' } & AdminTxSwapResponse) export type PayAppUserInvoice_Input = {rpcName:'PayAppUserInvoice', req: PayAppUserInvoiceRequest} export type PayAppUserInvoice_Output = ResultError | ({ status: 'OK' } & PayInvoiceResponse) @@ -357,6 +366,7 @@ export type ServerMethods = { EncryptionExchange?: (req: EncryptionExchange_Input & {ctx: GuestContext }) => Promise EnrollAdminToken?: (req: EnrollAdminToken_Input & {ctx: UserContext }) => Promise EnrollMessagingToken?: (req: EnrollMessagingToken_Input & {ctx: UserContext }) => Promise + GetAdminInvoiceSwapQuotes?: (req: GetAdminInvoiceSwapQuotes_Input & {ctx: AdminContext }) => Promise GetAdminTransactionSwapQuotes?: (req: GetAdminTransactionSwapQuotes_Input & {ctx: AdminContext }) => Promise GetApp?: (req: GetApp_Input & {ctx: AppContext }) => Promise GetAppUser?: (req: GetAppUser_Input & {ctx: AppContext }) => Promise @@ -397,16 +407,18 @@ export type ServerMethods = { HandleLnurlWithdraw?: (req: HandleLnurlWithdraw_Input & {ctx: GuestContext }) => Promise Health?: (req: Health_Input & {ctx: GuestContext }) => Promise LinkNPubThroughToken?: (req: LinkNPubThroughToken_Input & {ctx: GuestWithPubContext }) => Promise - ListAdminSwaps?: (req: ListAdminSwaps_Input & {ctx: AdminContext }) => Promise + ListAdminInvoiceSwaps?: (req: ListAdminInvoiceSwaps_Input & {ctx: AdminContext }) => Promise + ListAdminTxSwaps?: (req: ListAdminTxSwaps_Input & {ctx: AdminContext }) => Promise ListChannels?: (req: ListChannels_Input & {ctx: AdminContext }) => Promise - ListSwaps?: (req: ListSwaps_Input & {ctx: UserContext }) => Promise + ListTxSwaps?: (req: ListTxSwaps_Input & {ctx: UserContext }) => Promise LndGetInfo?: (req: LndGetInfo_Input & {ctx: AdminContext }) => Promise NewAddress?: (req: NewAddress_Input & {ctx: UserContext }) => Promise NewInvoice?: (req: NewInvoice_Input & {ctx: UserContext }) => Promise NewProductInvoice?: (req: NewProductInvoice_Input & {ctx: UserContext }) => Promise OpenChannel?: (req: OpenChannel_Input & {ctx: AdminContext }) => Promise PayAddress?: (req: PayAddress_Input & {ctx: UserContext }) => Promise - PayAdminTransactionSwap?: (req: PayAdminTransactionSwap_Input & {ctx: AdminContext }) => Promise + PayAdminInvoiceSwap?: (req: PayAdminInvoiceSwap_Input & {ctx: AdminContext }) => Promise + PayAdminTransactionSwap?: (req: PayAdminTransactionSwap_Input & {ctx: AdminContext }) => Promise PayAppUserInvoice?: (req: PayAppUserInvoice_Input & {ctx: AppContext }) => Promise PayInvoice?: (req: PayInvoice_Input & {ctx: UserContext }) => Promise PingSubProcesses?: (req: PingSubProcesses_Input & {ctx: MetricsContext }) => Promise @@ -671,17 +683,35 @@ export const AddProductRequestValidate = (o?: AddProductRequest, opts: AddProduc return null } -export type AdminSwapResponse = { +export type AdminInvoiceSwapResponse = { + tx_id: string +} +export const AdminInvoiceSwapResponseOptionalFields: [] = [] +export type AdminInvoiceSwapResponseOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + tx_id_CustomCheck?: (v: string) => boolean +} +export const AdminInvoiceSwapResponseValidate = (o?: AdminInvoiceSwapResponse, opts: AdminInvoiceSwapResponseOptions = {}, path: string = 'AdminInvoiceSwapResponse::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') + + if (typeof o.tx_id !== 'string') return new Error(`${path}.tx_id: is not a string`) + if (opts.tx_id_CustomCheck && !opts.tx_id_CustomCheck(o.tx_id)) return new Error(`${path}.tx_id: custom check failed`) + + return null +} + +export type AdminTxSwapResponse = { network_fee: number tx_id: string } -export const AdminSwapResponseOptionalFields: [] = [] -export type AdminSwapResponseOptions = OptionsBaseMessage & { +export const AdminTxSwapResponseOptionalFields: [] = [] +export type AdminTxSwapResponseOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] network_fee_CustomCheck?: (v: number) => boolean tx_id_CustomCheck?: (v: string) => boolean } -export const AdminSwapResponseValidate = (o?: AdminSwapResponse, opts: AdminSwapResponseOptions = {}, path: string = 'AdminSwapResponse::root.'): Error | null => { +export const AdminTxSwapResponseValidate = (o?: AdminTxSwapResponse, opts: AdminTxSwapResponseOptions = {}, path: string = 'AdminTxSwapResponse::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') @@ -2088,6 +2118,185 @@ export const HttpCredsValidate = (o?: HttpCreds, opts: HttpCredsOptions = {}, pa return null } +export type InvoiceSwapOperation = { + failure_reason?: string + invoice_paid: string + operation_payment?: UserOperation + swap_operation_id: string + tx_id: string +} +export type InvoiceSwapOperationOptionalField = 'failure_reason' | 'operation_payment' +export const InvoiceSwapOperationOptionalFields: InvoiceSwapOperationOptionalField[] = ['failure_reason', 'operation_payment'] +export type InvoiceSwapOperationOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: InvoiceSwapOperationOptionalField[] + failure_reason_CustomCheck?: (v?: string) => boolean + invoice_paid_CustomCheck?: (v: string) => boolean + operation_payment_Options?: UserOperationOptions + swap_operation_id_CustomCheck?: (v: string) => boolean + tx_id_CustomCheck?: (v: string) => boolean +} +export const InvoiceSwapOperationValidate = (o?: InvoiceSwapOperation, opts: InvoiceSwapOperationOptions = {}, path: string = 'InvoiceSwapOperation::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') + + if ((o.failure_reason || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('failure_reason')) && typeof o.failure_reason !== 'string') return new Error(`${path}.failure_reason: is not a string`) + if (opts.failure_reason_CustomCheck && !opts.failure_reason_CustomCheck(o.failure_reason)) return new Error(`${path}.failure_reason: custom check failed`) + + if (typeof o.invoice_paid !== 'string') return new Error(`${path}.invoice_paid: is not a string`) + if (opts.invoice_paid_CustomCheck && !opts.invoice_paid_CustomCheck(o.invoice_paid)) return new Error(`${path}.invoice_paid: custom check failed`) + + if (typeof o.operation_payment === 'object' || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('operation_payment')) { + const operation_paymentErr = UserOperationValidate(o.operation_payment, opts.operation_payment_Options, `${path}.operation_payment`) + if (operation_paymentErr !== null) return operation_paymentErr + } + + + if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) + if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) + + if (typeof o.tx_id !== 'string') return new Error(`${path}.tx_id: is not a string`) + if (opts.tx_id_CustomCheck && !opts.tx_id_CustomCheck(o.tx_id)) return new Error(`${path}.tx_id: custom check failed`) + + return null +} + +export type InvoiceSwapQuote = { + address: string + chain_fee_sats: number + invoice: string + invoice_amount_sats: number + service_fee_sats: number + service_url: string + swap_fee_sats: number + swap_operation_id: string + transaction_amount_sats: number + tx_id: string +} +export const InvoiceSwapQuoteOptionalFields: [] = [] +export type InvoiceSwapQuoteOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + address_CustomCheck?: (v: string) => boolean + chain_fee_sats_CustomCheck?: (v: number) => boolean + invoice_CustomCheck?: (v: string) => boolean + invoice_amount_sats_CustomCheck?: (v: number) => boolean + service_fee_sats_CustomCheck?: (v: number) => boolean + service_url_CustomCheck?: (v: string) => boolean + swap_fee_sats_CustomCheck?: (v: number) => boolean + swap_operation_id_CustomCheck?: (v: string) => boolean + transaction_amount_sats_CustomCheck?: (v: number) => boolean + tx_id_CustomCheck?: (v: string) => boolean +} +export const InvoiceSwapQuoteValidate = (o?: InvoiceSwapQuote, opts: InvoiceSwapQuoteOptions = {}, path: string = 'InvoiceSwapQuote::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') + + if (typeof o.address !== 'string') return new Error(`${path}.address: is not a string`) + if (opts.address_CustomCheck && !opts.address_CustomCheck(o.address)) return new Error(`${path}.address: custom check failed`) + + if (typeof o.chain_fee_sats !== 'number') return new Error(`${path}.chain_fee_sats: is not a number`) + if (opts.chain_fee_sats_CustomCheck && !opts.chain_fee_sats_CustomCheck(o.chain_fee_sats)) return new Error(`${path}.chain_fee_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`) + + if (typeof o.invoice_amount_sats !== 'number') return new Error(`${path}.invoice_amount_sats: is not a number`) + if (opts.invoice_amount_sats_CustomCheck && !opts.invoice_amount_sats_CustomCheck(o.invoice_amount_sats)) return new Error(`${path}.invoice_amount_sats: custom check failed`) + + if (typeof o.service_fee_sats !== 'number') return new Error(`${path}.service_fee_sats: is not a number`) + if (opts.service_fee_sats_CustomCheck && !opts.service_fee_sats_CustomCheck(o.service_fee_sats)) return new Error(`${path}.service_fee_sats: custom check failed`) + + if (typeof o.service_url !== 'string') return new Error(`${path}.service_url: is not a string`) + if (opts.service_url_CustomCheck && !opts.service_url_CustomCheck(o.service_url)) return new Error(`${path}.service_url: custom check failed`) + + if (typeof o.swap_fee_sats !== 'number') return new Error(`${path}.swap_fee_sats: is not a number`) + if (opts.swap_fee_sats_CustomCheck && !opts.swap_fee_sats_CustomCheck(o.swap_fee_sats)) return new Error(`${path}.swap_fee_sats: custom check failed`) + + if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) + if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) + + if (typeof o.transaction_amount_sats !== 'number') return new Error(`${path}.transaction_amount_sats: is not a number`) + if (opts.transaction_amount_sats_CustomCheck && !opts.transaction_amount_sats_CustomCheck(o.transaction_amount_sats)) return new Error(`${path}.transaction_amount_sats: custom check failed`) + + if (typeof o.tx_id !== 'string') return new Error(`${path}.tx_id: is not a string`) + if (opts.tx_id_CustomCheck && !opts.tx_id_CustomCheck(o.tx_id)) return new Error(`${path}.tx_id: custom check failed`) + + return null +} + +export type InvoiceSwapQuoteList = { + quotes: InvoiceSwapQuote[] +} +export const InvoiceSwapQuoteListOptionalFields: [] = [] +export type InvoiceSwapQuoteListOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + quotes_ItemOptions?: InvoiceSwapQuoteOptions + quotes_CustomCheck?: (v: InvoiceSwapQuote[]) => boolean +} +export const InvoiceSwapQuoteListValidate = (o?: InvoiceSwapQuoteList, opts: InvoiceSwapQuoteListOptions = {}, path: string = 'InvoiceSwapQuoteList::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') + + if (!Array.isArray(o.quotes)) return new Error(`${path}.quotes: is not an array`) + for (let index = 0; index < o.quotes.length; index++) { + const quotesErr = InvoiceSwapQuoteValidate(o.quotes[index], opts.quotes_ItemOptions, `${path}.quotes[${index}]`) + if (quotesErr !== null) return quotesErr + } + if (opts.quotes_CustomCheck && !opts.quotes_CustomCheck(o.quotes)) return new Error(`${path}.quotes: custom check failed`) + + return null +} + +export type InvoiceSwapRequest = { + invoice: string +} +export const InvoiceSwapRequestOptionalFields: [] = [] +export type InvoiceSwapRequestOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + invoice_CustomCheck?: (v: string) => boolean +} +export const InvoiceSwapRequestValidate = (o?: InvoiceSwapRequest, opts: InvoiceSwapRequestOptions = {}, path: string = 'InvoiceSwapRequest::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') + + 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`) + + return null +} + +export type InvoiceSwapsList = { + quotes: InvoiceSwapQuote[] + swaps: InvoiceSwapOperation[] +} +export const InvoiceSwapsListOptionalFields: [] = [] +export type InvoiceSwapsListOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + quotes_ItemOptions?: InvoiceSwapQuoteOptions + quotes_CustomCheck?: (v: InvoiceSwapQuote[]) => boolean + swaps_ItemOptions?: InvoiceSwapOperationOptions + swaps_CustomCheck?: (v: InvoiceSwapOperation[]) => boolean +} +export const InvoiceSwapsListValidate = (o?: InvoiceSwapsList, opts: InvoiceSwapsListOptions = {}, path: string = 'InvoiceSwapsList::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') + + if (!Array.isArray(o.quotes)) return new Error(`${path}.quotes: is not an array`) + for (let index = 0; index < o.quotes.length; index++) { + const quotesErr = InvoiceSwapQuoteValidate(o.quotes[index], opts.quotes_ItemOptions, `${path}.quotes[${index}]`) + if (quotesErr !== null) return quotesErr + } + if (opts.quotes_CustomCheck && !opts.quotes_CustomCheck(o.quotes)) return new Error(`${path}.quotes: custom check failed`) + + if (!Array.isArray(o.swaps)) return new Error(`${path}.swaps: is not an array`) + for (let index = 0; index < o.swaps.length; index++) { + const swapsErr = InvoiceSwapOperationValidate(o.swaps[index], opts.swaps_ItemOptions, `${path}.swaps[${index}]`) + if (swapsErr !== null) return swapsErr + } + if (opts.swaps_CustomCheck && !opts.swaps_CustomCheck(o.swaps)) return new Error(`${path}.swaps: custom check failed`) + + return null +} + export type LatestBundleMetricReq = { limit?: number } @@ -3308,6 +3517,29 @@ export const PayAddressResponseValidate = (o?: PayAddressResponse, opts: PayAddr return null } +export type PayAdminInvoiceSwapRequest = { + sat_per_v_byte: number + swap_operation_id: string +} +export const PayAdminInvoiceSwapRequestOptionalFields: [] = [] +export type PayAdminInvoiceSwapRequestOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + sat_per_v_byte_CustomCheck?: (v: number) => boolean + swap_operation_id_CustomCheck?: (v: string) => boolean +} +export const PayAdminInvoiceSwapRequestValidate = (o?: PayAdminInvoiceSwapRequest, opts: PayAdminInvoiceSwapRequestOptions = {}, path: string = 'PayAdminInvoiceSwapRequest::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') + + if (typeof o.sat_per_v_byte !== 'number') return new Error(`${path}.sat_per_v_byte: is not a number`) + if (opts.sat_per_v_byte_CustomCheck && !opts.sat_per_v_byte_CustomCheck(o.sat_per_v_byte)) return new Error(`${path}.sat_per_v_byte: custom check failed`) + + if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) + if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) + + return null +} + export type PayAdminTransactionSwapRequest = { address: string swap_operation_id: string @@ -3917,76 +4149,6 @@ export const SingleMetricReqValidate = (o?: SingleMetricReq, opts: SingleMetricR return null } -export type SwapOperation = { - address_paid: string - failure_reason?: string - operation_payment?: UserOperation - swap_operation_id: string -} -export type SwapOperationOptionalField = 'failure_reason' | 'operation_payment' -export const SwapOperationOptionalFields: SwapOperationOptionalField[] = ['failure_reason', 'operation_payment'] -export type SwapOperationOptions = OptionsBaseMessage & { - checkOptionalsAreSet?: SwapOperationOptionalField[] - address_paid_CustomCheck?: (v: string) => boolean - failure_reason_CustomCheck?: (v?: string) => boolean - operation_payment_Options?: UserOperationOptions - swap_operation_id_CustomCheck?: (v: string) => boolean -} -export const SwapOperationValidate = (o?: SwapOperation, opts: SwapOperationOptions = {}, path: string = 'SwapOperation::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') - - if (typeof o.address_paid !== 'string') return new Error(`${path}.address_paid: is not a string`) - if (opts.address_paid_CustomCheck && !opts.address_paid_CustomCheck(o.address_paid)) return new Error(`${path}.address_paid: custom check failed`) - - if ((o.failure_reason || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('failure_reason')) && typeof o.failure_reason !== 'string') return new Error(`${path}.failure_reason: is not a string`) - if (opts.failure_reason_CustomCheck && !opts.failure_reason_CustomCheck(o.failure_reason)) return new Error(`${path}.failure_reason: custom check failed`) - - if (typeof o.operation_payment === 'object' || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('operation_payment')) { - const operation_paymentErr = UserOperationValidate(o.operation_payment, opts.operation_payment_Options, `${path}.operation_payment`) - if (operation_paymentErr !== null) return operation_paymentErr - } - - - if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) - if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) - - return null -} - -export type SwapsList = { - quotes: TransactionSwapQuote[] - swaps: SwapOperation[] -} -export const SwapsListOptionalFields: [] = [] -export type SwapsListOptions = OptionsBaseMessage & { - checkOptionalsAreSet?: [] - quotes_ItemOptions?: TransactionSwapQuoteOptions - quotes_CustomCheck?: (v: TransactionSwapQuote[]) => boolean - swaps_ItemOptions?: SwapOperationOptions - swaps_CustomCheck?: (v: SwapOperation[]) => boolean -} -export const SwapsListValidate = (o?: SwapsList, opts: SwapsListOptions = {}, path: string = 'SwapsList::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') - - if (!Array.isArray(o.quotes)) return new Error(`${path}.quotes: is not an array`) - for (let index = 0; index < o.quotes.length; index++) { - const quotesErr = TransactionSwapQuoteValidate(o.quotes[index], opts.quotes_ItemOptions, `${path}.quotes[${index}]`) - if (quotesErr !== null) return quotesErr - } - if (opts.quotes_CustomCheck && !opts.quotes_CustomCheck(o.quotes)) return new Error(`${path}.quotes: custom check failed`) - - if (!Array.isArray(o.swaps)) return new Error(`${path}.swaps: is not an array`) - for (let index = 0; index < o.swaps.length; index++) { - const swapsErr = SwapOperationValidate(o.swaps[index], opts.swaps_ItemOptions, `${path}.swaps[${index}]`) - if (swapsErr !== null) return swapsErr - } - if (opts.swaps_CustomCheck && !opts.swaps_CustomCheck(o.swaps)) return new Error(`${path}.swaps: custom check failed`) - - return null -} - export type TransactionSwapQuote = { chain_fee_sats: number invoice_amount_sats: number @@ -4076,6 +4238,76 @@ export const TransactionSwapRequestValidate = (o?: TransactionSwapRequest, opts: return null } +export type TxSwapOperation = { + address_paid: string + failure_reason?: string + operation_payment?: UserOperation + swap_operation_id: string +} +export type TxSwapOperationOptionalField = 'failure_reason' | 'operation_payment' +export const TxSwapOperationOptionalFields: TxSwapOperationOptionalField[] = ['failure_reason', 'operation_payment'] +export type TxSwapOperationOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: TxSwapOperationOptionalField[] + address_paid_CustomCheck?: (v: string) => boolean + failure_reason_CustomCheck?: (v?: string) => boolean + operation_payment_Options?: UserOperationOptions + swap_operation_id_CustomCheck?: (v: string) => boolean +} +export const TxSwapOperationValidate = (o?: TxSwapOperation, opts: TxSwapOperationOptions = {}, path: string = 'TxSwapOperation::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') + + if (typeof o.address_paid !== 'string') return new Error(`${path}.address_paid: is not a string`) + if (opts.address_paid_CustomCheck && !opts.address_paid_CustomCheck(o.address_paid)) return new Error(`${path}.address_paid: custom check failed`) + + if ((o.failure_reason || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('failure_reason')) && typeof o.failure_reason !== 'string') return new Error(`${path}.failure_reason: is not a string`) + if (opts.failure_reason_CustomCheck && !opts.failure_reason_CustomCheck(o.failure_reason)) return new Error(`${path}.failure_reason: custom check failed`) + + if (typeof o.operation_payment === 'object' || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('operation_payment')) { + const operation_paymentErr = UserOperationValidate(o.operation_payment, opts.operation_payment_Options, `${path}.operation_payment`) + if (operation_paymentErr !== null) return operation_paymentErr + } + + + if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) + if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) + + return null +} + +export type TxSwapsList = { + quotes: TransactionSwapQuote[] + swaps: TxSwapOperation[] +} +export const TxSwapsListOptionalFields: [] = [] +export type TxSwapsListOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + quotes_ItemOptions?: TransactionSwapQuoteOptions + quotes_CustomCheck?: (v: TransactionSwapQuote[]) => boolean + swaps_ItemOptions?: TxSwapOperationOptions + swaps_CustomCheck?: (v: TxSwapOperation[]) => boolean +} +export const TxSwapsListValidate = (o?: TxSwapsList, opts: TxSwapsListOptions = {}, path: string = 'TxSwapsList::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') + + if (!Array.isArray(o.quotes)) return new Error(`${path}.quotes: is not an array`) + for (let index = 0; index < o.quotes.length; index++) { + const quotesErr = TransactionSwapQuoteValidate(o.quotes[index], opts.quotes_ItemOptions, `${path}.quotes[${index}]`) + if (quotesErr !== null) return quotesErr + } + if (opts.quotes_CustomCheck && !opts.quotes_CustomCheck(o.quotes)) return new Error(`${path}.quotes: custom check failed`) + + if (!Array.isArray(o.swaps)) return new Error(`${path}.swaps: is not an array`) + for (let index = 0; index < o.swaps.length; index++) { + const swapsErr = TxSwapOperationValidate(o.swaps[index], opts.swaps_ItemOptions, `${path}.swaps[${index}]`) + if (swapsErr !== null) return swapsErr + } + if (opts.swaps_CustomCheck && !opts.swaps_CustomCheck(o.swaps)) return new Error(`${path}.swaps: custom check failed`) + + return null +} + export type UpdateChannelPolicyRequest = { policy: ChannelPolicy update: UpdateChannelPolicyRequest_update diff --git a/proto/service/methods.proto b/proto/service/methods.proto index feaf3e97..15292e37 100644 --- a/proto/service/methods.proto +++ b/proto/service/methods.proto @@ -175,6 +175,27 @@ service LightningPub { option (nostr) = true; } + rpc GetAdminInvoiceSwapQuotes(structs.InvoiceSwapRequest) returns (structs.InvoiceSwapQuoteList) { + option (auth_type) = "Admin"; + option (http_method) = "post"; + option (http_route) = "/api/admin/swap/invoice/quote"; + option (nostr) = true; + } + + rpc ListAdminInvoiceSwaps(structs.Empty) returns (structs.InvoiceSwapsList) { + option (auth_type) = "Admin"; + option (http_method) = "post"; + option (http_route) = "/api/admin/swap/invoice/list"; + option (nostr) = true; + } + + rpc PayAdminInvoiceSwap(structs.PayAdminInvoiceSwapRequest) returns (structs.AdminInvoiceSwapResponse) { + option (auth_type) = "Admin"; + option (http_method) = "post"; + option (http_route) = "/api/admin/swap/invoice/pay"; + option (nostr) = true; + } + rpc GetAdminTransactionSwapQuotes(structs.TransactionSwapRequest) returns (structs.TransactionSwapQuoteList) { option (auth_type) = "Admin"; option (http_method) = "post"; @@ -182,17 +203,17 @@ service LightningPub { option (nostr) = true; } - rpc PayAdminTransactionSwap(structs.PayAdminTransactionSwapRequest) returns (structs.AdminSwapResponse) { + rpc PayAdminTransactionSwap(structs.PayAdminTransactionSwapRequest) returns (structs.AdminTxSwapResponse) { option (auth_type) = "Admin"; option (http_method) = "post"; option (http_route) = "/api/admin/swap/transaction/pay"; option (nostr) = true; } - rpc ListAdminSwaps(structs.Empty) returns (structs.SwapsList) { + rpc ListAdminTxSwaps(structs.Empty) returns (structs.TxSwapsList) { option (auth_type) = "Admin"; option (http_method) = "post"; - option (http_route) = "/api/admin/swap/list"; + option (http_route) = "/api/admin/swap/transaction/list"; option (nostr) = true; } @@ -520,14 +541,14 @@ service LightningPub { rpc GetTransactionSwapQuotes(structs.TransactionSwapRequest) returns (structs.TransactionSwapQuoteList){ option (auth_type) = "User"; option (http_method) = "post"; - option (http_route) = "/api/user/swap/quote"; + option (http_route) = "/api/user/swap/transaction/quote"; option (nostr) = true; } - rpc ListSwaps(structs.Empty) returns (structs.SwapsList){ + rpc ListTxSwaps(structs.Empty) returns (structs.TxSwapsList){ option (auth_type) = "User"; option (http_method) = "post"; - option (http_route) = "/api/user/swap/list"; + option (http_route) = "/api/user/swap/transaction/list"; option (nostr) = true; } diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 00552fdd..e93e6691 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -833,6 +833,49 @@ message MessagingToken { string firebase_messaging_token = 2; } +message InvoiceSwapRequest { + string invoice = 1; +} + +message InvoiceSwapQuote { + string swap_operation_id = 1; + string invoice = 2; + int64 invoice_amount_sats = 3; + string address = 4; + int64 transaction_amount_sats = 5; + int64 chain_fee_sats = 6; + int64 service_fee_sats = 7; + string service_url = 8; + int64 swap_fee_sats = 9; + string tx_id = 10; +} + +message InvoiceSwapQuoteList { + repeated InvoiceSwapQuote quotes = 1; +} + +message InvoiceSwapOperation { + string swap_operation_id = 1; + optional UserOperation operation_payment = 2; + optional string failure_reason = 3; + string invoice_paid = 4; + string tx_id = 5; +} + +message InvoiceSwapsList { + repeated InvoiceSwapOperation swaps = 1; + repeated InvoiceSwapQuote quotes = 2; +} + +message PayAdminInvoiceSwapRequest { + string swap_operation_id = 1; + int64 sat_per_v_byte = 2; +} + +message AdminInvoiceSwapResponse { + string tx_id = 1; +} + message TransactionSwapRequest { int64 transaction_amount_sats = 2; } @@ -857,20 +900,20 @@ message TransactionSwapQuoteList { repeated TransactionSwapQuote quotes = 1; } -message AdminSwapResponse { +message AdminTxSwapResponse { string tx_id = 1; int64 network_fee = 2; } -message SwapOperation { +message TxSwapOperation { string swap_operation_id = 1; optional UserOperation operation_payment = 2; optional string failure_reason = 3; string address_paid = 4; } -message SwapsList { - repeated SwapOperation swaps = 1; +message TxSwapsList { + repeated TxSwapOperation swaps = 1; repeated TransactionSwapQuote quotes = 2; } diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index 0c7de08c..ffbb8545 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -11,10 +11,38 @@ import { getLogger, PubLogger, ERROR } from '../../helpers/logger.js'; import { loggedGet, loggedPost } from './swapHelpers.js'; import { BTCNetwork } from '../../main/settings.js'; -type InvoiceSwapResponse = { id: string, claimPublicKey: string, swapTree: string } -type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface } -type InvoiceSwapData = { createdResponse: InvoiceSwapResponse, info: InvoiceSwapInfo } +/* type InvoiceSwapFees = { + hash: string, + rate: number, + limits: { + maximal: number, + minimal: number, + maximalZeroConf: number + }, + fees: { + percentage: number, + minerFees: number, + } +} */ +type InvoiceSwapFees = { + percentage: number, + minerFees: number, +} + +type InvoiceSwapFeesRes = { + BTC?: { + BTC?: { + fees: InvoiceSwapFees + } + } +} +type InvoiceSwapResponse = { + id: string, claimPublicKey: string, swapTree: string, timeoutBlockHeight: number, + expectedAmount: number, address: string +} +type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface } +export type InvoiceSwapData = { createdResponse: InvoiceSwapResponse, info: InvoiceSwapInfo } export class SubmarineSwaps { private httpUrl: string @@ -33,8 +61,23 @@ export class SubmarineSwaps { return this.wsUrl } - SwapInvoice = async (invoice: string, paymentHash: string) => { + GetFees = async (): Promise<{ ok: true, fees: InvoiceSwapFees, } | { ok: false, error: string }> => { + const url = `${this.httpUrl}/v2/swap/submarine` + const feesRes = await loggedGet(this.log, url) + if (!feesRes.ok) { + return { ok: false, error: feesRes.error } + } + if (!feesRes.data.BTC?.BTC?.fees) { + return { ok: false, error: 'No fees found for BTC to BTC swap' } + } + return { ok: true, fees: feesRes.data.BTC.BTC.fees } + } + + SwapInvoice = async (invoice: string): Promise<{ ok: true, createdResponse: InvoiceSwapResponse, pubkey: string, privKey: string } | { ok: false, error: string }> => { const keys = ECPairFactory(ecc).makeRandom() + if (!keys.privateKey) { + return { ok: false, error: 'Failed to generate keys' } + } const refundPublicKey = Buffer.from(keys.publicKey).toString('hex') const req = { invoice, to: 'BTC', from: 'BTC', refundPublicKey } const url = `${this.httpUrl}/v2/swap/submarine` @@ -46,21 +89,25 @@ export class SubmarineSwaps { const createdResponse = createdResponseRes.data this.log('Created invoice swap'); this.log(createdResponse); - + return { + ok: true, createdResponse, + pubkey: refundPublicKey, + privKey: Buffer.from(keys.privateKey).toString('hex') + } } - SubscribeToInvoiceSwap = async (data: InvoiceSwapData, swapDone: (result: { ok: true, txId: string } | { ok: false, error: string }) => void) => { + SubscribeToInvoiceSwap = (data: InvoiceSwapData, swapDone: (result: { ok: true } | { ok: false, error: string }) => void, waitingTx: () => void) => { const webSocket = new ws(`${this.wsUrl}/v2/ws`) const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] } webSocket.on('open', () => { webSocket.send(JSON.stringify(subReq)) }) - let txId = "", isDone = false + let isDone = false const done = () => { isDone = true webSocket.close() - swapDone({ ok: true, txId }) + swapDone({ ok: true }) } webSocket.on('error', (err) => { this.log(ERROR, 'Error in WebSocket', err.message) @@ -73,16 +120,19 @@ export class SubmarineSwaps { }) webSocket.on('message', async (rawMsg) => { try { - await this.handleSwapInvoiceMessage(rawMsg, data, done) + await this.handleSwapInvoiceMessage(rawMsg, data, done, waitingTx) } catch (err: any) { this.log(ERROR, 'Error handling invoice WebSocket message', err.message) webSocket.close() return } }); + return () => { + webSocket.close() + } } - handleSwapInvoiceMessage = async (rawMsg: ws.RawData, data: InvoiceSwapData, closeWebSocket: () => void) => { + handleSwapInvoiceMessage = async (rawMsg: ws.RawData, data: InvoiceSwapData, closeWebSocket: () => void, waitingTx: () => void) => { const msg = JSON.parse(rawMsg.toString('utf-8')); if (msg.event !== 'update') { return; @@ -94,6 +144,7 @@ export class SubmarineSwaps { // "invoice.set" means Boltz is waiting for an onchain transaction to be sent case 'invoice.set': this.log('Waiting for onchain transaction'); + waitingTx() return; // Create a partial signature to allow Boltz to do a key path spend to claim the mainchain coins case 'transaction.claim.pending': diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index e53c8751..381e0690 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -7,7 +7,8 @@ import Storage from '../../storage/index.js'; import LND from '../lnd.js'; import { UserInvoicePayment } from '../../storage/entity/UserInvoicePayment.js'; import { ReverseSwaps, TransactionSwapData } from './reverseSwaps.js'; -import { SubmarineSwaps } from './submarineSwaps.js'; +import { SubmarineSwaps, InvoiceSwapData } from './submarineSwaps.js'; +import { InvoiceSwap } from '../../storage/entity/InvoiceSwap.js'; export class Swaps { @@ -16,6 +17,7 @@ export class Swaps { subSwappers: Record storage: Storage lnd: LND + waitingSwaps: Record = {} log = getLogger({ component: 'swaps' }) constructor(settings: SettingsManager, storage: Storage) { this.settings = settings @@ -45,7 +47,7 @@ export class Swaps { return keys } - GetInvoiceSwapQuotes = async (appUserId: string, payments: UserInvoicePayment[], getServiceFee: (amt: number) => number): Promise => { + GetInvoiceSwapQuotes = async (appUserId: string, invoice: string): Promise => { if (!this.settings.getSettings().swapsSettings.enableSwaps) { throw new Error("Swaps are not enabled") } @@ -53,13 +55,178 @@ export class Swaps { if (swappers.length === 0) { throw new Error("No swap services available") } - const res = await Promise.allSettled(swappers.map(sw => this.getInvoiceSwapQuote(sw, appUserId, payments, getServiceFee))) + const res = await Promise.allSettled(swappers.map(sw => this.getInvoiceSwapQuote(sw, appUserId, invoice))) const failures: string[] = [] const success: Types.InvoiceSwapQuote[] = [] - for (const r of res) { } + for (const r of res) { + if (r.status === 'fulfilled') { + success.push(r.value) + } else { + failures.push(r.reason.message ? r.reason.message : r.reason.toString()) + } + } + if (success.length === 0) { + throw new Error(failures.join("\n")) + } + return success } - ListTxSwaps = async (appUserId: string, payments: UserInvoicePayment[], newOp: (p: UserInvoicePayment) => Types.UserOperation | undefined, getServiceFee: (amt: number) => number): Promise => { + ListInvoiceSwaps = async (appUserId: string): Promise => { + const completedSwaps = await this.storage.paymentStorage.ListCompletedInvoiceSwaps(appUserId) + const pendingSwaps = await this.storage.paymentStorage.ListPendingInvoiceSwaps(appUserId) + return { + swaps: completedSwaps.map(s => { + return { + invoice_paid: s.invoice, + swap_operation_id: s.swap_operation_id, + failure_reason: s.failure_reason, + tx_id: s.tx_id, + } + }), + quotes: pendingSwaps.map(s => { + return { + swap_operation_id: s.swap_operation_id, + invoice: s.invoice, + invoice_amount_sats: s.invoice_amount, + address: s.address, + transaction_amount_sats: s.transaction_amount, + chain_fee_sats: s.chain_fee_sats, + service_fee_sats: 0, + service_url: s.service_url, + swap_fee_sats: s.swap_fee_sats, + tx_id: s.tx_id, + } + }) + } + } + + PayInvoiceSwap = async (appUserId: string, swapOpId: string, satPerVByte: number, payAddress: (address: string, amt: number) => Promise<{ txId: string }>): Promise => { + if (!this.settings.getSettings().swapsSettings.enableSwaps) { + throw new Error("Swaps are not enabled") + } + if (!swapOpId) { + throw new Error("swap operation id is required") + } + if (!satPerVByte) { + throw new Error("sat per v byte is required") + } + const swap = await this.storage.paymentStorage.GetInvoiceSwap(swapOpId, appUserId) + if (!swap) { + throw new Error("swap not found") + } + const swapper = this.subSwappers[swap.service_url] + if (!swapper) { + throw new Error("swapper service not found") + } + if (this.waitingSwaps[swapOpId]) { + throw new Error("swap already in progress") + } + this.waitingSwaps[swapOpId] = true + const data = this.getInvoiceSwapData(swap) + let txId = "" + const close = swapper.SubscribeToInvoiceSwap(data, async (result) => { + if (result.ok) { + await this.storage.paymentStorage.FinalizeInvoiceSwap(swapOpId) + this.log("invoice swap completed", { swapOpId, txId }) + } else { + await this.storage.paymentStorage.FailInvoiceSwap(swapOpId, result.error, txId) + this.log("invoice swap failed", { swapOpId, error: result.error }) + } + }, () => payAddress(swap.address, swap.transaction_amount).then(res => { txId = res.txId }).catch(err => { close(); this.log("error paying address", err) })) + } + + ResumeInvoiceSwaps = async () => { + this.log("resuming invoice swaps") + const swaps = await this.storage.paymentStorage.ListUnfinishedInvoiceSwaps() + this.log("resuming", swaps.length, "invoice swaps") + for (const swap of swaps) { + this.resumeInvoiceSwap(swap) + } + } + + + private resumeInvoiceSwap = (swap: InvoiceSwap) => { + // const swap = await this.storage.paymentStorage.GetInvoiceSwap(swapOpId, appUserId) + if (!swap || !swap.tx_id || swap.used) { + throw new Error("swap to resume not found, or does not have a tx id") + } + const swapper = this.subSwappers[swap.service_url] + if (!swapper) { + throw new Error("swapper service not found") + } + const data = this.getInvoiceSwapData(swap) + swapper.SubscribeToInvoiceSwap(data, async (result) => { + if (result.ok) { + await this.storage.paymentStorage.FinalizeInvoiceSwap(swap.swap_operation_id) + this.log("invoice swap completed", { swapOpId: swap.swap_operation_id, txId: swap.tx_id }) + } else { + await this.storage.paymentStorage.FailInvoiceSwap(swap.swap_operation_id, result.error) + this.log("invoice swap failed", { swapOpId: swap.swap_operation_id, error: result.error }) + } + }, () => { throw new Error("swap tx already paid") }) + } + + private getInvoiceSwapData = (swap: InvoiceSwap) => { + return { + createdResponse: { + address: swap.address, + claimPublicKey: swap.claim_public_key, + id: swap.swap_quote_id, + swapTree: swap.swap_tree, + timeoutBlockHeight: swap.timeout_block_height, + expectedAmount: swap.transaction_amount, + }, + info: { + keys: this.GetKeys(swap.ephemeral_private_key), + paymentHash: swap.payment_hash, + } + } + } + + private async getInvoiceSwapQuote(swapper: SubmarineSwaps, appUserId: string, invoice: string): Promise { + const feesRes = await swapper.GetFees() + if (!feesRes.ok) { + throw new Error(feesRes.error) + } + const decoded = await this.lnd.DecodeInvoice(invoice) + const amt = decoded.numSatoshis + const fee = Math.ceil((feesRes.fees.percentage / 100) * amt) + feesRes.fees.minerFees + const res = await swapper.SwapInvoice(invoice) + if (!res.ok) { + throw new Error(res.error) + } + const newSwap = await this.storage.paymentStorage.AddInvoiceSwap({ + app_user_id: appUserId, + swap_quote_id: res.createdResponse.id, + swap_tree: JSON.stringify(res.createdResponse.swapTree), + timeout_block_height: res.createdResponse.timeoutBlockHeight, + ephemeral_public_key: res.pubkey, + ephemeral_private_key: res.privKey, + invoice: invoice, + invoice_amount: amt, + transaction_amount: res.createdResponse.expectedAmount, + swap_fee_sats: fee, + chain_fee_sats: 0, + service_url: swapper.getHttpUrl(), + address: res.createdResponse.address, + claim_public_key: res.createdResponse.claimPublicKey, + payment_hash: decoded.paymentHash, + }) + return { + swap_operation_id: newSwap.swap_operation_id, + invoice: invoice, + invoice_amount_sats: amt, + address: res.createdResponse.address, + transaction_amount_sats: res.createdResponse.expectedAmount, + chain_fee_sats: 0, + service_fee_sats: 0, + service_url: swapper.getHttpUrl(), + swap_fee_sats: fee, + tx_id: newSwap.tx_id, + } + } + + ListTxSwaps = async (appUserId: string, payments: UserInvoicePayment[], newOp: (p: UserInvoicePayment) => Types.UserOperation | undefined, getServiceFee: (amt: number) => number): Promise => { const completedSwaps = await this.storage.paymentStorage.ListCompletedTxSwaps(appUserId, payments) const pendingSwaps = await this.storage.paymentStorage.ListPendingTransactionSwaps(appUserId) return { diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 1474707a..8284b253 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -260,15 +260,36 @@ export class AdminManager { } } - async ListAdminSwaps(): Promise { - return this.swaps.ListSwaps("admin", [], p => undefined, amt => 0) + async ListAdminInvoiceSwaps(): Promise { + return this.swaps.ListInvoiceSwaps("admin") + } + + async GetAdminInvoiceSwapQuotes(req: Types.InvoiceSwapRequest): Promise { + const quotes = await this.swaps.GetInvoiceSwapQuotes("admin", req.invoice) + return { quotes } + } + + async PayAdminInvoiceSwap(req: Types.PayAdminInvoiceSwapRequest): Promise { + const txId = await new Promise(res => { + this.swaps.PayInvoiceSwap("admin", req.swap_operation_id, req.sat_per_v_byte, async (addr, amt) => { + const tx = await this.lnd.PayAddress(addr, amt, req.sat_per_v_byte, "", { useProvider: false, from: 'system' }) + this.log("paid admin invoice swap", { swapOpId: req.swap_operation_id, txId: tx.txid }) + res(tx.txid) + return { txId: tx.txid } + }) + }) + return { tx_id: txId } + } + + async ListAdminTxSwaps(): Promise { + return this.swaps.ListTxSwaps("admin", [], p => undefined, amt => 0) } async GetAdminTransactionSwapQuotes(req: Types.TransactionSwapRequest): Promise { const quotes = await this.swaps.GetTxSwapQuotes("admin", req.transaction_amount_sats, () => 0) return { quotes } } - async PayAdminTransactionSwap(req: Types.PayAdminTransactionSwapRequest): Promise { + async PayAdminTransactionSwap(req: Types.PayAdminTransactionSwapRequest): Promise { const routingFloor = this.settings.getSettings().lndSettings.routingFeeFloor const routingLimit = this.settings.getSettings().lndSettings.routingFeeLimitBps / 10000 diff --git a/src/services/main/index.ts b/src/services/main/index.ts index ab528b84..6f610878 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -394,6 +394,7 @@ export default class { const j = JSON.stringify(op) const encrypted = nip44.encrypt(j, ck) const encryptedData: { encrypted: string, app_npub_hex: string } = { encrypted, app_npub_hex: app.nostr_public_key } + this.notificationsManager.SendNotification(JSON.stringify(encryptedData), tokens, { pubkey: app.nostr_public_key!, privateKey: app.nostr_private_key! diff --git a/src/services/main/init.ts b/src/services/main/init.ts index 121750e3..946061fb 100644 --- a/src/services/main/init.ts +++ b/src/services/main/init.ts @@ -79,6 +79,7 @@ export const initMainHandler = async (log: PubLogger, settingsManager: SettingsM await mainHandler.paymentManager.CleanupOldUnpaidInvoices() await mainHandler.appUserManager.CleanupInactiveUsers() await mainHandler.appUserManager.CleanupNeverActiveUsers() + await swaps.ResumeInvoiceSwaps() await mainHandler.paymentManager.watchDog.Start() return { mainHandler, apps, localProviderClient, wizard, adminManager } } diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 5fd04022..82610061 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -18,7 +18,7 @@ import { LiquidityManager } from './liquidityManager.js' import { Utils } from '../helpers/utilsWrapper.js' import { UserInvoicePayment } from '../storage/entity/UserInvoicePayment.js' import SettingsManager from './settingsManager.js' -import { Swaps, TransactionSwapData } from '../lnd/swaps/swaps.js' +import { Swaps } from '../lnd/swaps/swaps.js' import { Transaction, OutputDetail } from '../../../proto/lnd/lightning.js' import { LndAddress } from '../lnd/lnd.js' import Metrics from '../metrics/index.js' @@ -617,7 +617,7 @@ export default class { } } - async ListSwaps(ctx: Types.UserContext): Promise { + async ListTxSwaps(ctx: Types.UserContext): Promise { const payments = await this.storage.paymentStorage.ListTxSwapPayments(ctx.app_user_id) const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const isManagedUser = ctx.user_id !== app.owner.user_id diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index 8b9b80a3..47624db7 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -106,6 +106,26 @@ export default (mainHandler: Main): Types.ServerMethods => { if (err != null) throw new Error(err.message) return mainHandler.adminManager.PayAdminTransactionSwap(req) }, + ListAdminTxSwaps: async ({ ctx }) => { + return mainHandler.adminManager.ListAdminTxSwaps() + }, + GetAdminInvoiceSwapQuotes: async ({ ctx, req }) => { + const err = Types.InvoiceSwapRequestValidate(req, { + invoice_CustomCheck: invoice => invoice !== '' + }) + if (err != null) throw new Error(err.message) + return mainHandler.adminManager.GetAdminInvoiceSwapQuotes(req) + }, + ListAdminInvoiceSwaps: async ({ ctx }) => { + return mainHandler.adminManager.ListAdminInvoiceSwaps() + }, + PayAdminInvoiceSwap: async ({ ctx, req }) => { + const err = Types.PayAdminInvoiceSwapRequestValidate(req, { + swap_operation_id_CustomCheck: id => id !== '', + }) + if (err != null) throw new Error(err.message) + return mainHandler.adminManager.PayAdminInvoiceSwap(req) + }, GetProvidersDisruption: async () => { return mainHandler.metricsManager.GetProvidersDisruption() }, @@ -145,9 +165,7 @@ export default (mainHandler: Main): Types.ServerMethods => { GetUserOperations: async ({ ctx, req }) => { return mainHandler.paymentManager.GetUserOperations(ctx.user_id, req) }, - ListAdminSwaps: async ({ ctx }) => { - return mainHandler.adminManager.ListAdminSwaps() - }, + GetPaymentState: async ({ ctx, req }) => { const err = Types.GetPaymentStateRequestValidate(req, { invoice_CustomCheck: invoice => invoice !== "" @@ -165,8 +183,8 @@ export default (mainHandler: Main): Types.ServerMethods => { if (err != null) throw new Error(err.message) return mainHandler.paymentManager.PayAddress(ctx, req) }, - ListSwaps: async ({ ctx }) => { - return mainHandler.paymentManager.ListSwaps(ctx) + ListTxSwaps: async ({ ctx }) => { + return mainHandler.paymentManager.ListTxSwaps(ctx) }, GetTransactionSwapQuotes: async ({ ctx, req }) => { return mainHandler.paymentManager.GetTransactionSwapQuotes(ctx, req) diff --git a/src/services/storage/entity/InvoiceSwap.ts b/src/services/storage/entity/InvoiceSwap.ts index 4548f042..2ab18347 100644 --- a/src/services/storage/entity/InvoiceSwap.ts +++ b/src/services/storage/entity/InvoiceSwap.ts @@ -16,10 +16,16 @@ export class InvoiceSwap { swap_tree: string @Column() - lockup_address: string + claim_public_key: string @Column() - refund_public_key: string + payment_hash: string + + /* @Column() + lockup_address: string */ + + /* @Column() + refund_public_key: string */ @Column() timeout_block_height: number @@ -39,12 +45,14 @@ export class InvoiceSwap { @Column() chain_fee_sats: number - @Column() - preimage: string + @Column() ephemeral_public_key: string + @Column() + address: string + // the private key is used on to perform a swap, it does not hold any funds once the swap is completed // the swap should only last a few seconds, so it is not a security risk to store the private key in the database // the key is stored here mostly for recovery purposes, in case something goes wrong with the swap @@ -54,14 +62,17 @@ export class InvoiceSwap { @Column({ default: false }) used: boolean + @Column({ default: "" }) + preimage: string + @Column({ default: "" }) failure_reason: string @Column({ default: "" }) tx_id: string - @Column({ default: "" }) - address_paid: string + /* @Column({ default: "" }) + address_paid: string */ @Column({ default: "" }) service_url: string diff --git a/src/services/storage/migrations/1769529793283-invoice_swaps.ts b/src/services/storage/migrations/1769529793283-invoice_swaps.ts new file mode 100644 index 00000000..b32cc1de --- /dev/null +++ b/src/services/storage/migrations/1769529793283-invoice_swaps.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class InvoiceSwaps1769529793283 implements MigrationInterface { + name = 'InvoiceSwaps1769529793283' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`); + await queryRunner.query(`DROP INDEX "recv_invoice_paid_serial"`); + await queryRunner.query(`DROP INDEX "IDX_a131e6b58f084f1340538681b5"`); + await queryRunner.query(`CREATE TABLE "temporary_user_receiving_invoice" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "invoice" varchar NOT NULL, "expires_at_unix" integer NOT NULL, "paid_at_unix" integer NOT NULL DEFAULT (0), "internal" boolean NOT NULL DEFAULT (0), "paidByLnd" boolean NOT NULL DEFAULT (0), "callbackUrl" varchar NOT NULL DEFAULT (''), "paid_amount" integer NOT NULL DEFAULT (0), "service_fee" integer NOT NULL DEFAULT (0), "zap_info" text, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "productProductId" varchar, "payerSerialId" integer, "linkedApplicationSerialId" integer, "liquidityProvider" varchar, "payer_data" text, "offer_id" varchar NOT NULL DEFAULT (''), "rejectUnauthorized" boolean NOT NULL DEFAULT (1), "bearer_token" varchar NOT NULL DEFAULT (''), "clink_requester_pub" varchar, "clink_requester_event_id" varchar, CONSTRAINT "FK_2c0dfb3483f3e5e7e3cdd5dc71f" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5263bde2a519db9ea608b702ec8" FOREIGN KEY ("productProductId") REFERENCES "product" ("product_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_d4bb1e4c60e8a869f1f43ca2e31" FOREIGN KEY ("payerSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_714a8b7d4f89f8a802ca181b789" FOREIGN KEY ("linkedApplicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_user_receiving_invoice"("serial_id", "invoice", "expires_at_unix", "paid_at_unix", "internal", "paidByLnd", "callbackUrl", "paid_amount", "service_fee", "zap_info", "created_at", "updated_at", "userSerialId", "productProductId", "payerSerialId", "linkedApplicationSerialId", "liquidityProvider", "payer_data", "offer_id", "rejectUnauthorized", "bearer_token", "clink_requester_pub", "clink_requester_event_id") SELECT "serial_id", "invoice", "expires_at_unix", "paid_at_unix", "internal", "paidByLnd", "callbackUrl", "paid_amount", "service_fee", "zap_info", "created_at", "updated_at", "userSerialId", "productProductId", "payerSerialId", "linkedApplicationSerialId", "liquidityProvider", "payer_data", "offer_id", "rejectUnauthorized", "bearer_token", "clink_requester_pub", "clink_requester_event_id" FROM "user_receiving_invoice"`); + await queryRunner.query(`DROP TABLE "user_receiving_invoice"`); + await queryRunner.query(`ALTER TABLE "temporary_user_receiving_invoice" RENAME TO "user_receiving_invoice"`); + await queryRunner.query(`CREATE INDEX "recv_invoice_paid_serial" ON "user_receiving_invoice" ("userSerialId", "paid_at_unix", "serial_id") WHERE paid_at_unix > 0`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a131e6b58f084f1340538681b5" ON "user_receiving_invoice" ("invoice") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_a131e6b58f084f1340538681b5"`); + await queryRunner.query(`DROP INDEX "recv_invoice_paid_serial"`); + await queryRunner.query(`ALTER TABLE "user_receiving_invoice" RENAME TO "temporary_user_receiving_invoice"`); + await queryRunner.query(`CREATE TABLE "user_receiving_invoice" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "invoice" varchar NOT NULL, "expires_at_unix" integer NOT NULL, "paid_at_unix" integer NOT NULL DEFAULT (0), "internal" boolean NOT NULL DEFAULT (0), "paidByLnd" boolean NOT NULL DEFAULT (0), "callbackUrl" varchar NOT NULL DEFAULT (''), "paid_amount" integer NOT NULL DEFAULT (0), "service_fee" integer NOT NULL DEFAULT (0), "zap_info" text, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "productProductId" varchar, "payerSerialId" integer, "linkedApplicationSerialId" integer, "liquidityProvider" varchar, "payer_data" text, "offer_id" varchar NOT NULL DEFAULT (''), "rejectUnauthorized" boolean NOT NULL DEFAULT (1), "bearer_token" varchar NOT NULL DEFAULT (''), "clink_requester_pub" varchar(64), "clink_requester_event_id" varchar(64), CONSTRAINT "FK_2c0dfb3483f3e5e7e3cdd5dc71f" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5263bde2a519db9ea608b702ec8" FOREIGN KEY ("productProductId") REFERENCES "product" ("product_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_d4bb1e4c60e8a869f1f43ca2e31" FOREIGN KEY ("payerSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_714a8b7d4f89f8a802ca181b789" FOREIGN KEY ("linkedApplicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "user_receiving_invoice"("serial_id", "invoice", "expires_at_unix", "paid_at_unix", "internal", "paidByLnd", "callbackUrl", "paid_amount", "service_fee", "zap_info", "created_at", "updated_at", "userSerialId", "productProductId", "payerSerialId", "linkedApplicationSerialId", "liquidityProvider", "payer_data", "offer_id", "rejectUnauthorized", "bearer_token", "clink_requester_pub", "clink_requester_event_id") SELECT "serial_id", "invoice", "expires_at_unix", "paid_at_unix", "internal", "paidByLnd", "callbackUrl", "paid_amount", "service_fee", "zap_info", "created_at", "updated_at", "userSerialId", "productProductId", "payerSerialId", "linkedApplicationSerialId", "liquidityProvider", "payer_data", "offer_id", "rejectUnauthorized", "bearer_token", "clink_requester_pub", "clink_requester_event_id" FROM "temporary_user_receiving_invoice"`); + await queryRunner.query(`DROP TABLE "temporary_user_receiving_invoice"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a131e6b58f084f1340538681b5" ON "user_receiving_invoice" ("invoice") `); + await queryRunner.query(`CREATE INDEX "recv_invoice_paid_serial" ON "user_receiving_invoice" ("userSerialId", "paid_at_unix", "serial_id") WHERE paid_at_unix > 0`); + await queryRunner.query(`DROP TABLE "invoice_swap"`); + } + +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index d14b8381..ce3f7e8d 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -32,6 +32,7 @@ import { TxSwapAddress1764779178945 } from './1764779178945-tx_swap_address.js' import { ClinkRequester1765497600000 } from './1765497600000-clink_requester.js' import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_provider_height.js' import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' +import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js' export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, @@ -39,7 +40,7 @@ export const allMigrations = [Initial1703170309875, LspOrder1718387847693, Liqui DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036] + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283] export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] /* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise => { diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 090ae271..a1ffa3d1 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -473,20 +473,20 @@ export default class { return this.dbs.FindOne('TransactionSwap', { where: { swap_operation_id: swapOperationId, used: false, app_user_id: appUserId } }, txId) } - async FinalizeTransactionSwap(swapOperationId: string, address: string, txId: string) { + async FinalizeTransactionSwap(swapOperationId: string, address: string, chainTxId: string, txId?: string) { return this.dbs.Update('TransactionSwap', { swap_operation_id: swapOperationId }, { used: true, - tx_id: txId, + tx_id: chainTxId, address_paid: address, - }) + }, txId) } - async FailTransactionSwap(swapOperationId: string, address: string, failureReason: string) { + async FailTransactionSwap(swapOperationId: string, address: string, failureReason: string, txId?: string) { return this.dbs.Update('TransactionSwap', { swap_operation_id: swapOperationId }, { used: true, failure_reason: failureReason, address_paid: address, - }) + }, txId) } async DeleteTransactionSwap(swapOperationId: string, txId?: string) { @@ -522,7 +522,51 @@ export default class { } async GetInvoiceSwap(swapOperationId: string, appUserId: string, txId?: string) { - return this.dbs.FindOne('InvoiceSwap', { where: { swap_operation_id: swapOperationId, used: false, app_user_id: appUserId } }, txId) + const swap = await this.dbs.FindOne('InvoiceSwap', { where: { swap_operation_id: swapOperationId, used: false, app_user_id: appUserId } }, txId) + if (!swap || swap.tx_id) { + return null + } + return swap + } + + async FinalizeInvoiceSwap(swapOperationId: string, txId?: string) { + return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, { + used: true, + }, txId) + } + + async SetInvoiceSwapTxId(swapOperationId: string, chainTxId: string, txId?: string) { + return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, { + tx_id: chainTxId, + }, txId) + } + + async FailInvoiceSwap(swapOperationId: string, failureReason: string, txId?: string) { + return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, { + used: true, + failure_reason: failureReason, + }, txId) + } + + async DeleteInvoiceSwap(swapOperationId: string, txId?: string) { + return this.dbs.Delete('InvoiceSwap', { swap_operation_id: swapOperationId }, txId) + } + + async DeleteExpiredInvoiceSwaps(currentHeight: number, txId?: string) { + return this.dbs.Delete('InvoiceSwap', { timeout_block_height: LessThan(currentHeight) }, txId) + } + + async ListCompletedInvoiceSwaps(appUserId: string, txId?: string) { + return this.dbs.Find('InvoiceSwap', { where: { used: true, app_user_id: appUserId } }, txId) + } + + async ListPendingInvoiceSwaps(appUserId: string, txId?: string) { + return this.dbs.Find('InvoiceSwap', { where: { used: false, app_user_id: appUserId } }, txId) + } + + async ListUnfinishedInvoiceSwaps(txId?: string) { + const swaps = await this.dbs.Find('InvoiceSwap', { where: { used: false } }, txId) + return swaps.filter(s => !s.tx_id) } } From 3b827626a6dd661096fb4cd891cf868d7da40c48 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 28 Jan 2026 15:53:57 +0000 Subject: [PATCH 04/49] handle exit signal when code is null --- src/services/nostr/index.ts | 10 +++++----- src/services/storage/db/storageInterface.ts | 14 +++++++------- src/services/storage/tlv/tlvFilesStorageFactory.ts | 10 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/services/nostr/index.ts b/src/services/nostr/index.ts index 3dce7b41..3cd90e88 100644 --- a/src/services/nostr/index.ts +++ b/src/services/nostr/index.ts @@ -18,12 +18,12 @@ export default class NostrSubprocess { this.log(ERROR, "nostr subprocess error", error) }) - this.childProcess.on("exit", (code) => { - this.log(ERROR, `nostr subprocess exited with code ${code}`) - if (!code) { + this.childProcess.on("exit", (code, signal) => { + this.log(ERROR, `nostr subprocess exited with code ${code} and signal ${signal}`) + if (code === 0) { return } - throw new Error(`nostr subprocess exited with code ${code}`) + throw new Error(`nostr subprocess exited with code ${code} and signal ${signal}`) }) this.childProcess.on("message", (message: ChildProcessResponse) => { @@ -69,6 +69,6 @@ export default class NostrSubprocess { this.sendToChildProcess({ type: 'send', data, initiator, relays }) } Stop() { - this.childProcess.kill() + this.childProcess.kill(0) } } diff --git a/src/services/storage/db/storageInterface.ts b/src/services/storage/db/storageInterface.ts index 941d9533..85b757f5 100644 --- a/src/services/storage/db/storageInterface.ts +++ b/src/services/storage/db/storageInterface.ts @@ -29,7 +29,7 @@ export class StorageInterface extends EventEmitter { private debug: boolean = false; private utils: Utils private dbType: 'main' | 'metrics' - private log = getLogger({component: 'StorageInterface'}) + private log = getLogger({ component: 'StorageInterface' }) constructor(utils: Utils) { super(); this.initializeSubprocess(); @@ -61,13 +61,13 @@ export class StorageInterface extends EventEmitter { this.isConnected = false; }); - this.process.on('exit', (code: number) => { - this.log(ERROR, `Storage processor exited with code ${code}`); + this.process.on('exit', (code: number, signal: string) => { + this.log(ERROR, `Storage processor exited with code ${code} and signal ${signal}`); this.isConnected = false; - if (!code) { + if (code === 0) { return } - throw new Error(`Storage processor exited with code ${code}`) + throw new Error(`Storage processor exited with code ${code} and signal ${signal}`) }); this.isConnected = true; @@ -179,7 +179,7 @@ export class StorageInterface extends EventEmitter { reject(new Error('Invalid storage response type')); return } - resolve(deserializeResponseData(response.data)); + resolve(deserializeResponseData(response.data)); } this.once(op.opId, responseHandler) this.process.send(this.serializeOperation(op)) @@ -205,7 +205,7 @@ export class StorageInterface extends EventEmitter { public disconnect() { if (this.process) { - this.process.kill(); + this.process.kill(0); this.isConnected = false; this.debug = false; } diff --git a/src/services/storage/tlv/tlvFilesStorageFactory.ts b/src/services/storage/tlv/tlvFilesStorageFactory.ts index 0e396703..3eb2d117 100644 --- a/src/services/storage/tlv/tlvFilesStorageFactory.ts +++ b/src/services/storage/tlv/tlvFilesStorageFactory.ts @@ -53,13 +53,13 @@ export class TlvStorageFactory extends EventEmitter { this.isConnected = false; }); - this.process.on('exit', (code: number) => { - this.log(ERROR, `Tlv Storage processor exited with code ${code}`); + this.process.on('exit', (code: number, signal: string) => { + this.log(ERROR, `Tlv Storage processor exited with code ${code} and signal ${signal}`); this.isConnected = false; - if (!code) { + if (code === 0) { return } - throw new Error(`Tlv Storage processor exited with code ${code}`) + throw new Error(`Tlv Storage processor exited with code ${code} and signal ${signal}`) }); this.isConnected = true; @@ -173,7 +173,7 @@ export class TlvStorageFactory extends EventEmitter { public disconnect() { if (this.process) { - this.process.kill(); + this.process.kill(0); this.isConnected = false; this.debug = false; } From 3255730ae22030b6e7086860408dfaed66e6c4b1 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Fri, 30 Jan 2026 20:37:44 +0000 Subject: [PATCH 05/49] swap refunds --- datasource.js | 5 +- proto/autogenerated/client.md | 17 ++ proto/autogenerated/go/http_client.go | 30 ++ proto/autogenerated/go/types.go | 5 + proto/autogenerated/ts/express_server.ts | 22 ++ proto/autogenerated/ts/http_client.ts | 14 + proto/autogenerated/ts/nostr_client.ts | 15 + proto/autogenerated/ts/nostr_transport.ts | 16 + proto/autogenerated/ts/types.ts | 41 ++- proto/service/methods.proto | 7 + proto/service/structs.proto | 6 + src/services/lnd/lnd.ts | 9 +- src/services/lnd/swaps/reverseSwaps.ts | 2 +- src/services/lnd/swaps/submarineSwaps.ts | 277 +++++++++++++++++- src/services/lnd/swaps/swaps.ts | 30 ++ src/services/main/adminManager.ts | 24 ++ src/services/storage/entity/InvoiceSwap.ts | 3 + .../migrations/1769529793283-invoice_swaps.ts | 16 - .../1769805357459-invoice_swaps_fixes.ts | 21 ++ src/services/storage/migrations/runner.ts | 4 +- src/services/storage/paymentStorage.ts | 23 +- 21 files changed, 554 insertions(+), 33 deletions(-) create mode 100644 src/services/storage/migrations/1769805357459-invoice_swaps_fixes.ts diff --git a/datasource.js b/datasource.js index ae243ada..081634d8 100644 --- a/datasource.js +++ b/datasource.js @@ -49,6 +49,7 @@ import { TxSwapAddress1764779178945 } from './build/src/services/storage/migrati import { ClinkRequester1765497600000 } from './build/src/services/storage/migrations/1765497600000-clink_requester.js' import { TrackedProviderHeight1766504040000 } from './build/src/services/storage/migrations/1766504040000-tracked_provider_height.js' import { SwapsServiceUrl1768413055036 } from './build/src/services/storage/migrations/1768413055036-swaps_service_url.js' +import { InvoiceSwaps1769529793283 } from './build/src/services/storage/migrations/1769529793283-invoice_swaps.js' export default new DataSource({ type: "better-sqlite3", @@ -58,11 +59,11 @@ export default new DataSource({ PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036], + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283], entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment, UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo, TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings, TransactionSwap, InvoiceSwap], // synchronize: true, }) -//npx typeorm migration:generate ./src/services/storage/migrations/invoice_swaps -d ./datasource.js \ No newline at end of file +//npx typeorm migration:generate ./src/services/storage/migrations/invoice_swaps_fixes -d ./datasource.js \ No newline at end of file diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index 774ed9cd..60dd284e 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -320,6 +320,11 @@ The nostr server will send back a message response, and inside the body there wi - This methods has an __empty__ __request__ body - This methods has an __empty__ __response__ body +- RefundAdminInvoiceSwap + - auth type: __Admin__ + - input: [RefundAdminInvoiceSwapRequest](#RefundAdminInvoiceSwapRequest) + - output: [AdminInvoiceSwapResponse](#AdminInvoiceSwapResponse) + - ResetDebit - auth type: __User__ - input: [DebitOperation](#DebitOperation) @@ -963,6 +968,13 @@ The nostr server will send back a message response, and inside the body there wi - This methods has an __empty__ __request__ body - This methods has an __empty__ __response__ body +- RefundAdminInvoiceSwap + - auth type: __Admin__ + - http method: __post__ + - http route: __/api/admin/swap/invoice/refund__ + - input: [RefundAdminInvoiceSwapRequest](#RefundAdminInvoiceSwapRequest) + - output: [AdminInvoiceSwapResponse](#AdminInvoiceSwapResponse) + - RequestNPubLinkingToken - auth type: __App__ - http method: __post__ @@ -1603,6 +1615,7 @@ The nostr server will send back a message response, and inside the body there wi - __txId__: _string_ ### PayAdminInvoiceSwapRequest + - __no_claim__: _boolean_ *this field is optional - __sat_per_v_byte__: _number_ - __swap_operation_id__: _string_ @@ -1656,6 +1669,10 @@ The nostr server will send back a message response, and inside the body there wi ### ProvidersDisruption - __disruptions__: ARRAY of: _[ProviderDisruption](#ProviderDisruption)_ +### RefundAdminInvoiceSwapRequest + - __sat_per_v_byte__: _number_ + - __swap_operation_id__: _string_ + ### RelaysMigration - __relays__: ARRAY of: _string_ diff --git a/proto/autogenerated/go/http_client.go b/proto/autogenerated/go/http_client.go index a010fd82..98c9c648 100644 --- a/proto/autogenerated/go/http_client.go +++ b/proto/autogenerated/go/http_client.go @@ -130,6 +130,7 @@ type Client struct { PayAppUserInvoice func(req PayAppUserInvoiceRequest) (*PayInvoiceResponse, error) PayInvoice func(req PayInvoiceRequest) (*PayInvoiceResponse, error) PingSubProcesses func() error + RefundAdminInvoiceSwap func(req RefundAdminInvoiceSwapRequest) (*AdminInvoiceSwapResponse, error) RequestNPubLinkingToken func(req RequestNPubLinkingTokenRequest) (*RequestNPubLinkingTokenResponse, error) ResetDebit func(req DebitOperation) error ResetManage func(req ManageOperation) error @@ -2087,6 +2088,35 @@ func NewClient(params ClientParams) *Client { } return nil }, + RefundAdminInvoiceSwap: func(req RefundAdminInvoiceSwapRequest) (*AdminInvoiceSwapResponse, error) { + auth, err := params.RetrieveAdminAuth() + if err != nil { + return nil, err + } + finalRoute := "/api/admin/swap/invoice/refund" + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) + if err != nil { + return nil, err + } + result := ResultError{} + err = json.Unmarshal(resBody, &result) + if err != nil { + return nil, err + } + if result.Status == "ERROR" { + return nil, fmt.Errorf(result.Reason) + } + res := AdminInvoiceSwapResponse{} + err = json.Unmarshal(resBody, &res) + if err != nil { + return nil, err + } + return &res, nil + }, RequestNPubLinkingToken: func(req RequestNPubLinkingTokenRequest) (*RequestNPubLinkingTokenResponse, error) { auth, err := params.RetrieveAppAuth() if err != nil { diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index f1d45300..d5b21b2f 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -592,6 +592,7 @@ type PayAddressResponse struct { Txid string `json:"txId"` } type PayAdminInvoiceSwapRequest struct { + No_claim bool `json:"no_claim"` Sat_per_v_byte int64 `json:"sat_per_v_byte"` Swap_operation_id string `json:"swap_operation_id"` } @@ -645,6 +646,10 @@ type ProviderDisruption struct { type ProvidersDisruption struct { Disruptions []ProviderDisruption `json:"disruptions"` } +type RefundAdminInvoiceSwapRequest struct { + Sat_per_v_byte int64 `json:"sat_per_v_byte"` + Swap_operation_id string `json:"swap_operation_id"` +} type RelaysMigration struct { Relays []string `json:"relays"` } diff --git a/proto/autogenerated/ts/express_server.ts b/proto/autogenerated/ts/express_server.ts index 8ce0aa96..03ff728a 100644 --- a/proto/autogenerated/ts/express_server.ts +++ b/proto/autogenerated/ts/express_server.ts @@ -1941,6 +1941,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { 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 } }) + if (!opts.allowNotImplementedMethods && !methods.RefundAdminInvoiceSwap) throw new Error('method: RefundAdminInvoiceSwap is not implemented') + app.post('/api/admin/swap/invoice/refund', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'RefundAdminInvoiceSwap', batch: false, nostr: false, batchSize: 0} + const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } + let authCtx: Types.AuthContext = {} + try { + if (!methods.RefundAdminInvoiceSwap) throw new Error('method: RefundAdminInvoiceSwap is not implemented') + const authContext = await opts.AdminAuthGuard(req.headers['authorization']) + authCtx = authContext + stats.guard = process.hrtime.bigint() + const request = req.body + const error = Types.RefundAdminInvoiceSwapRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback) + const query = req.query + const params = req.params + const response = await methods.RefundAdminInvoiceSwap({rpcName:'RefundAdminInvoiceSwap', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res.json({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 } + }) if (!opts.allowNotImplementedMethods && !methods.RequestNPubLinkingToken) throw new Error('method: RequestNPubLinkingToken is not implemented') app.post('/api/app/user/npub/token', async (req, res) => { const info: Types.RequestInfo = { rpcName: 'RequestNPubLinkingToken', batch: false, nostr: false, batchSize: 0} diff --git a/proto/autogenerated/ts/http_client.ts b/proto/autogenerated/ts/http_client.ts index e84470eb..195737be 100644 --- a/proto/autogenerated/ts/http_client.ts +++ b/proto/autogenerated/ts/http_client.ts @@ -1004,6 +1004,20 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, + RefundAdminInvoiceSwap: async (request: Types.RefundAdminInvoiceSwapRequest): Promise => { + const auth = await params.retrieveAdminAuth() + if (auth === null) throw new Error('retrieveAdminAuth() returned null') + let finalRoute = '/api/admin/swap/invoice/refund' + const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.AdminInvoiceSwapResponseValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, RequestNPubLinkingToken: async (request: Types.RequestNPubLinkingTokenRequest): Promise => { const auth = await params.retrieveAppAuth() if (auth === null) throw new Error('retrieveAppAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_client.ts b/proto/autogenerated/ts/nostr_client.ts index 2db7e8ce..d174d247 100644 --- a/proto/autogenerated/ts/nostr_client.ts +++ b/proto/autogenerated/ts/nostr_client.ts @@ -883,6 +883,21 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, + RefundAdminInvoiceSwap: async (request: Types.RefundAdminInvoiceSwapRequest): Promise => { + const auth = await params.retrieveNostrAdminAuth() + if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') + const nostrRequest: NostrRequest = {} + nostrRequest.body = request + const data = await send(params.pubDestination, {rpcName:'RefundAdminInvoiceSwap',authIdentifier:auth, ...nostrRequest }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.AdminInvoiceSwapResponseValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, ResetDebit: async (request: Types.DebitOperation): Promise => { const auth = await params.retrieveNostrUserAuth() if (auth === null) throw new Error('retrieveNostrUserAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_transport.ts b/proto/autogenerated/ts/nostr_transport.ts index 8a586d5b..4f9307b3 100644 --- a/proto/autogenerated/ts/nostr_transport.ts +++ b/proto/autogenerated/ts/nostr_transport.ts @@ -1344,6 +1344,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 'RefundAdminInvoiceSwap': + try { + if (!methods.RefundAdminInvoiceSwap) throw new Error('method: RefundAdminInvoiceSwap is not implemented') + const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + const request = req.body + const error = Types.RefundAdminInvoiceSwapRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback) + const response = await methods.RefundAdminInvoiceSwap({rpcName:'RefundAdminInvoiceSwap', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + 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 'ResetDebit': try { if (!methods.ResetDebit) throw new Error('method: ResetDebit is not implemented') diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index 78add454..a738a06c 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -7,8 +7,8 @@ export type RequestMetric = AuthContext & RequestInfo & RequestStats & { error?: export type AdminContext = { admin_id: string } -export type AdminMethodInputs = AddApp_Input | AddPeer_Input | AuthApp_Input | BanUser_Input | CloseChannel_Input | CreateOneTimeInviteLink_Input | GetAdminInvoiceSwapQuotes_Input | GetAdminTransactionSwapQuotes_Input | GetInviteLinkState_Input | GetSeed_Input | ListAdminInvoiceSwaps_Input | ListAdminTxSwaps_Input | ListChannels_Input | LndGetInfo_Input | OpenChannel_Input | PayAdminInvoiceSwap_Input | PayAdminTransactionSwap_Input | UpdateChannelPolicy_Input -export type AdminMethodOutputs = AddApp_Output | AddPeer_Output | AuthApp_Output | BanUser_Output | CloseChannel_Output | CreateOneTimeInviteLink_Output | GetAdminInvoiceSwapQuotes_Output | GetAdminTransactionSwapQuotes_Output | GetInviteLinkState_Output | GetSeed_Output | ListAdminInvoiceSwaps_Output | ListAdminTxSwaps_Output | ListChannels_Output | LndGetInfo_Output | OpenChannel_Output | PayAdminInvoiceSwap_Output | PayAdminTransactionSwap_Output | UpdateChannelPolicy_Output +export type AdminMethodInputs = AddApp_Input | AddPeer_Input | AuthApp_Input | BanUser_Input | CloseChannel_Input | CreateOneTimeInviteLink_Input | GetAdminInvoiceSwapQuotes_Input | GetAdminTransactionSwapQuotes_Input | GetInviteLinkState_Input | GetSeed_Input | ListAdminInvoiceSwaps_Input | ListAdminTxSwaps_Input | ListChannels_Input | LndGetInfo_Input | OpenChannel_Input | PayAdminInvoiceSwap_Input | PayAdminTransactionSwap_Input | RefundAdminInvoiceSwap_Input | UpdateChannelPolicy_Input +export type AdminMethodOutputs = AddApp_Output | AddPeer_Output | AuthApp_Output | BanUser_Output | CloseChannel_Output | CreateOneTimeInviteLink_Output | GetAdminInvoiceSwapQuotes_Output | GetAdminTransactionSwapQuotes_Output | GetInviteLinkState_Output | GetSeed_Output | ListAdminInvoiceSwaps_Output | ListAdminTxSwaps_Output | ListChannels_Output | LndGetInfo_Output | OpenChannel_Output | PayAdminInvoiceSwap_Output | PayAdminTransactionSwap_Output | RefundAdminInvoiceSwap_Output | UpdateChannelPolicy_Output export type AppContext = { app_id: string } @@ -289,6 +289,9 @@ export type PayInvoice_Output = ResultError | ({ status: 'OK' } & PayInvoiceResp export type PingSubProcesses_Input = {rpcName:'PingSubProcesses'} export type PingSubProcesses_Output = ResultError | { status: 'OK' } +export type RefundAdminInvoiceSwap_Input = {rpcName:'RefundAdminInvoiceSwap', req: RefundAdminInvoiceSwapRequest} +export type RefundAdminInvoiceSwap_Output = ResultError | ({ status: 'OK' } & AdminInvoiceSwapResponse) + export type RequestNPubLinkingToken_Input = {rpcName:'RequestNPubLinkingToken', req: RequestNPubLinkingTokenRequest} export type RequestNPubLinkingToken_Output = ResultError | ({ status: 'OK' } & RequestNPubLinkingTokenResponse) @@ -422,6 +425,7 @@ export type ServerMethods = { PayAppUserInvoice?: (req: PayAppUserInvoice_Input & {ctx: AppContext }) => Promise PayInvoice?: (req: PayInvoice_Input & {ctx: UserContext }) => Promise PingSubProcesses?: (req: PingSubProcesses_Input & {ctx: MetricsContext }) => Promise + RefundAdminInvoiceSwap?: (req: RefundAdminInvoiceSwap_Input & {ctx: AdminContext }) => Promise RequestNPubLinkingToken?: (req: RequestNPubLinkingToken_Input & {ctx: AppContext }) => Promise ResetDebit?: (req: ResetDebit_Input & {ctx: UserContext }) => Promise ResetManage?: (req: ResetManage_Input & {ctx: UserContext }) => Promise @@ -3518,12 +3522,15 @@ export const PayAddressResponseValidate = (o?: PayAddressResponse, opts: PayAddr } export type PayAdminInvoiceSwapRequest = { + no_claim?: boolean sat_per_v_byte: number swap_operation_id: string } -export const PayAdminInvoiceSwapRequestOptionalFields: [] = [] +export type PayAdminInvoiceSwapRequestOptionalField = 'no_claim' +export const PayAdminInvoiceSwapRequestOptionalFields: PayAdminInvoiceSwapRequestOptionalField[] = ['no_claim'] export type PayAdminInvoiceSwapRequestOptions = OptionsBaseMessage & { - checkOptionalsAreSet?: [] + checkOptionalsAreSet?: PayAdminInvoiceSwapRequestOptionalField[] + no_claim_CustomCheck?: (v?: boolean) => boolean sat_per_v_byte_CustomCheck?: (v: number) => boolean swap_operation_id_CustomCheck?: (v: string) => boolean } @@ -3531,6 +3538,9 @@ export const PayAdminInvoiceSwapRequestValidate = (o?: PayAdminInvoiceSwapReques 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') + if ((o.no_claim || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('no_claim')) && typeof o.no_claim !== 'boolean') return new Error(`${path}.no_claim: is not a boolean`) + if (opts.no_claim_CustomCheck && !opts.no_claim_CustomCheck(o.no_claim)) return new Error(`${path}.no_claim: custom check failed`) + if (typeof o.sat_per_v_byte !== 'number') return new Error(`${path}.sat_per_v_byte: is not a number`) if (opts.sat_per_v_byte_CustomCheck && !opts.sat_per_v_byte_CustomCheck(o.sat_per_v_byte)) return new Error(`${path}.sat_per_v_byte: custom check failed`) @@ -3832,6 +3842,29 @@ export const ProvidersDisruptionValidate = (o?: ProvidersDisruption, opts: Provi return null } +export type RefundAdminInvoiceSwapRequest = { + sat_per_v_byte: number + swap_operation_id: string +} +export const RefundAdminInvoiceSwapRequestOptionalFields: [] = [] +export type RefundAdminInvoiceSwapRequestOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + sat_per_v_byte_CustomCheck?: (v: number) => boolean + swap_operation_id_CustomCheck?: (v: string) => boolean +} +export const RefundAdminInvoiceSwapRequestValidate = (o?: RefundAdminInvoiceSwapRequest, opts: RefundAdminInvoiceSwapRequestOptions = {}, path: string = 'RefundAdminInvoiceSwapRequest::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') + + if (typeof o.sat_per_v_byte !== 'number') return new Error(`${path}.sat_per_v_byte: is not a number`) + if (opts.sat_per_v_byte_CustomCheck && !opts.sat_per_v_byte_CustomCheck(o.sat_per_v_byte)) return new Error(`${path}.sat_per_v_byte: custom check failed`) + + if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) + if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) + + return null +} + export type RelaysMigration = { relays: string[] } diff --git a/proto/service/methods.proto b/proto/service/methods.proto index 15292e37..3eeef126 100644 --- a/proto/service/methods.proto +++ b/proto/service/methods.proto @@ -196,6 +196,13 @@ service LightningPub { option (nostr) = true; } + rpc RefundAdminInvoiceSwap(structs.RefundAdminInvoiceSwapRequest) returns (structs.AdminInvoiceSwapResponse) { + option (auth_type) = "Admin"; + option (http_method) = "post"; + option (http_route) = "/api/admin/swap/invoice/refund"; + option (nostr) = true; + } + rpc GetAdminTransactionSwapQuotes(structs.TransactionSwapRequest) returns (structs.TransactionSwapQuoteList) { option (auth_type) = "Admin"; option (http_method) = "post"; diff --git a/proto/service/structs.proto b/proto/service/structs.proto index e93e6691..5b25c699 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -867,9 +867,15 @@ message InvoiceSwapsList { repeated InvoiceSwapQuote quotes = 2; } +message RefundAdminInvoiceSwapRequest { + string swap_operation_id = 1; + int64 sat_per_v_byte = 2; +} + message PayAdminInvoiceSwapRequest { string swap_operation_id = 1; int64 sat_per_v_byte = 2; + optional bool no_claim = 3; } message AdminInvoiceSwapResponse { diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index daf7b411..e1f8ed1b 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -23,7 +23,7 @@ import { TxPointSettings } from '../storage/tlv/stateBundler.js'; import { WalletKitClient } from '../../../proto/lnd/walletkit.client.js'; import SettingsManager from '../main/settingsManager.js'; import { LndNodeSettings, LndSettings } from '../main/settings.js'; -import { ListAddressesResponse } from '../../../proto/lnd/walletkit.js'; +import { ListAddressesResponse, PublishResponse } from '../../../proto/lnd/walletkit.js'; const DeadLineMetadata = (deadline = 10 * 1000) => ({ deadline: Date.now() + deadline }) const deadLndRetrySeconds = 20 @@ -156,6 +156,13 @@ export default class { }) } + async PublishTransaction(txHex: string): Promise { + const res = await this.walletKit.publishTransaction({ + txHex: Buffer.from(txHex, 'hex'), label: "" + }, DeadLineMetadata()) + return res.response + } + async GetInfo(): Promise { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { // Return dummy info when bypass is enabled diff --git a/src/services/lnd/swaps/reverseSwaps.ts b/src/services/lnd/swaps/reverseSwaps.ts index 4510389b..f0a5bc03 100644 --- a/src/services/lnd/swaps/reverseSwaps.ts +++ b/src/services/lnd/swaps/reverseSwaps.ts @@ -3,7 +3,7 @@ import { initEccLib, Transaction, address } from 'bitcoinjs-lib'; // import bolt11 from 'bolt11'; import { Musig, SwapTreeSerializer, TaprootUtils, detectSwap, - constructClaimTransaction, OutputType, + constructClaimTransaction, OutputType, constructRefundTransaction } from 'boltz-core'; import { randomBytes, createHash } from 'crypto'; import { ECPairFactory, ECPairInterface } from 'ecpair'; diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index ffbb8545..bf223b3e 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -1,14 +1,16 @@ import zkpInit from '@vulpemventures/secp256k1-zkp'; // import bolt11 from 'bolt11'; import { - Musig, SwapTreeSerializer, TaprootUtils + Musig, SwapTreeSerializer, TaprootUtils, constructRefundTransaction, + detectSwap, OutputType } from 'boltz-core'; import { randomBytes, createHash } from 'crypto'; import { ECPairFactory, ECPairInterface } from 'ecpair'; import * as ecc from 'tiny-secp256k1'; +import { Transaction, address } from 'bitcoinjs-lib'; import ws from 'ws'; import { getLogger, PubLogger, ERROR } from '../../helpers/logger.js'; -import { loggedGet, loggedPost } from './swapHelpers.js'; +import { loggedGet, loggedPost, getNetwork } from './swapHelpers.js'; import { BTCNetwork } from '../../main/settings.js'; /* type InvoiceSwapFees = { @@ -47,10 +49,12 @@ export type InvoiceSwapData = { createdResponse: InvoiceSwapResponse, info: Invo export class SubmarineSwaps { private httpUrl: string private wsUrl: string + private network: BTCNetwork log: PubLogger - constructor({ httpUrl, wsUrl }: { httpUrl: string, wsUrl: string, network: BTCNetwork }) { + constructor({ httpUrl, wsUrl, network }: { httpUrl: string, wsUrl: string, network: BTCNetwork }) { this.httpUrl = httpUrl this.wsUrl = wsUrl + this.network = network this.log = getLogger({ component: 'SubmarineSwaps' }) } @@ -97,6 +101,273 @@ export class SubmarineSwaps { } + /** + * Get the lockup transaction for a swap from Boltz + */ + private getLockupTransaction = async (swapId: string): Promise<{ ok: true, data: { hex: string } } | { ok: false, error: string }> => { + const url = `${this.httpUrl}/v2/swap/submarine/${swapId}/transaction` + return await loggedGet<{ hex: string }>(this.log, url) + } + + /** + * Get partial refund signature from Boltz for cooperative refund + */ + private getPartialRefundSignature = async ( + swapId: string, + pubNonce: Buffer, + transaction: Transaction, + index: number + ): Promise<{ ok: true, data: { pubNonce: string, partialSignature: string } } | { ok: false, error: string }> => { + const url = `${this.httpUrl}/v2/swap/submarine/${swapId}/refund` + const req = { + index, + pubNonce: pubNonce.toString('hex'), + transaction: transaction.toHex() + } + return await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, url, req) + } + + /** + * Constructs a Taproot refund transaction (cooperative or uncooperative) + */ + private constructTaprootRefund = async ( + swapId: string, + claimPublicKey: string, + swapTree: string, + timeoutBlockHeight: number, + lockupTx: Transaction, + privateKey: ECPairInterface, + refundAddress: string, + feePerVbyte: number, + cooperative: boolean = true + ): Promise<{ + ok: true, + transaction: Transaction, + cooperativeError?: string + } | { + ok: false, + error: string + }> => { + this.log(`Constructing ${cooperative ? 'cooperative' : 'uncooperative'} Taproot refund for swap ${swapId}`) + + const boltzPublicKey = Buffer.from(claimPublicKey, 'hex') + const swapTreeDeserialized = SwapTreeSerializer.deserializeSwapTree(swapTree) + + // Create musig and tweak it + let musig = new Musig(await zkpInit(), privateKey, randomBytes(32), [ + boltzPublicKey, + Buffer.from(privateKey.publicKey), + ]) + const tweakedKey = TaprootUtils.tweakMusig(musig, swapTreeDeserialized.tree) + + // Detect the swap output in the lockup transaction + const swapOutput = detectSwap(tweakedKey, lockupTx) + if (!swapOutput) { + return { ok: false, error: 'Could not detect swap output in lockup transaction' } + } + + const network = getNetwork(this.network) + // const decodedAddress = address.fromBech32(refundAddress) + + const details = [ + { + ...swapOutput, + keys: privateKey, + cooperative, + type: OutputType.Taproot, + txHash: lockupTx.getHash(), + swapTree: swapTreeDeserialized, + internalKey: musig.getAggregatedPublicKey(), + } + ] + const outputScript = address.toOutputScript(refundAddress, network) + // Construct the refund transaction + const refundTx = constructRefundTransaction( + details, + outputScript, + cooperative ? 0 : timeoutBlockHeight, + feePerVbyte, + true + ) + + if (!cooperative) { + return { ok: true, transaction: refundTx } + } + + // For cooperative refund, get Boltz's partial signature + try { + musig = new Musig(await zkpInit(), privateKey, randomBytes(32), [ + boltzPublicKey, + Buffer.from(privateKey.publicKey), + ]) + // Get the partial signature from Boltz + const boltzSigRes = await this.getPartialRefundSignature( + swapId, + Buffer.from(musig.getPublicNonce()), + refundTx, + 0 + ) + + if (!boltzSigRes.ok) { + this.log(ERROR, 'Failed to get Boltz partial signature, falling back to uncooperative refund') + // Fallback to uncooperative refund + return await this.constructTaprootRefund( + swapId, + claimPublicKey, + swapTree, + timeoutBlockHeight, + lockupTx, + privateKey, + refundAddress, + feePerVbyte, + false + ) + } + + const boltzSig = boltzSigRes.data + + // Aggregate nonces + musig.aggregateNonces([ + [boltzPublicKey, Musig.parsePubNonce(boltzSig.pubNonce)], + ]) + + // Tweak musig again after aggregating nonces + TaprootUtils.tweakMusig(musig, swapTreeDeserialized.tree) + + // Initialize session and sign + musig.initializeSession( + TaprootUtils.hashForWitnessV1( + details, + refundTx, + 0 + ) + ) + + musig.signPartial() + musig.addPartial(boltzPublicKey, Buffer.from(boltzSig.partialSignature, 'hex')) + + // Set the witness to the aggregated signature + refundTx.ins[0].witness = [musig.aggregatePartials()] + + return { ok: true, transaction: refundTx } + } catch (error: any) { + this.log(ERROR, 'Cooperative refund failed:', error.message) + // Fallback to uncooperative refund + return await this.constructTaprootRefund( + swapId, + claimPublicKey, + swapTree, + timeoutBlockHeight, + lockupTx, + privateKey, + refundAddress, + feePerVbyte, + false + ) + } + } + + /** + * Broadcasts a refund transaction + */ + private broadcastRefundTransaction = async (transaction: Transaction): Promise<{ ok: true, txId: string } | { ok: false, error: string }> => { + const url = `${this.httpUrl}/v2/chain/BTC/transaction` + const req = { hex: transaction.toHex() } + + const result = await loggedPost<{ id: string }>(this.log, url, req) + if (!result.ok) { + return result + } + + return { ok: true, txId: result.data.id } + } + + /** + * Refund a submarine swap + * @param swapId - The swap ID + * @param claimPublicKey - Boltz's claim public key + * @param swapTree - The swap tree + * @param timeoutBlockHeight - The timeout block height + * @param privateKey - The refund private key (hex string) + * @param refundAddress - The address to refund to + * @param currentHeight - The current block height + * @param lockupTxHex - The lockup transaction hex (optional, will fetch from Boltz if not provided) + * @param feePerVbyte - Fee rate in sat/vbyte (optional, will use default if not provided) + */ + RefundSwap = async (params: { + swapId: string, + claimPublicKey: string, + swapTree: string, + timeoutBlockHeight: number, + privateKeyHex: string, + refundAddress: string, + currentHeight: number, + lockupTxHex?: string, + feePerVbyte?: number + }): Promise<{ ok: true, publish: { done: false, txHex: string, txId: string } | { done: true, txId: string } } | { ok: false, error: string }> => { + const { swapId, claimPublicKey, swapTree, timeoutBlockHeight, privateKeyHex, refundAddress, currentHeight, lockupTxHex, feePerVbyte = 2 } = params + + this.log('Starting refund process for swap:', swapId) + + // Get the lockup transaction (from parameter or fetch from Boltz) + let lockupTx: Transaction + if (lockupTxHex) { + this.log('Using provided lockup transaction hex') + lockupTx = Transaction.fromHex(lockupTxHex) + } else { + this.log('Fetching lockup transaction from Boltz') + const lockupTxRes = await this.getLockupTransaction(swapId) + if (!lockupTxRes.ok) { + return { ok: false, error: `Failed to get lockup transaction: ${lockupTxRes.error}` } + } + lockupTx = Transaction.fromHex(lockupTxRes.data.hex) + } + this.log('Lockup transaction retrieved:', lockupTx.getId()) + + // Check if swap has timed out + if (currentHeight < timeoutBlockHeight) { + return { + ok: false, + error: `Swap has not timed out yet. Current height: ${currentHeight}, timeout: ${timeoutBlockHeight}` + } + } + this.log(`Swap has timed out. Current height: ${currentHeight}, timeout: ${timeoutBlockHeight}`) + + // Parse the private key + const privateKey = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKeyHex, 'hex')) + + // Construct the refund transaction (tries cooperative first, then falls back to uncooperative) + const refundTxRes = await this.constructTaprootRefund( + swapId, + claimPublicKey, + swapTree, + timeoutBlockHeight, + lockupTx, + privateKey, + refundAddress, + feePerVbyte, + true // Try cooperative first + ) + + if (!refundTxRes.ok) { + return { ok: false, error: refundTxRes.error } + } + + const cooperative = !refundTxRes.cooperativeError + this.log(`Refund transaction constructed (${cooperative ? 'cooperative' : 'uncooperative'}):`, refundTxRes.transaction.getId()) + if (!cooperative) { + return { ok: true, publish: { done: false, txHex: refundTxRes.transaction.toHex(), txId: refundTxRes.transaction.getId() } } + } + // Broadcast the refund transaction + const broadcastRes = await this.broadcastRefundTransaction(refundTxRes.transaction) + if (!broadcastRes.ok) { + return { ok: false, error: `Failed to broadcast refund transaction: ${broadcastRes.error}` } + } + + this.log('Refund transaction broadcasted successfully:', broadcastRes.txId) + return { ok: true, publish: { done: true, txId: broadcastRes.txId } } + } + SubscribeToInvoiceSwap = (data: InvoiceSwapData, swapDone: (result: { ok: true } | { ok: false, error: string }) => void, waitingTx: () => void) => { const webSocket = new ws(`${this.wsUrl}/v2/ws`) const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] } diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index 381e0690..79a3f408 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -100,6 +100,36 @@ export class Swaps { } } + RefundInvoiceSwap = async (swapOperationId: string, satPerVByte: number, refundAddress: string, currentHeight: number): Promise<{ published: false, txHex: string, txId: string } | { published: true, txId: string }> => { + const swap = await this.storage.paymentStorage.GetRefundableInvoiceSwap(swapOperationId) + if (!swap) { + throw new Error("Swap not found or already used") + } + const swapper = this.subSwappers[swap.service_url] + if (!swapper) { + throw new Error("swapper service not found") + } + const result = await swapper.RefundSwap({ + swapId: swap.swap_quote_id, + claimPublicKey: swap.claim_public_key, + currentHeight, + privateKeyHex: swap.ephemeral_private_key, + refundAddress, + swapTree: swap.swap_tree, + timeoutBlockHeight: swap.timeout_block_height, + feePerVbyte: satPerVByte, + lockupTxHex: swap.lockup_tx_hex, + }) + if (!result.ok) { + throw new Error(result.error) + } + if (result.publish.done) { + return { published: true, txId: result.publish.txId } + } + return { published: false, txHex: result.publish.txHex, txId: result.publish.txId } + + } + PayInvoiceSwap = async (appUserId: string, swapOpId: string, satPerVByte: number, payAddress: (address: string, amt: number) => Promise<{ txId: string }>): Promise => { if (!this.settings.getSettings().swapsSettings.enableSwaps) { throw new Error("Swaps are not enabled") diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 8284b253..8fae2b5d 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -274,6 +274,18 @@ export class AdminManager { this.swaps.PayInvoiceSwap("admin", req.swap_operation_id, req.sat_per_v_byte, async (addr, amt) => { const tx = await this.lnd.PayAddress(addr, amt, req.sat_per_v_byte, "", { useProvider: false, from: 'system' }) this.log("paid admin invoice swap", { swapOpId: req.swap_operation_id, txId: tx.txid }) + await this.storage.metricsStorage.AddRootOperation("chain_payment", txId, amt) + + // Fetch the full transaction hex for potential refunds + let lockupTxHex: string | undefined + try { + const txDetails = await this.lnd.GetTx(tx.txid) + lockupTxHex = txDetails.rawTxHex + } catch (err: any) { + this.log("Warning: Could not fetch transaction hex for refund purposes:", err.message) + } + + await this.storage.paymentStorage.SetInvoiceSwapTxId(req.swap_operation_id, txId, lockupTxHex) res(tx.txid) return { txId: tx.txid } }) @@ -281,6 +293,18 @@ export class AdminManager { return { tx_id: txId } } + async RefundAdminInvoiceSwap(req: Types.RefundAdminInvoiceSwapRequest): Promise { + const info = await this.lnd.GetInfo() + const currentHeight = info.blockHeight + const address = await this.lnd.NewAddress(Types.AddressType.WITNESS_PUBKEY_HASH, { useProvider: false, from: 'system' }) + const result = await this.swaps.RefundInvoiceSwap(req.swap_operation_id, req.sat_per_v_byte, address.address, currentHeight) + if (result.published) { + return { tx_id: result.txId } + } + await this.lnd.PublishTransaction(result.txHex) + return { tx_id: result.txId } + } + async ListAdminTxSwaps(): Promise { return this.swaps.ListTxSwaps("admin", [], p => undefined, amt => 0) } diff --git a/src/services/storage/entity/InvoiceSwap.ts b/src/services/storage/entity/InvoiceSwap.ts index 2ab18347..f435edab 100644 --- a/src/services/storage/entity/InvoiceSwap.ts +++ b/src/services/storage/entity/InvoiceSwap.ts @@ -71,6 +71,9 @@ export class InvoiceSwap { @Column({ default: "" }) tx_id: string + @Column({ default: "", type: "text" }) + lockup_tx_hex: string + /* @Column({ default: "" }) address_paid: string */ diff --git a/src/services/storage/migrations/1769529793283-invoice_swaps.ts b/src/services/storage/migrations/1769529793283-invoice_swaps.ts index b32cc1de..f7b93755 100644 --- a/src/services/storage/migrations/1769529793283-invoice_swaps.ts +++ b/src/services/storage/migrations/1769529793283-invoice_swaps.ts @@ -5,25 +5,9 @@ export class InvoiceSwaps1769529793283 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`CREATE TABLE "invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`); - await queryRunner.query(`DROP INDEX "recv_invoice_paid_serial"`); - await queryRunner.query(`DROP INDEX "IDX_a131e6b58f084f1340538681b5"`); - await queryRunner.query(`CREATE TABLE "temporary_user_receiving_invoice" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "invoice" varchar NOT NULL, "expires_at_unix" integer NOT NULL, "paid_at_unix" integer NOT NULL DEFAULT (0), "internal" boolean NOT NULL DEFAULT (0), "paidByLnd" boolean NOT NULL DEFAULT (0), "callbackUrl" varchar NOT NULL DEFAULT (''), "paid_amount" integer NOT NULL DEFAULT (0), "service_fee" integer NOT NULL DEFAULT (0), "zap_info" text, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "productProductId" varchar, "payerSerialId" integer, "linkedApplicationSerialId" integer, "liquidityProvider" varchar, "payer_data" text, "offer_id" varchar NOT NULL DEFAULT (''), "rejectUnauthorized" boolean NOT NULL DEFAULT (1), "bearer_token" varchar NOT NULL DEFAULT (''), "clink_requester_pub" varchar, "clink_requester_event_id" varchar, CONSTRAINT "FK_2c0dfb3483f3e5e7e3cdd5dc71f" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5263bde2a519db9ea608b702ec8" FOREIGN KEY ("productProductId") REFERENCES "product" ("product_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_d4bb1e4c60e8a869f1f43ca2e31" FOREIGN KEY ("payerSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_714a8b7d4f89f8a802ca181b789" FOREIGN KEY ("linkedApplicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); - await queryRunner.query(`INSERT INTO "temporary_user_receiving_invoice"("serial_id", "invoice", "expires_at_unix", "paid_at_unix", "internal", "paidByLnd", "callbackUrl", "paid_amount", "service_fee", "zap_info", "created_at", "updated_at", "userSerialId", "productProductId", "payerSerialId", "linkedApplicationSerialId", "liquidityProvider", "payer_data", "offer_id", "rejectUnauthorized", "bearer_token", "clink_requester_pub", "clink_requester_event_id") SELECT "serial_id", "invoice", "expires_at_unix", "paid_at_unix", "internal", "paidByLnd", "callbackUrl", "paid_amount", "service_fee", "zap_info", "created_at", "updated_at", "userSerialId", "productProductId", "payerSerialId", "linkedApplicationSerialId", "liquidityProvider", "payer_data", "offer_id", "rejectUnauthorized", "bearer_token", "clink_requester_pub", "clink_requester_event_id" FROM "user_receiving_invoice"`); - await queryRunner.query(`DROP TABLE "user_receiving_invoice"`); - await queryRunner.query(`ALTER TABLE "temporary_user_receiving_invoice" RENAME TO "user_receiving_invoice"`); - await queryRunner.query(`CREATE INDEX "recv_invoice_paid_serial" ON "user_receiving_invoice" ("userSerialId", "paid_at_unix", "serial_id") WHERE paid_at_unix > 0`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a131e6b58f084f1340538681b5" ON "user_receiving_invoice" ("invoice") `); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_a131e6b58f084f1340538681b5"`); - await queryRunner.query(`DROP INDEX "recv_invoice_paid_serial"`); - await queryRunner.query(`ALTER TABLE "user_receiving_invoice" RENAME TO "temporary_user_receiving_invoice"`); - await queryRunner.query(`CREATE TABLE "user_receiving_invoice" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "invoice" varchar NOT NULL, "expires_at_unix" integer NOT NULL, "paid_at_unix" integer NOT NULL DEFAULT (0), "internal" boolean NOT NULL DEFAULT (0), "paidByLnd" boolean NOT NULL DEFAULT (0), "callbackUrl" varchar NOT NULL DEFAULT (''), "paid_amount" integer NOT NULL DEFAULT (0), "service_fee" integer NOT NULL DEFAULT (0), "zap_info" text, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "productProductId" varchar, "payerSerialId" integer, "linkedApplicationSerialId" integer, "liquidityProvider" varchar, "payer_data" text, "offer_id" varchar NOT NULL DEFAULT (''), "rejectUnauthorized" boolean NOT NULL DEFAULT (1), "bearer_token" varchar NOT NULL DEFAULT (''), "clink_requester_pub" varchar(64), "clink_requester_event_id" varchar(64), CONSTRAINT "FK_2c0dfb3483f3e5e7e3cdd5dc71f" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5263bde2a519db9ea608b702ec8" FOREIGN KEY ("productProductId") REFERENCES "product" ("product_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_d4bb1e4c60e8a869f1f43ca2e31" FOREIGN KEY ("payerSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_714a8b7d4f89f8a802ca181b789" FOREIGN KEY ("linkedApplicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); - await queryRunner.query(`INSERT INTO "user_receiving_invoice"("serial_id", "invoice", "expires_at_unix", "paid_at_unix", "internal", "paidByLnd", "callbackUrl", "paid_amount", "service_fee", "zap_info", "created_at", "updated_at", "userSerialId", "productProductId", "payerSerialId", "linkedApplicationSerialId", "liquidityProvider", "payer_data", "offer_id", "rejectUnauthorized", "bearer_token", "clink_requester_pub", "clink_requester_event_id") SELECT "serial_id", "invoice", "expires_at_unix", "paid_at_unix", "internal", "paidByLnd", "callbackUrl", "paid_amount", "service_fee", "zap_info", "created_at", "updated_at", "userSerialId", "productProductId", "payerSerialId", "linkedApplicationSerialId", "liquidityProvider", "payer_data", "offer_id", "rejectUnauthorized", "bearer_token", "clink_requester_pub", "clink_requester_event_id" FROM "temporary_user_receiving_invoice"`); - await queryRunner.query(`DROP TABLE "temporary_user_receiving_invoice"`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a131e6b58f084f1340538681b5" ON "user_receiving_invoice" ("invoice") `); - await queryRunner.query(`CREATE INDEX "recv_invoice_paid_serial" ON "user_receiving_invoice" ("userSerialId", "paid_at_unix", "serial_id") WHERE paid_at_unix > 0`); await queryRunner.query(`DROP TABLE "invoice_swap"`); } diff --git a/src/services/storage/migrations/1769805357459-invoice_swaps_fixes.ts b/src/services/storage/migrations/1769805357459-invoice_swaps_fixes.ts new file mode 100644 index 00000000..3ba13031 --- /dev/null +++ b/src/services/storage/migrations/1769805357459-invoice_swaps_fixes.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class InvoiceSwapsFixes1769805357459 implements MigrationInterface { + name = 'InvoiceSwapsFixes1769805357459' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "lockup_tx_hex" text NOT NULL DEFAULT (''))`); + await queryRunner.query(`INSERT INTO "temporary_invoice_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at" FROM "invoice_swap"`); + await queryRunner.query(`DROP TABLE "invoice_swap"`); + await queryRunner.query(`ALTER TABLE "temporary_invoice_swap" RENAME TO "invoice_swap"`); + + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "invoice_swap" RENAME TO "temporary_invoice_swap"`); + await queryRunner.query(`CREATE TABLE "invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`); + await queryRunner.query(`INSERT INTO "invoice_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at" FROM "temporary_invoice_swap"`); + await queryRunner.query(`DROP TABLE "temporary_invoice_swap"`); + } + +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index ce3f7e8d..0b08799a 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -33,14 +33,14 @@ import { ClinkRequester1765497600000 } from './1765497600000-clink_requester.js' import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_provider_height.js' import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js' - +import { InvoiceSwapsFixes1769805357459 } from './1769805357459-invoice_swaps_fixes.js' export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283] + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459] export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] /* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise => { diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index a1ffa3d1..34c6d3bf 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -535,10 +535,14 @@ export default class { }, txId) } - async SetInvoiceSwapTxId(swapOperationId: string, chainTxId: string, txId?: string) { - return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, { + async SetInvoiceSwapTxId(swapOperationId: string, chainTxId: string, lockupTxHex?: string, txId?: string) { + const update: Partial = { tx_id: chainTxId, - }, txId) + } + if (lockupTxHex) { + update.lockup_tx_hex = lockupTxHex + } + return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, update, txId) } async FailInvoiceSwap(swapOperationId: string, failureReason: string, txId?: string) { @@ -566,7 +570,18 @@ export default class { async ListUnfinishedInvoiceSwaps(txId?: string) { const swaps = await this.dbs.Find('InvoiceSwap', { where: { used: false } }, txId) - return swaps.filter(s => !s.tx_id) + return swaps.filter(s => !!s.tx_id) + } + + async GetRefundableInvoiceSwap(swapOperationId: string, txId?: string) { + const swap = await this.dbs.FindOne('InvoiceSwap', { where: { swap_operation_id: swapOperationId } }, txId) + if (!swap || !swap.tx_id) { + return null + } + if (swap.used && !swap.failure_reason) { + return null + } + return swap } } From f5eb1f3253e5a8e716c7304ae42de372dff31ceb Mon Sep 17 00:00:00 2001 From: boufni95 Date: Mon, 2 Feb 2026 21:15:42 +0000 Subject: [PATCH 06/49] amount input --- proto/autogenerated/client.md | 2 +- proto/autogenerated/go/types.go | 2 +- proto/autogenerated/ts/types.ts | 8 ++++---- proto/service/structs.proto | 2 +- src/services/main/adminManager.ts | 4 +++- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index 60dd284e..75b44d4e 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -1405,7 +1405,7 @@ The nostr server will send back a message response, and inside the body there wi - __quotes__: ARRAY of: _[InvoiceSwapQuote](#InvoiceSwapQuote)_ ### InvoiceSwapRequest - - __invoice__: _string_ + - __amount_sats__: _number_ ### InvoiceSwapsList - __quotes__: ARRAY of: _[InvoiceSwapQuote](#InvoiceSwapQuote)_ diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index d5b21b2f..2c79cb0a 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -382,7 +382,7 @@ type InvoiceSwapQuoteList struct { Quotes []InvoiceSwapQuote `json:"quotes"` } type InvoiceSwapRequest struct { - Invoice string `json:"invoice"` + Amount_sats int64 `json:"amount_sats"` } type InvoiceSwapsList struct { Quotes []InvoiceSwapQuote `json:"quotes"` diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index a738a06c..17fec2b1 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -2251,19 +2251,19 @@ export const InvoiceSwapQuoteListValidate = (o?: InvoiceSwapQuoteList, opts: Inv } export type InvoiceSwapRequest = { - invoice: string + amount_sats: number } export const InvoiceSwapRequestOptionalFields: [] = [] export type InvoiceSwapRequestOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] - invoice_CustomCheck?: (v: string) => boolean + amount_sats_CustomCheck?: (v: number) => boolean } export const InvoiceSwapRequestValidate = (o?: InvoiceSwapRequest, opts: InvoiceSwapRequestOptions = {}, path: string = 'InvoiceSwapRequest::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') - 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`) + if (typeof o.amount_sats !== 'number') return new Error(`${path}.amount_sats: is not a number`) + if (opts.amount_sats_CustomCheck && !opts.amount_sats_CustomCheck(o.amount_sats)) return new Error(`${path}.amount_sats: custom check failed`) return null } diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 5b25c699..2ce6adc3 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -834,7 +834,7 @@ message MessagingToken { } message InvoiceSwapRequest { - string invoice = 1; + int64 amount_sats = 1; } message InvoiceSwapQuote { diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 8fae2b5d..1c54d12f 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -6,6 +6,7 @@ import * as Types from '../../../proto/autogenerated/ts/types.js' import LND from "../lnd/lnd.js"; import SettingsManager from "./settingsManager.js"; import { Swaps } from "../lnd/swaps/swaps.js"; +import { defaultInvoiceExpiry } from "../storage/paymentStorage.js"; export class AdminManager { settings: SettingsManager storage: Storage @@ -265,7 +266,8 @@ export class AdminManager { } async GetAdminInvoiceSwapQuotes(req: Types.InvoiceSwapRequest): Promise { - const quotes = await this.swaps.GetInvoiceSwapQuotes("admin", req.invoice) + const invoice = await this.lnd.NewInvoice(req.amount_sats, "Admin Swap", defaultInvoiceExpiry, { useProvider: false, from: 'system' }) + const quotes = await this.swaps.GetInvoiceSwapQuotes("admin", invoice.payRequest) return { quotes } } From ae4a5cb5d042af6273df3c82b9dffa4f26a736bb Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 16:53:59 +0000 Subject: [PATCH 07/49] lnd logs --- src/services/lnd/lnd.ts | 62 +++++++++++++++++++++++--------------- src/services/main/index.ts | 1 + 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index daf7b411..27e9f8a4 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -126,6 +126,7 @@ export default class { } async Warmup() { + this.log("Warming up LND") // Skip LND warmup if using only liquidity provider if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { this.log("USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND warmup") @@ -174,6 +175,7 @@ export default class { return res.response } async ListPendingChannels(): Promise { + this.log("Listing pending channels") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { pendingOpenChannels: [], pendingClosingChannels: [], pendingForceClosingChannels: [], waitingCloseChannels: [], totalLimboBalance: 0n } } @@ -182,14 +184,14 @@ export default class { return res.response } async ListChannels(peerLookup = false): Promise { - // console.log("Listing channels") + this.log("Listing channels") const res = await this.lightning.listChannels({ activeOnly: false, inactiveOnly: false, privateOnly: false, publicOnly: false, peer: Buffer.alloc(0), peerAliasLookup: peerLookup }, DeadLineMetadata()) return res.response } async ListClosedChannels(): Promise { - // console.log("Listing closed channels") + this.log("Listing closed channels") const res = await this.lightning.closedChannels({ abandoned: true, breach: true, @@ -217,7 +219,7 @@ export default class { } RestartStreams() { - // console.log("Restarting streams") + this.log("Restarting streams") if (!this.ready || this.abortController.signal.aborted) { return } @@ -235,7 +237,7 @@ export default class { } async SubscribeChannelEvents() { - // console.log("Subscribing to channel events") + this.log("Subscribing to channel events") const stream = this.lightning.subscribeChannelEvents({}, { abort: this.abortController.signal }) stream.responses.onMessage(async channel => { const channels = await this.ListChannels() @@ -250,7 +252,7 @@ export default class { } async SubscribeHtlcEvents() { - // console.log("Subscribing to htlc events") + this.log("Subscribing to htlc events") const stream = this.router.subscribeHtlcEvents({}, { abort: this.abortController.signal }) stream.responses.onMessage(htlc => { this.htlcCb(htlc) @@ -264,7 +266,7 @@ export default class { } async SubscribeNewBlock() { - // console.log("Subscribing to new block") + this.log("Subscribing to new block") const { blockHeight } = await this.GetInfo() const stream = this.chainNotifier.registerBlockEpochNtfn({ height: blockHeight, hash: Buffer.alloc(0) }, { abort: this.abortController.signal }) stream.responses.onMessage(block => { @@ -279,7 +281,7 @@ export default class { } SubscribeAddressPaid(): void { - // console.log("Subscribing to address paid") + this.log("Subscribing to address paid") const stream = this.lightning.subscribeTransactions({ account: "", endHeight: 0, @@ -306,7 +308,7 @@ export default class { } SubscribeInvoicePaid(): void { - // console.log("Subscribing to invoice paid") + this.log("Subscribing to invoice paid") const stream = this.lightning.subscribeInvoices({ settleIndex: BigInt(this.latestKnownSettleIndex), addIndex: 0n, @@ -348,6 +350,7 @@ export default class { } async ListAddresses(): Promise { + this.log("Listing addresses") const res = await this.walletKit.listAddresses({ accountName: "", showCustomAccounts: false }, DeadLineMetadata()) const addresses = res.response.accountWithAddresses.map(a => a.addresses.map(a => ({ address: a.address, change: a.isInternal }))).flat() addresses.forEach(a => this.addressesCache[a.address] = { isChange: a.change }) @@ -355,6 +358,7 @@ export default class { } async NewAddress(addressType: Types.AddressType, { useProvider, from }: TxActionOptions): Promise { + this.log("Creating new address") // Force use of provider when bypass is enabled (addresses not supported by provider, but we should fail gracefully) if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { throw new Error("Address generation not supported when USE_ONLY_LIQUIDITY_PROVIDER is enabled") @@ -389,7 +393,7 @@ export default class { } async NewInvoice(value: number, memo: string, expiry: number, { useProvider, from }: TxActionOptions, blind = false): Promise { - // console.log("Creating new invoice") + this.log("Creating new invoice") // Force use of provider when bypass is enabled const mustUseProvider = this.liquidProvider.getSettings().useOnlyLiquidityProvider || useProvider if (mustUseProvider) { @@ -409,6 +413,7 @@ export default class { } async DecodeInvoice(paymentRequest: string): Promise { + this.log("Decoding invoice") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { // Use light-bolt11-decoder when LND is bypassed try { @@ -440,6 +445,7 @@ export default class { } async ChannelBalance(): Promise<{ local: number, remote: number }> { + this.log("Getting channel balance") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { local: 0, remote: 0 } } @@ -449,7 +455,7 @@ export default class { return { local: r.localBalance ? Number(r.localBalance.sat) : 0, remote: r.remoteBalance ? Number(r.remoteBalance.sat) : 0 } } async PayInvoice(invoice: string, amount: number, { routingFeeLimit, serviceFee }: { routingFeeLimit: number, serviceFee: number }, decodedAmount: number, { useProvider, from }: TxActionOptions, paymentIndexCb?: (index: number) => void): Promise { - // console.log("Paying invoice") + this.log("Paying invoice") if (this.outgoingOpsLocked) { this.log("outgoing ops locked, rejecting payment request") throw new Error("lnd node is currently out of sync") @@ -498,7 +504,7 @@ export default class { } async EstimateChainFees(address: string, amount: number, targetConf: number): Promise { - // console.log("Estimating chain fees") + this.log("Estimating chain fees") await this.Health() const res = await this.lightning.estimateFee({ addrToAmount: { [address]: BigInt(amount) }, @@ -511,6 +517,7 @@ export default class { } async PayAddress(address: string, amount: number, satPerVByte: number, label = "", { useProvider, from }: TxActionOptions): Promise { + this.log("Paying address") // Address payments not supported when bypass is enabled if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { throw new Error("Address payments not supported when USE_ONLY_LIQUIDITY_PROVIDER is enabled") @@ -535,19 +542,19 @@ export default class { } async GetTransactions(startHeight: number): Promise { - // console.log("Getting transactions") + this.log("Getting transactions") const res = await this.lightning.getTransactions({ startHeight, endHeight: 0, account: "" }, DeadLineMetadata()) return res.response } async GetChannelInfo(chanId: string) { - // console.log("Getting channel info") + this.log("Getting channel info") const res = await this.lightning.getChanInfo({ chanId, chanPoint: "" }, DeadLineMetadata()) return res.response } async UpdateChannelPolicy(chanPoint: string, policy: Types.ChannelPolicy) { - // console.log("Updating channel policy") + this.log("Updating channel policy") const split = chanPoint.split(':') const res = await this.lightning.updateChannelPolicy({ @@ -565,19 +572,19 @@ export default class { } async GetChannelBalance() { - // console.log("Getting channel balance") + this.log("Getting channel balance") const res = await this.lightning.channelBalance({}, DeadLineMetadata()) return res.response } async GetWalletBalance() { - // console.log("Getting wallet balance") + this.log("Getting wallet balance") const res = await this.lightning.walletBalance({ account: "", minConfs: 1 }, DeadLineMetadata()) return res.response } async GetTotalBalace() { - // console.log("Getting total balance") + this.log("Getting total balance") const walletBalance = await this.GetWalletBalance() const confirmedWalletBalance = Number(walletBalance.confirmedBalance) this.utils.stateBundler.AddBalancePoint('walletBalance', confirmedWalletBalance) @@ -592,6 +599,7 @@ export default class { } async GetBalance(): Promise { // TODO: remove this + this.log("Getting balance") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { confirmedBalance: 0, unconfirmedBalance: 0, totalBalance: 0, channelsBalance: [] } } @@ -611,6 +619,7 @@ export default class { } async GetForwardingHistory(indexOffset: number, startTime = 0, endTime = 0): Promise { + this.log("Getting forwarding history") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { forwardingEvents: [], lastOffsetIndex: indexOffset } } @@ -620,6 +629,7 @@ export default class { } async GetAllPaidInvoices(max: number) { + this.log("Getting all paid invoices") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { invoices: [] } } @@ -628,6 +638,7 @@ export default class { return res.response } async GetAllPayments(max: number) { + this.log("Getting all payments") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { payments: [] } } @@ -637,7 +648,7 @@ export default class { } async GetPayment(paymentIndex: number) { - // console.log("Getting payment") + this.log("Getting payment") if (paymentIndex === 0) { throw new Error("payment index starts from 1") } @@ -649,6 +660,7 @@ export default class { } async GetLatestPaymentIndex(from = 0) { + this.log("Getting latest payment index") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return from } @@ -664,7 +676,7 @@ export default class { } async ConnectPeer(addr: { pubkey: string, host: string }) { - // console.log("Connecting to peer") + this.log("Connecting to peer") const res = await this.lightning.connectPeer({ addr, perm: true, @@ -674,7 +686,7 @@ export default class { } async GetPaymentFromHash(paymentHash: string): Promise { - // console.log("Getting payment from hash") + this.log("Getting payment from hash") const abortController = new AbortController() const stream = this.router.trackPaymentV2({ paymentHash: Buffer.from(paymentHash, 'hex'), @@ -696,13 +708,13 @@ export default class { } async GetTx(txid: string) { - // console.log("Getting transaction") + this.log("Getting transaction") const res = await this.walletKit.getTransaction({ txid }, DeadLineMetadata()) return res.response } async AddPeer(pub: string, host: string, port: number) { - // console.log("Adding peer") + this.log("Adding peer") const res = await this.lightning.connectPeer({ addr: { pubkey: pub, @@ -715,13 +727,13 @@ export default class { } async ListPeers() { - // console.log("Listing peers") + this.log("Listing peers") const res = await this.lightning.listPeers({ latestError: true }, DeadLineMetadata()) return res.response } async OpenChannel(destination: string, closeAddress: string, fundingAmount: number, pushSats: number, satsPerVByte: number): Promise { - // console.log("Opening channel") + this.log("Opening channel") const abortController = new AbortController() const req = OpenChannelReq(destination, closeAddress, fundingAmount, pushSats, satsPerVByte) const stream = this.lightning.openChannel(req, { abort: abortController.signal }) @@ -742,7 +754,7 @@ export default class { } async CloseChannel(fundingTx: string, outputIndex: number, force: boolean, satPerVByte: number): Promise { - // console.log("Closing channel") + this.log("Closing channel") const stream = this.lightning.closeChannel({ deliveryAddress: "", force: force, diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 3d885681..b79f74f6 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -208,6 +208,7 @@ export default class { addressPaidCb: AddressPaidCb = (txOutput, address, amount, used, broadcastHeight) => { return this.storage.StartTransaction(async tx => { + getLogger({})("addressPaidCb called", JSON.stringify({ txOutput, address, amount, used, broadcastHeight })) // On-chain payments not supported when bypass is enabled if (this.liquidityProvider.getSettings().useOnlyLiquidityProvider) { getLogger({})("addressPaidCb called but USE_ONLY_LIQUIDITY_PROVIDER is enabled, ignoring") From f262d46d1f7508f1e5900e14d53165c35dd401e3 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 17:58:29 +0000 Subject: [PATCH 08/49] fixes --- src/services/lnd/swaps/swaps.ts | 6 +++++- src/services/main/index.ts | 2 ++ src/services/serverMethods/index.ts | 9 ++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index 79a3f408..ba9b7f5b 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -170,7 +170,11 @@ export class Swaps { const swaps = await this.storage.paymentStorage.ListUnfinishedInvoiceSwaps() this.log("resuming", swaps.length, "invoice swaps") for (const swap of swaps) { - this.resumeInvoiceSwap(swap) + try { + this.resumeInvoiceSwap(swap) + } catch (err: any) { + this.log("error resuming invoice swap", err.message || err) + } } } diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 3d885681..6f136e09 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -163,6 +163,8 @@ export default class { let log = getLogger({}) this.storage.paymentStorage.DeleteExpiredTransactionSwaps(height) .catch(err => log(ERROR, "failed to delete expired transaction swaps", err.message || err)) + this.storage.paymentStorage.DeleteExpiredInvoiceSwaps(height) + .catch(err => log(ERROR, "failed to delete expired invoice swaps", err.message || err)) try { const balanceEvents = await this.paymentManager.GetLndBalance() if (!skipMetrics) { diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index 47624db7..21668449 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -111,11 +111,18 @@ export default (mainHandler: Main): Types.ServerMethods => { }, GetAdminInvoiceSwapQuotes: async ({ ctx, req }) => { const err = Types.InvoiceSwapRequestValidate(req, { - invoice_CustomCheck: invoice => invoice !== '' + amount_sats_CustomCheck: amt => amt > 0 }) if (err != null) throw new Error(err.message) return mainHandler.adminManager.GetAdminInvoiceSwapQuotes(req) }, + RefundAdminInvoiceSwap: async ({ ctx, req }) => { + const err = Types.RefundAdminInvoiceSwapRequestValidate(req, { + swap_operation_id_CustomCheck: id => id !== '', + }) + if (err != null) throw new Error(err.message) + return mainHandler.adminManager.RefundAdminInvoiceSwap(req) + }, ListAdminInvoiceSwaps: async ({ ctx }) => { return mainHandler.adminManager.ListAdminInvoiceSwaps() }, From 23156986b1e8e62793838fa80cb1aa37ccc67f49 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 18:31:37 +0000 Subject: [PATCH 09/49] handle failure cases --- src/services/lnd/swaps/reverseSwaps.ts | 21 +++++++++++++++++---- src/services/lnd/swaps/submarineSwaps.ts | 20 ++++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/services/lnd/swaps/reverseSwaps.ts b/src/services/lnd/swaps/reverseSwaps.ts index f0a5bc03..1486464a 100644 --- a/src/services/lnd/swaps/reverseSwaps.ts +++ b/src/services/lnd/swaps/reverseSwaps.ts @@ -121,10 +121,14 @@ export class ReverseSwaps { webSocket.send(JSON.stringify(subReq)) }) let txId = "", isDone = false - const done = () => { + const done = (failureReason?: string) => { isDone = true webSocket.close() - swapDone({ ok: true, txId }) + if (failureReason) { + swapDone({ ok: false, error: failureReason }) + } else { + swapDone({ ok: true, txId }) + } } webSocket.on('error', (err) => { this.log(ERROR, 'Error in WebSocket', err.message) @@ -132,7 +136,7 @@ export class ReverseSwaps { webSocket.on('close', () => { if (!isDone) { this.log(ERROR, 'WebSocket closed before swap was done'); - swapDone({ ok: false, error: 'WebSocket closed before swap was done' }) + done('WebSocket closed before swap was done') } }) webSocket.on('message', async (rawMsg) => { @@ -151,7 +155,7 @@ export class ReverseSwaps { }) } - handleSwapTransactionMessage = async (rawMsg: ws.RawData, data: TransactionSwapData, done: () => void) => { + handleSwapTransactionMessage = async (rawMsg: ws.RawData, data: TransactionSwapData, done: (failureReason?: string) => void) => { const msg = JSON.parse(rawMsg.toString('utf-8')); if (msg.event !== 'update') { return; @@ -176,6 +180,15 @@ export class ReverseSwaps { this.log('Transaction swap successful'); done() return; + case 'invoice.expired': + case 'swap.expired': + case 'transaction.failed': + done(`swap ${data.createdResponse.id} failed with status ${msg.args[0].status}`) + return; + default: + this.log('Unknown swap transaction WebSocket message', msg) + return; + } } diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index bf223b3e..760ca57e 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -375,10 +375,14 @@ export class SubmarineSwaps { webSocket.send(JSON.stringify(subReq)) }) let isDone = false - const done = () => { + const done = (failureReason?: string) => { isDone = true webSocket.close() - swapDone({ ok: true }) + if (failureReason) { + swapDone({ ok: false, error: failureReason }) + } else { + swapDone({ ok: true }) + } } webSocket.on('error', (err) => { this.log(ERROR, 'Error in WebSocket', err.message) @@ -386,7 +390,7 @@ export class SubmarineSwaps { webSocket.on('close', () => { if (!isDone) { this.log(ERROR, 'WebSocket closed before swap was done'); - swapDone({ ok: false, error: 'WebSocket closed before swap was done' }) + done('WebSocket closed before swap was done') } }) webSocket.on('message', async (rawMsg) => { @@ -403,7 +407,7 @@ export class SubmarineSwaps { } } - handleSwapInvoiceMessage = async (rawMsg: ws.RawData, data: InvoiceSwapData, closeWebSocket: () => void, waitingTx: () => void) => { + handleSwapInvoiceMessage = async (rawMsg: ws.RawData, data: InvoiceSwapData, closeWebSocket: (failureReason?: string) => void, waitingTx: () => void) => { const msg = JSON.parse(rawMsg.toString('utf-8')); if (msg.event !== 'update') { return; @@ -426,6 +430,14 @@ export class SubmarineSwaps { this.log('Invoice swap successful'); closeWebSocket() return; + case 'swap.expired': + case 'transaction.lockupFailed': + case 'invoice.failedToPay': + closeWebSocket(`swap ${data.createdResponse.id} failed with status ${msg.args[0].status}`) + return; + default: + this.log('Unknown swap invoice WebSocket message', msg) + return; } } From 070aa758d899437bab008632039e77c3c095781a Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 19:42:53 +0000 Subject: [PATCH 10/49] swap logs --- src/services/lnd/swaps/submarineSwaps.ts | 1 + src/services/lnd/swaps/swaps.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index 760ca57e..788e3ce9 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -369,6 +369,7 @@ export class SubmarineSwaps { } SubscribeToInvoiceSwap = (data: InvoiceSwapData, swapDone: (result: { ok: true } | { ok: false, error: string }) => void, waitingTx: () => void) => { + this.log("subscribing to invoice swap", { id: data.createdResponse.id }) const webSocket = new ws(`${this.wsUrl}/v2/ws`) const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] } webSocket.on('open', () => { diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index ba9b7f5b..fbb1613a 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -101,6 +101,7 @@ export class Swaps { } RefundInvoiceSwap = async (swapOperationId: string, satPerVByte: number, refundAddress: string, currentHeight: number): Promise<{ published: false, txHex: string, txId: string } | { published: true, txId: string }> => { + this.log("refunding invoice swap", { swapOperationId, satPerVByte, refundAddress, currentHeight }) const swap = await this.storage.paymentStorage.GetRefundableInvoiceSwap(swapOperationId) if (!swap) { throw new Error("Swap not found or already used") @@ -131,6 +132,7 @@ export class Swaps { } PayInvoiceSwap = async (appUserId: string, swapOpId: string, satPerVByte: number, payAddress: (address: string, amt: number) => Promise<{ txId: string }>): Promise => { + this.log("paying invoice swap", { appUserId, swapOpId, satPerVByte }) if (!this.settings.getSettings().swapsSettings.enableSwaps) { throw new Error("Swaps are not enabled") } From 1f09bcc679c103ede4985604dc1aa2e1fdf101a6 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 19:59:22 +0000 Subject: [PATCH 11/49] tmp fix --- src/services/lnd/swaps/swaps.ts | 4 +++- src/services/main/adminManager.ts | 5 +++++ src/services/main/init.ts | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index fbb1613a..dc57309a 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -164,7 +164,9 @@ export class Swaps { await this.storage.paymentStorage.FailInvoiceSwap(swapOpId, result.error, txId) this.log("invoice swap failed", { swapOpId, error: result.error }) } - }, () => payAddress(swap.address, swap.transaction_amount).then(res => { txId = res.txId }).catch(err => { close(); this.log("error paying address", err) })) + }, () => payAddress(swap.address, swap.transaction_amount) + .then(res => { txId = res.txId }) + .catch(err => { close(); this.log("error paying address", err.message || err) })) } ResumeInvoiceSwaps = async () => { diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 1c54d12f..3585248e 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -271,6 +271,10 @@ export class AdminManager { return { quotes } } + TMP_FIX_ADMIN_TX_ID = async () => { + await this.storage.paymentStorage.SetInvoiceSwapTxId("6089e1e5-2178-418e-ae19-d32ac5eb1a84", "f997b521ce1374a85e40a0fee5ad40692338b0f5965002b9f07d141cdbe03036") + } + async PayAdminInvoiceSwap(req: Types.PayAdminInvoiceSwapRequest): Promise { const txId = await new Promise(res => { this.swaps.PayInvoiceSwap("admin", req.swap_operation_id, req.sat_per_v_byte, async (addr, amt) => { @@ -288,6 +292,7 @@ export class AdminManager { } await this.storage.paymentStorage.SetInvoiceSwapTxId(req.swap_operation_id, txId, lockupTxHex) + this.log("saved admin swap txid", { swapOpId: req.swap_operation_id, txId }) res(tx.txid) return { txId: tx.txid } }) diff --git a/src/services/main/init.ts b/src/services/main/init.ts index 946061fb..5d48ea6b 100644 --- a/src/services/main/init.ts +++ b/src/services/main/init.ts @@ -80,6 +80,7 @@ export const initMainHandler = async (log: PubLogger, settingsManager: SettingsM await mainHandler.appUserManager.CleanupInactiveUsers() await mainHandler.appUserManager.CleanupNeverActiveUsers() await swaps.ResumeInvoiceSwaps() + await adminManager.TMP_FIX_ADMIN_TX_ID() await mainHandler.paymentManager.watchDog.Start() return { mainHandler, apps, localProviderClient, wizard, adminManager } } From 7b81a90c1aef42e3cbfe1a36ed2159441d136bdb Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 20:00:01 +0000 Subject: [PATCH 12/49] fix --- src/services/main/init.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/main/init.ts b/src/services/main/init.ts index 5d48ea6b..acfe44f9 100644 --- a/src/services/main/init.ts +++ b/src/services/main/init.ts @@ -79,8 +79,8 @@ export const initMainHandler = async (log: PubLogger, settingsManager: SettingsM await mainHandler.paymentManager.CleanupOldUnpaidInvoices() await mainHandler.appUserManager.CleanupInactiveUsers() await mainHandler.appUserManager.CleanupNeverActiveUsers() - await swaps.ResumeInvoiceSwaps() await adminManager.TMP_FIX_ADMIN_TX_ID() + await swaps.ResumeInvoiceSwaps() await mainHandler.paymentManager.watchDog.Start() return { mainHandler, apps, localProviderClient, wizard, adminManager } } From d9ec58165fbf29508a5c4bad799cfb55099c5866 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 20:03:46 +0000 Subject: [PATCH 13/49] log --- src/services/main/adminManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 3585248e..e01346ab 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -272,6 +272,7 @@ export class AdminManager { } TMP_FIX_ADMIN_TX_ID = async () => { + this.log("fixing tmp admin tx") await this.storage.paymentStorage.SetInvoiceSwapTxId("6089e1e5-2178-418e-ae19-d32ac5eb1a84", "f997b521ce1374a85e40a0fee5ad40692338b0f5965002b9f07d141cdbe03036") } From e24fa4f055e7edab7cac5a8534e0badc38fa9de7 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 20:09:15 +0000 Subject: [PATCH 14/49] better fix --- src/services/main/adminManager.ts | 4 +++- src/services/storage/paymentStorage.ts | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index e01346ab..e9695dc2 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -273,7 +273,9 @@ export class AdminManager { TMP_FIX_ADMIN_TX_ID = async () => { this.log("fixing tmp admin tx") - await this.storage.paymentStorage.SetInvoiceSwapTxId("6089e1e5-2178-418e-ae19-d32ac5eb1a84", "f997b521ce1374a85e40a0fee5ad40692338b0f5965002b9f07d141cdbe03036") + await this.storage.paymentStorage.UpdateInvoiceSwap("6089e1e5-2178-418e-ae19-d32ac5eb1a84", { + used: false, failure_reason: "", tx_id: "f997b521ce1374a85e40a0fee5ad40692338b0f5965002b9f07d141cdbe03036" + }) } async PayAdminInvoiceSwap(req: Types.PayAdminInvoiceSwapRequest): Promise { diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 34c6d3bf..d14138bd 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -535,6 +535,10 @@ export default class { }, txId) } + async UpdateInvoiceSwap(swapOperationId: string, update: Partial, txId?: string) { + return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, update, txId) + } + async SetInvoiceSwapTxId(swapOperationId: string, chainTxId: string, lockupTxHex?: string, txId?: string) { const update: Partial = { tx_id: chainTxId, From 3046e734645a3489412a2212d875ef1f9880151b Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Wed, 4 Feb 2026 15:27:52 -0500 Subject: [PATCH 15/49] zkpinit workaround --- src/services/lnd/swaps/reverseSwaps.ts | 3 ++- src/services/lnd/swaps/submarineSwaps.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/lnd/swaps/reverseSwaps.ts b/src/services/lnd/swaps/reverseSwaps.ts index 1486464a..a8bd6ade 100644 --- a/src/services/lnd/swaps/reverseSwaps.ts +++ b/src/services/lnd/swaps/reverseSwaps.ts @@ -1,4 +1,5 @@ -import zkpInit from '@vulpemventures/secp256k1-zkp'; +import secp256k1ZkpModule from '@vulpemventures/secp256k1-zkp'; +const zkpInit = (secp256k1ZkpModule as any).default || secp256k1ZkpModule; import { initEccLib, Transaction, address } from 'bitcoinjs-lib'; // import bolt11 from 'bolt11'; import { diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index 788e3ce9..56ce40e3 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -1,4 +1,5 @@ -import zkpInit from '@vulpemventures/secp256k1-zkp'; +import secp256k1ZkpModule from '@vulpemventures/secp256k1-zkp'; +const zkpInit = (secp256k1ZkpModule as any).default || secp256k1ZkpModule; // import bolt11 from 'bolt11'; import { Musig, SwapTreeSerializer, TaprootUtils, constructRefundTransaction, From 0a2556d0d46abb288e89c3e32b36047f8b11fb49 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Feb 2026 20:30:52 +0000 Subject: [PATCH 16/49] cleanup tmp fix --- src/services/main/adminManager.ts | 7 ------- src/services/main/init.ts | 1 - 2 files changed, 8 deletions(-) diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index e9695dc2..84ff5537 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -271,13 +271,6 @@ export class AdminManager { return { quotes } } - TMP_FIX_ADMIN_TX_ID = async () => { - this.log("fixing tmp admin tx") - await this.storage.paymentStorage.UpdateInvoiceSwap("6089e1e5-2178-418e-ae19-d32ac5eb1a84", { - used: false, failure_reason: "", tx_id: "f997b521ce1374a85e40a0fee5ad40692338b0f5965002b9f07d141cdbe03036" - }) - } - async PayAdminInvoiceSwap(req: Types.PayAdminInvoiceSwapRequest): Promise { const txId = await new Promise(res => { this.swaps.PayInvoiceSwap("admin", req.swap_operation_id, req.sat_per_v_byte, async (addr, amt) => { diff --git a/src/services/main/init.ts b/src/services/main/init.ts index acfe44f9..946061fb 100644 --- a/src/services/main/init.ts +++ b/src/services/main/init.ts @@ -79,7 +79,6 @@ export const initMainHandler = async (log: PubLogger, settingsManager: SettingsM await mainHandler.paymentManager.CleanupOldUnpaidInvoices() await mainHandler.appUserManager.CleanupInactiveUsers() await mainHandler.appUserManager.CleanupNeverActiveUsers() - await adminManager.TMP_FIX_ADMIN_TX_ID() await swaps.ResumeInvoiceSwaps() await mainHandler.paymentManager.watchDog.Start() return { mainHandler, apps, localProviderClient, wizard, adminManager } From 3a6b22cff8fb699afa54091a611e7197e907a94a Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 5 Feb 2026 19:00:00 +0000 Subject: [PATCH 17/49] swap fixes --- src/services/main/adminManager.ts | 10 +++++----- src/services/storage/paymentStorage.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 84ff5537..1581c245 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -272,11 +272,11 @@ export class AdminManager { } async PayAdminInvoiceSwap(req: Types.PayAdminInvoiceSwapRequest): Promise { - const txId = await new Promise(res => { + const resolvedTxId = await new Promise(res => { this.swaps.PayInvoiceSwap("admin", req.swap_operation_id, req.sat_per_v_byte, async (addr, amt) => { const tx = await this.lnd.PayAddress(addr, amt, req.sat_per_v_byte, "", { useProvider: false, from: 'system' }) this.log("paid admin invoice swap", { swapOpId: req.swap_operation_id, txId: tx.txid }) - await this.storage.metricsStorage.AddRootOperation("chain_payment", txId, amt) + await this.storage.metricsStorage.AddRootOperation("chain_payment", tx.txid, amt) // Fetch the full transaction hex for potential refunds let lockupTxHex: string | undefined @@ -287,13 +287,13 @@ export class AdminManager { this.log("Warning: Could not fetch transaction hex for refund purposes:", err.message) } - await this.storage.paymentStorage.SetInvoiceSwapTxId(req.swap_operation_id, txId, lockupTxHex) - this.log("saved admin swap txid", { swapOpId: req.swap_operation_id, txId }) + await this.storage.paymentStorage.SetInvoiceSwapTxId(req.swap_operation_id, tx.txid, lockupTxHex) + this.log("saved admin swap txid", { swapOpId: req.swap_operation_id, txId: tx.txid }) res(tx.txid) return { txId: tx.txid } }) }) - return { tx_id: txId } + return { tx_id: resolvedTxId } } async RefundAdminInvoiceSwap(req: Types.RefundAdminInvoiceSwapRequest): Promise { diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index d14138bd..5726c5cf 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -494,7 +494,7 @@ export default class { } async DeleteExpiredTransactionSwaps(currentHeight: number, txId?: string) { - return this.dbs.Delete('TransactionSwap', { timeout_block_height: LessThan(currentHeight) }, txId) + return this.dbs.Delete('TransactionSwap', { timeout_block_height: LessThan(currentHeight), used: false }, txId) } async ListPendingTransactionSwaps(appUserId: string, txId?: string) { @@ -561,7 +561,7 @@ export default class { } async DeleteExpiredInvoiceSwaps(currentHeight: number, txId?: string) { - return this.dbs.Delete('InvoiceSwap', { timeout_block_height: LessThan(currentHeight) }, txId) + return this.dbs.Delete('InvoiceSwap', { timeout_block_height: LessThan(currentHeight), used: false, tx_id: "" }, txId) } async ListCompletedInvoiceSwaps(appUserId: string, txId?: string) { From 81f7400748d601963883094c2ef1de2d5727d5e0 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 5 Feb 2026 20:07:57 +0000 Subject: [PATCH 18/49] socket ping pong --- src/services/lnd/swaps/reverseSwaps.ts | 7 +++++++ src/services/lnd/swaps/submarineSwaps.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/services/lnd/swaps/reverseSwaps.ts b/src/services/lnd/swaps/reverseSwaps.ts index a8bd6ade..14dc4d44 100644 --- a/src/services/lnd/swaps/reverseSwaps.ts +++ b/src/services/lnd/swaps/reverseSwaps.ts @@ -121,9 +121,13 @@ export class ReverseSwaps { webSocket.on('open', () => { webSocket.send(JSON.stringify(subReq)) }) + const interval = setInterval(() => { + webSocket.ping() + }, 30 * 1000) let txId = "", isDone = false const done = (failureReason?: string) => { isDone = true + clearInterval(interval) webSocket.close() if (failureReason) { swapDone({ ok: false, error: failureReason }) @@ -131,6 +135,9 @@ export class ReverseSwaps { swapDone({ ok: true, txId }) } } + webSocket.on('pong', () => { + this.log('WebSocket transaction swap pong received') + }) webSocket.on('error', (err) => { this.log(ERROR, 'Error in WebSocket', err.message) }) diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index 56ce40e3..32ed1fac 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -376,9 +376,13 @@ export class SubmarineSwaps { webSocket.on('open', () => { webSocket.send(JSON.stringify(subReq)) }) + const interval = setInterval(() => { + webSocket.ping() + }, 30 * 1000) let isDone = false const done = (failureReason?: string) => { isDone = true + clearInterval(interval) webSocket.close() if (failureReason) { swapDone({ ok: false, error: failureReason }) @@ -386,6 +390,9 @@ export class SubmarineSwaps { swapDone({ ok: true }) } } + webSocket.on('pong', () => { + this.log('WebSocket invoice swap pong received') + }) webSocket.on('error', (err) => { this.log(ERROR, 'Error in WebSocket', err.message) }) From 9fe681d73bc0c106984b691a07d62836f0a922ee Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Thu, 5 Feb 2026 15:46:39 -0500 Subject: [PATCH 19/49] swap clear interval --- src/services/lnd/swaps/reverseSwaps.ts | 4 +--- src/services/lnd/swaps/submarineSwaps.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/services/lnd/swaps/reverseSwaps.ts b/src/services/lnd/swaps/reverseSwaps.ts index 14dc4d44..83dddc37 100644 --- a/src/services/lnd/swaps/reverseSwaps.ts +++ b/src/services/lnd/swaps/reverseSwaps.ts @@ -155,9 +155,7 @@ export class ReverseSwaps { } } catch (err: any) { this.log(ERROR, 'Error handling transaction WebSocket message', err.message) - isDone = true - webSocket.close() - swapDone({ ok: false, error: err.message }) + done(err.message) return } }) diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index 32ed1fac..462f4751 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -407,7 +407,7 @@ export class SubmarineSwaps { await this.handleSwapInvoiceMessage(rawMsg, data, done, waitingTx) } catch (err: any) { this.log(ERROR, 'Error handling invoice WebSocket message', err.message) - webSocket.close() + done(err.message) return } }); From 3389045de708adfa4db70aab822b662679e552d2 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Fri, 6 Feb 2026 20:23:09 +0000 Subject: [PATCH 20/49] missing ops --- src/services/lnd/lnd.ts | 1 - src/services/metrics/index.ts | 9 +++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index f4548aef..3d66b268 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -715,7 +715,6 @@ export default class { } async GetTx(txid: string) { - this.log("Getting transaction") const res = await this.walletKit.getTransaction({ txid }, DeadLineMetadata()) return res.response } diff --git a/src/services/metrics/index.ts b/src/services/metrics/index.ts index 3dbafedb..20c92a95 100644 --- a/src/services/metrics/index.ts +++ b/src/services/metrics/index.ts @@ -425,8 +425,13 @@ export default class Handler { const mapRootOpType = (opType: string): Types.OperationType => { switch (opType) { - case "chain": return Types.OperationType.CHAIN_OP - case "invoice": return Types.OperationType.INVOICE_OP + case "chain_payment": + case "chain": + return Types.OperationType.CHAIN_OP + case "invoice_payment": + case "invoice": + return Types.OperationType.INVOICE_OP + default: throw new Error("Unknown operation type") } } \ No newline at end of file From e271d35c2d748913135841f07ac0730419aa9a13 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Tue, 10 Feb 2026 17:04:49 +0000 Subject: [PATCH 21/49] restart sub p --- src/services/nostr/index.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/services/nostr/index.ts b/src/services/nostr/index.ts index 3cd90e88..d544a286 100644 --- a/src/services/nostr/index.ts +++ b/src/services/nostr/index.ts @@ -11,19 +11,31 @@ export default class NostrSubprocess { utils: Utils awaitingPongs: (() => void)[] = [] log = getLogger({}) + latestRestart = 0 constructor(settings: NostrSettings, utils: Utils, eventCallback: EventCallback, beaconCallback: BeaconCallback) { this.utils = utils + this.startSubProcess(settings, eventCallback, beaconCallback) + } + + startSubProcess(settings: NostrSettings, eventCallback: EventCallback, beaconCallback: BeaconCallback) { this.childProcess = fork("./build/src/services/nostr/handler") this.childProcess.on("error", (error) => { this.log(ERROR, "nostr subprocess error", error) }) this.childProcess.on("exit", (code, signal) => { - this.log(ERROR, `nostr subprocess exited with code ${code} and signal ${signal}`) if (code === 0) { + this.log("nostr subprocess exited") return } - throw new Error(`nostr subprocess exited with code ${code} and signal ${signal}`) + this.log(ERROR, `nostr subprocess exited with code ${code} and signal ${signal}`) + const now = Date.now() + if (now - this.latestRestart < 1000 * 5) { + this.log(ERROR, "nostr subprocess exited too quickly") + throw new Error("nostr subprocess exited too quickly") + } + this.latestRestart = now + this.startSubProcess(settings, eventCallback, beaconCallback) }) this.childProcess.on("message", (message: ChildProcessResponse) => { @@ -50,6 +62,8 @@ export default class NostrSubprocess { } }) } + + sendToChildProcess(message: ChildProcessRequest) { this.childProcess.send(message) } From c3e3f99c7d4942a7dc81dd9760fc1822ff1a73d2 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Tue, 10 Feb 2026 17:36:37 -0500 Subject: [PATCH 22/49] fix subprocess restart issues - Fix event listener memory leak by removing all listeners before restart - Properly cleanup old process with SIGTERM before forking new one - Fix Stop() to actually kill process (was using kill(0) which only checks existence) - Store callbacks in instance to avoid re-attaching listeners on restart - Add isShuttingDown flag to prevent restart when stopping intentionally - Add 100ms delay before restart to ensure clean process termination - Check if process is killed before sending messages - Update Reset() to store new settings for future restarts Co-authored-by: Cursor --- src/services/nostr/index.ts | 65 ++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/src/services/nostr/index.ts b/src/services/nostr/index.ts index d544a286..5b2f0ddc 100644 --- a/src/services/nostr/index.ts +++ b/src/services/nostr/index.ts @@ -12,39 +12,68 @@ export default class NostrSubprocess { awaitingPongs: (() => void)[] = [] log = getLogger({}) latestRestart = 0 + private settings: NostrSettings + private eventCallback: EventCallback + private beaconCallback: BeaconCallback + private isShuttingDown = false + constructor(settings: NostrSettings, utils: Utils, eventCallback: EventCallback, beaconCallback: BeaconCallback) { this.utils = utils - this.startSubProcess(settings, eventCallback, beaconCallback) + this.settings = settings + this.eventCallback = eventCallback + this.beaconCallback = beaconCallback + this.startSubProcess() } - startSubProcess(settings: NostrSettings, eventCallback: EventCallback, beaconCallback: BeaconCallback) { + private cleanupProcess() { + if (this.childProcess) { + this.childProcess.removeAllListeners() + if (!this.childProcess.killed) { + this.childProcess.kill('SIGTERM') + } + } + } + + private startSubProcess() { + this.cleanupProcess() + this.childProcess = fork("./build/src/services/nostr/handler") + this.childProcess.on("error", (error) => { this.log(ERROR, "nostr subprocess error", error) }) this.childProcess.on("exit", (code, signal) => { - if (code === 0) { - this.log("nostr subprocess exited") + if (this.isShuttingDown) { + this.log("nostr subprocess stopped") return } - this.log(ERROR, `nostr subprocess exited with code ${code} and signal ${signal}`) - const now = Date.now() - if (now - this.latestRestart < 1000 * 5) { - this.log(ERROR, "nostr subprocess exited too quickly") - throw new Error("nostr subprocess exited too quickly") + + if (code === 0) { + this.log("nostr subprocess exited cleanly") + return } + + this.log(ERROR, `nostr subprocess exited with code ${code} and signal ${signal}`) + + const now = Date.now() + if (now - this.latestRestart < 5000) { + this.log(ERROR, "nostr subprocess exited too quickly, not restarting") + throw new Error("nostr subprocess crashed repeatedly") + } + + this.log("restarting nostr subprocess...") this.latestRestart = now - this.startSubProcess(settings, eventCallback, beaconCallback) + setTimeout(() => this.startSubProcess(), 100) }) this.childProcess.on("message", (message: ChildProcessResponse) => { switch (message.type) { case 'ready': - this.sendToChildProcess({ type: 'settings', settings: settings }) + this.sendToChildProcess({ type: 'settings', settings: this.settings }) break; case 'event': - eventCallback(message.event) + this.eventCallback(message.event) break case 'processMetrics': this.utils.tlvStorageFactory.ProcessMetrics(message.metrics, 'nostr') @@ -54,7 +83,7 @@ export default class NostrSubprocess { this.awaitingPongs = [] break case 'beacon': - beaconCallback({ content: message.content, pub: message.pub }) + this.beaconCallback({ content: message.content, pub: message.pub }) break default: console.error("unknown nostr event response", message) @@ -63,12 +92,14 @@ export default class NostrSubprocess { }) } - sendToChildProcess(message: ChildProcessRequest) { - this.childProcess.send(message) + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.send(message) + } } Reset(settings: NostrSettings) { + this.settings = settings this.sendToChildProcess({ type: 'settings', settings }) } @@ -82,7 +113,9 @@ export default class NostrSubprocess { Send(initiator: SendInitiator, data: SendData, relays?: string[]) { this.sendToChildProcess({ type: 'send', data, initiator, relays }) } + Stop() { - this.childProcess.kill(0) + this.isShuttingDown = true + this.cleanupProcess() } } From edbbbd4aecb150078939cbc23eed6d693e853d79 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Tue, 17 Feb 2026 18:12:33 +0000 Subject: [PATCH 23/49] add time info to swaps --- datasource.js | 5 +- proto/autogenerated/client.md | 9 ++-- proto/autogenerated/go/types.go | 15 +++--- proto/autogenerated/ts/types.ts | 51 ++++++++++--------- proto/service/structs.proto | 9 ++-- src/services/lnd/swaps/swaps.ts | 51 +++++++++++-------- src/services/main/adminManager.ts | 4 +- src/services/nostr/index.ts | 16 +++--- src/services/storage/entity/InvoiceSwap.ts | 6 +++ .../1771347307798-swap_timestamps.ts | 20 ++++++++ src/services/storage/migrations/runner.ts | 4 +- src/services/storage/paymentStorage.ts | 9 +++- 12 files changed, 124 insertions(+), 75 deletions(-) create mode 100644 src/services/storage/migrations/1771347307798-swap_timestamps.ts diff --git a/datasource.js b/datasource.js index 081634d8..9c291b25 100644 --- a/datasource.js +++ b/datasource.js @@ -50,6 +50,7 @@ import { ClinkRequester1765497600000 } from './build/src/services/storage/migrat import { TrackedProviderHeight1766504040000 } from './build/src/services/storage/migrations/1766504040000-tracked_provider_height.js' import { SwapsServiceUrl1768413055036 } from './build/src/services/storage/migrations/1768413055036-swaps_service_url.js' import { InvoiceSwaps1769529793283 } from './build/src/services/storage/migrations/1769529793283-invoice_swaps.js' +import { InvoiceSwapsFixes1769805357459 } from './build/src/services/storage/migrations/1769805357459-invoice_swaps_fixes.js' export default new DataSource({ type: "better-sqlite3", @@ -59,11 +60,11 @@ export default new DataSource({ PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283], + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459], entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment, UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo, TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings, TransactionSwap, InvoiceSwap], // synchronize: true, }) -//npx typeorm migration:generate ./src/services/storage/migrations/invoice_swaps_fixes -d ./datasource.js \ No newline at end of file +//npx typeorm migration:generate ./src/services/storage/migrations/swap_timestamps -d ./datasource.js \ No newline at end of file diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index 75b44d4e..679e0d30 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -1383,17 +1383,18 @@ The nostr server will send back a message response, and inside the body there wi - __url__: _string_ ### InvoiceSwapOperation + - __completed_at_unix__: _number_ *this field is optional - __failure_reason__: _string_ *this field is optional - - __invoice_paid__: _string_ - __operation_payment__: _[UserOperation](#UserOperation)_ *this field is optional - - __swap_operation_id__: _string_ - - __tx_id__: _string_ + - __quote__: _[InvoiceSwapQuote](#InvoiceSwapQuote)_ ### InvoiceSwapQuote - __address__: _string_ - __chain_fee_sats__: _number_ + - __expires_at_block_height__: _number_ - __invoice__: _string_ - __invoice_amount_sats__: _number_ + - __paid_at_unix__: _number_ - __service_fee_sats__: _number_ - __service_url__: _string_ - __swap_fee_sats__: _number_ @@ -1408,7 +1409,7 @@ The nostr server will send back a message response, and inside the body there wi - __amount_sats__: _number_ ### InvoiceSwapsList - - __quotes__: ARRAY of: _[InvoiceSwapQuote](#InvoiceSwapQuote)_ + - __current_block_height__: _number_ - __swaps__: ARRAY of: _[InvoiceSwapOperation](#InvoiceSwapOperation)_ ### LatestBundleMetricReq diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index 2c79cb0a..a59adaaf 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -360,17 +360,18 @@ type HttpCreds struct { Url string `json:"url"` } type InvoiceSwapOperation struct { - Failure_reason string `json:"failure_reason"` - Invoice_paid string `json:"invoice_paid"` - Operation_payment *UserOperation `json:"operation_payment"` - Swap_operation_id string `json:"swap_operation_id"` - Tx_id string `json:"tx_id"` + Completed_at_unix int64 `json:"completed_at_unix"` + Failure_reason string `json:"failure_reason"` + Operation_payment *UserOperation `json:"operation_payment"` + Quote *InvoiceSwapQuote `json:"quote"` } type InvoiceSwapQuote struct { Address string `json:"address"` Chain_fee_sats int64 `json:"chain_fee_sats"` + Expires_at_block_height int64 `json:"expires_at_block_height"` Invoice string `json:"invoice"` Invoice_amount_sats int64 `json:"invoice_amount_sats"` + Paid_at_unix int64 `json:"paid_at_unix"` Service_fee_sats int64 `json:"service_fee_sats"` Service_url string `json:"service_url"` Swap_fee_sats int64 `json:"swap_fee_sats"` @@ -385,8 +386,8 @@ type InvoiceSwapRequest struct { Amount_sats int64 `json:"amount_sats"` } type InvoiceSwapsList struct { - Quotes []InvoiceSwapQuote `json:"quotes"` - Swaps []InvoiceSwapOperation `json:"swaps"` + Current_block_height int64 `json:"current_block_height"` + Swaps []InvoiceSwapOperation `json:"swaps"` } type LatestBundleMetricReq struct { Limit int64 `json:"limit"` diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index 17fec2b1..82f2c1fe 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -2123,43 +2123,39 @@ export const HttpCredsValidate = (o?: HttpCreds, opts: HttpCredsOptions = {}, pa } export type InvoiceSwapOperation = { + completed_at_unix?: number failure_reason?: string - invoice_paid: string operation_payment?: UserOperation - swap_operation_id: string - tx_id: string + quote: InvoiceSwapQuote } -export type InvoiceSwapOperationOptionalField = 'failure_reason' | 'operation_payment' -export const InvoiceSwapOperationOptionalFields: InvoiceSwapOperationOptionalField[] = ['failure_reason', 'operation_payment'] +export type InvoiceSwapOperationOptionalField = 'completed_at_unix' | 'failure_reason' | 'operation_payment' +export const InvoiceSwapOperationOptionalFields: InvoiceSwapOperationOptionalField[] = ['completed_at_unix', 'failure_reason', 'operation_payment'] export type InvoiceSwapOperationOptions = OptionsBaseMessage & { checkOptionalsAreSet?: InvoiceSwapOperationOptionalField[] + completed_at_unix_CustomCheck?: (v?: number) => boolean failure_reason_CustomCheck?: (v?: string) => boolean - invoice_paid_CustomCheck?: (v: string) => boolean operation_payment_Options?: UserOperationOptions - swap_operation_id_CustomCheck?: (v: string) => boolean - tx_id_CustomCheck?: (v: string) => boolean + quote_Options?: InvoiceSwapQuoteOptions } export const InvoiceSwapOperationValidate = (o?: InvoiceSwapOperation, opts: InvoiceSwapOperationOptions = {}, path: string = 'InvoiceSwapOperation::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') + if ((o.completed_at_unix || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('completed_at_unix')) && typeof o.completed_at_unix !== 'number') return new Error(`${path}.completed_at_unix: is not a number`) + if (opts.completed_at_unix_CustomCheck && !opts.completed_at_unix_CustomCheck(o.completed_at_unix)) return new Error(`${path}.completed_at_unix: custom check failed`) + if ((o.failure_reason || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('failure_reason')) && typeof o.failure_reason !== 'string') return new Error(`${path}.failure_reason: is not a string`) if (opts.failure_reason_CustomCheck && !opts.failure_reason_CustomCheck(o.failure_reason)) return new Error(`${path}.failure_reason: custom check failed`) - if (typeof o.invoice_paid !== 'string') return new Error(`${path}.invoice_paid: is not a string`) - if (opts.invoice_paid_CustomCheck && !opts.invoice_paid_CustomCheck(o.invoice_paid)) return new Error(`${path}.invoice_paid: custom check failed`) - if (typeof o.operation_payment === 'object' || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('operation_payment')) { const operation_paymentErr = UserOperationValidate(o.operation_payment, opts.operation_payment_Options, `${path}.operation_payment`) if (operation_paymentErr !== null) return operation_paymentErr } - if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) - if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) - - if (typeof o.tx_id !== 'string') return new Error(`${path}.tx_id: is not a string`) - if (opts.tx_id_CustomCheck && !opts.tx_id_CustomCheck(o.tx_id)) return new Error(`${path}.tx_id: custom check failed`) + const quoteErr = InvoiceSwapQuoteValidate(o.quote, opts.quote_Options, `${path}.quote`) + if (quoteErr !== null) return quoteErr + return null } @@ -2167,8 +2163,10 @@ export const InvoiceSwapOperationValidate = (o?: InvoiceSwapOperation, opts: Inv export type InvoiceSwapQuote = { address: string chain_fee_sats: number + expires_at_block_height: number invoice: string invoice_amount_sats: number + paid_at_unix: number service_fee_sats: number service_url: string swap_fee_sats: number @@ -2181,8 +2179,10 @@ export type InvoiceSwapQuoteOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] address_CustomCheck?: (v: string) => boolean chain_fee_sats_CustomCheck?: (v: number) => boolean + expires_at_block_height_CustomCheck?: (v: number) => boolean invoice_CustomCheck?: (v: string) => boolean invoice_amount_sats_CustomCheck?: (v: number) => boolean + paid_at_unix_CustomCheck?: (v: number) => boolean service_fee_sats_CustomCheck?: (v: number) => boolean service_url_CustomCheck?: (v: string) => boolean swap_fee_sats_CustomCheck?: (v: number) => boolean @@ -2200,12 +2200,18 @@ export const InvoiceSwapQuoteValidate = (o?: InvoiceSwapQuote, opts: InvoiceSwap if (typeof o.chain_fee_sats !== 'number') return new Error(`${path}.chain_fee_sats: is not a number`) if (opts.chain_fee_sats_CustomCheck && !opts.chain_fee_sats_CustomCheck(o.chain_fee_sats)) return new Error(`${path}.chain_fee_sats: custom check failed`) + if (typeof o.expires_at_block_height !== 'number') return new Error(`${path}.expires_at_block_height: is not a number`) + if (opts.expires_at_block_height_CustomCheck && !opts.expires_at_block_height_CustomCheck(o.expires_at_block_height)) return new Error(`${path}.expires_at_block_height: 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`) if (typeof o.invoice_amount_sats !== 'number') return new Error(`${path}.invoice_amount_sats: is not a number`) if (opts.invoice_amount_sats_CustomCheck && !opts.invoice_amount_sats_CustomCheck(o.invoice_amount_sats)) return new Error(`${path}.invoice_amount_sats: custom check failed`) + if (typeof o.paid_at_unix !== 'number') return new Error(`${path}.paid_at_unix: is not a number`) + if (opts.paid_at_unix_CustomCheck && !opts.paid_at_unix_CustomCheck(o.paid_at_unix)) return new Error(`${path}.paid_at_unix: custom check failed`) + if (typeof o.service_fee_sats !== 'number') return new Error(`${path}.service_fee_sats: is not a number`) if (opts.service_fee_sats_CustomCheck && !opts.service_fee_sats_CustomCheck(o.service_fee_sats)) return new Error(`${path}.service_fee_sats: custom check failed`) @@ -2269,14 +2275,13 @@ export const InvoiceSwapRequestValidate = (o?: InvoiceSwapRequest, opts: Invoice } export type InvoiceSwapsList = { - quotes: InvoiceSwapQuote[] + current_block_height: number swaps: InvoiceSwapOperation[] } export const InvoiceSwapsListOptionalFields: [] = [] export type InvoiceSwapsListOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] - quotes_ItemOptions?: InvoiceSwapQuoteOptions - quotes_CustomCheck?: (v: InvoiceSwapQuote[]) => boolean + current_block_height_CustomCheck?: (v: number) => boolean swaps_ItemOptions?: InvoiceSwapOperationOptions swaps_CustomCheck?: (v: InvoiceSwapOperation[]) => boolean } @@ -2284,12 +2289,8 @@ export const InvoiceSwapsListValidate = (o?: InvoiceSwapsList, opts: InvoiceSwap 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') - if (!Array.isArray(o.quotes)) return new Error(`${path}.quotes: is not an array`) - for (let index = 0; index < o.quotes.length; index++) { - const quotesErr = InvoiceSwapQuoteValidate(o.quotes[index], opts.quotes_ItemOptions, `${path}.quotes[${index}]`) - if (quotesErr !== null) return quotesErr - } - if (opts.quotes_CustomCheck && !opts.quotes_CustomCheck(o.quotes)) return new Error(`${path}.quotes: custom check failed`) + if (typeof o.current_block_height !== 'number') return new Error(`${path}.current_block_height: is not a number`) + if (opts.current_block_height_CustomCheck && !opts.current_block_height_CustomCheck(o.current_block_height)) return new Error(`${path}.current_block_height: custom check failed`) if (!Array.isArray(o.swaps)) return new Error(`${path}.swaps: is not an array`) for (let index = 0; index < o.swaps.length; index++) { diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 2ce6adc3..07b930da 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -848,6 +848,8 @@ message InvoiceSwapQuote { string service_url = 8; int64 swap_fee_sats = 9; string tx_id = 10; + int64 paid_at_unix = 11; + int64 expires_at_block_height = 12; } message InvoiceSwapQuoteList { @@ -855,16 +857,15 @@ message InvoiceSwapQuoteList { } message InvoiceSwapOperation { - string swap_operation_id = 1; + InvoiceSwapQuote quote = 1; optional UserOperation operation_payment = 2; optional string failure_reason = 3; - string invoice_paid = 4; - string tx_id = 5; + optional int64 completed_at_unix = 6; } message InvoiceSwapsList { repeated InvoiceSwapOperation swaps = 1; - repeated InvoiceSwapQuote quotes = 2; + int64 current_block_height = 3; } message RefundAdminInvoiceSwapRequest { diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index dc57309a..3a21d2f8 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -71,32 +71,37 @@ export class Swaps { return success } + private mapInvoiceSwapQuote = (s: InvoiceSwap): Types.InvoiceSwapQuote => { + return { + swap_operation_id: s.swap_operation_id, + invoice: s.invoice, + invoice_amount_sats: s.invoice_amount, + address: s.address, + transaction_amount_sats: s.transaction_amount, + chain_fee_sats: s.chain_fee_sats, + service_fee_sats: 0, + service_url: s.service_url, + swap_fee_sats: s.swap_fee_sats, + tx_id: s.tx_id, + paid_at_unix: s.paid_at_unix, + expires_at_block_height: s.timeout_block_height, + } + } + ListInvoiceSwaps = async (appUserId: string): Promise => { + const info = await this.lnd.GetInfo() + const currentBlockHeight = info.blockHeight const completedSwaps = await this.storage.paymentStorage.ListCompletedInvoiceSwaps(appUserId) const pendingSwaps = await this.storage.paymentStorage.ListPendingInvoiceSwaps(appUserId) + const quotes: Types.InvoiceSwapOperation[] = pendingSwaps.map(s => ({ quote: this.mapInvoiceSwapQuote(s) })) + const operations: Types.InvoiceSwapOperation[] = completedSwaps.map(s => ({ + quote: this.mapInvoiceSwapQuote(s), + failure_reason: s.failure_reason, + completed_at_unix: s.completed_at_unix || 1, + })) return { - swaps: completedSwaps.map(s => { - return { - invoice_paid: s.invoice, - swap_operation_id: s.swap_operation_id, - failure_reason: s.failure_reason, - tx_id: s.tx_id, - } - }), - quotes: pendingSwaps.map(s => { - return { - swap_operation_id: s.swap_operation_id, - invoice: s.invoice, - invoice_amount_sats: s.invoice_amount, - address: s.address, - transaction_amount_sats: s.transaction_amount, - chain_fee_sats: s.chain_fee_sats, - service_fee_sats: 0, - service_url: s.service_url, - swap_fee_sats: s.swap_fee_sats, - tx_id: s.tx_id, - } - }) + current_block_height: currentBlockHeight, + swaps: operations.concat(quotes), } } @@ -261,6 +266,8 @@ export class Swaps { service_url: swapper.getHttpUrl(), swap_fee_sats: fee, tx_id: newSwap.tx_id, + paid_at_unix: newSwap.paid_at_unix, + expires_at_block_height: newSwap.timeout_block_height, } } diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 1581c245..4449e9ca 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -280,14 +280,16 @@ export class AdminManager { // Fetch the full transaction hex for potential refunds let lockupTxHex: string | undefined + let chainFeeSats = 0 try { const txDetails = await this.lnd.GetTx(tx.txid) + chainFeeSats = Number(txDetails.totalFees) lockupTxHex = txDetails.rawTxHex } catch (err: any) { this.log("Warning: Could not fetch transaction hex for refund purposes:", err.message) } - await this.storage.paymentStorage.SetInvoiceSwapTxId(req.swap_operation_id, tx.txid, lockupTxHex) + await this.storage.paymentStorage.SetInvoiceSwapTxId(req.swap_operation_id, tx.txid, chainFeeSats, lockupTxHex) this.log("saved admin swap txid", { swapOpId: req.swap_operation_id, txId: tx.txid }) res(tx.txid) return { txId: tx.txid } diff --git a/src/services/nostr/index.ts b/src/services/nostr/index.ts index 5b2f0ddc..ffa636b6 100644 --- a/src/services/nostr/index.ts +++ b/src/services/nostr/index.ts @@ -16,7 +16,7 @@ export default class NostrSubprocess { private eventCallback: EventCallback private beaconCallback: BeaconCallback private isShuttingDown = false - + constructor(settings: NostrSettings, utils: Utils, eventCallback: EventCallback, beaconCallback: BeaconCallback) { this.utils = utils this.settings = settings @@ -36,9 +36,9 @@ export default class NostrSubprocess { private startSubProcess() { this.cleanupProcess() - + this.childProcess = fork("./build/src/services/nostr/handler") - + this.childProcess.on("error", (error) => { this.log(ERROR, "nostr subprocess error", error) }) @@ -48,20 +48,20 @@ export default class NostrSubprocess { this.log("nostr subprocess stopped") return } - + if (code === 0) { this.log("nostr subprocess exited cleanly") return } - + this.log(ERROR, `nostr subprocess exited with code ${code} and signal ${signal}`) - + const now = Date.now() if (now - this.latestRestart < 5000) { this.log(ERROR, "nostr subprocess exited too quickly, not restarting") throw new Error("nostr subprocess crashed repeatedly") } - + this.log("restarting nostr subprocess...") this.latestRestart = now setTimeout(() => this.startSubProcess(), 100) @@ -113,7 +113,7 @@ export default class NostrSubprocess { Send(initiator: SendInitiator, data: SendData, relays?: string[]) { this.sendToChildProcess({ type: 'send', data, initiator, relays }) } - + Stop() { this.isShuttingDown = true this.cleanupProcess() diff --git a/src/services/storage/entity/InvoiceSwap.ts b/src/services/storage/entity/InvoiceSwap.ts index f435edab..746756b8 100644 --- a/src/services/storage/entity/InvoiceSwap.ts +++ b/src/services/storage/entity/InvoiceSwap.ts @@ -62,6 +62,12 @@ export class InvoiceSwap { @Column({ default: false }) used: boolean + @Column({ default: 0 }) + completed_at_unix: number + + @Column({ default: 0 }) + paid_at_unix: number + @Column({ default: "" }) preimage: string diff --git a/src/services/storage/migrations/1771347307798-swap_timestamps.ts b/src/services/storage/migrations/1771347307798-swap_timestamps.ts new file mode 100644 index 00000000..ce5c0b39 --- /dev/null +++ b/src/services/storage/migrations/1771347307798-swap_timestamps.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class SwapTimestamps1771347307798 implements MigrationInterface { + name = 'SwapTimestamps1771347307798' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "lockup_tx_hex" text NOT NULL DEFAULT (''), "completed_at_unix" integer NOT NULL DEFAULT (0), "paid_at_unix" integer NOT NULL DEFAULT (0))`); + await queryRunner.query(`INSERT INTO "temporary_invoice_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at", "lockup_tx_hex") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at", "lockup_tx_hex" FROM "invoice_swap"`); + await queryRunner.query(`DROP TABLE "invoice_swap"`); + await queryRunner.query(`ALTER TABLE "temporary_invoice_swap" RENAME TO "invoice_swap"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "invoice_swap" RENAME TO "temporary_invoice_swap"`); + await queryRunner.query(`CREATE TABLE "invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "lockup_tx_hex" text NOT NULL DEFAULT (''))`); + await queryRunner.query(`INSERT INTO "invoice_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at", "lockup_tx_hex") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at", "lockup_tx_hex" FROM "temporary_invoice_swap"`); + await queryRunner.query(`DROP TABLE "temporary_invoice_swap"`); + } + +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index 0b08799a..eb5d7c19 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -34,13 +34,15 @@ import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_prov import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js' import { InvoiceSwapsFixes1769805357459 } from './1769805357459-invoice_swaps_fixes.js' +import { SwapTimestamps1771347307798 } from './1771347307798-swap_timestamps.js' export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459] + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, + InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, SwapTimestamps1771347307798] export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] /* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise => { diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 5726c5cf..785f9909 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -530,8 +530,10 @@ export default class { } async FinalizeInvoiceSwap(swapOperationId: string, txId?: string) { + const now = Math.floor(Date.now() / 1000) return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, { used: true, + completed_at_unix: now, }, txId) } @@ -539,9 +541,12 @@ export default class { return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, update, txId) } - async SetInvoiceSwapTxId(swapOperationId: string, chainTxId: string, lockupTxHex?: string, txId?: string) { + async SetInvoiceSwapTxId(swapOperationId: string, chainTxId: string, chainFeeSats: number, lockupTxHex?: string, txId?: string) { + const now = Math.floor(Date.now() / 1000) const update: Partial = { tx_id: chainTxId, + paid_at_unix: now, + chain_fee_sats: chainFeeSats, } if (lockupTxHex) { update.lockup_tx_hex = lockupTxHex @@ -550,9 +555,11 @@ export default class { } async FailInvoiceSwap(swapOperationId: string, failureReason: string, txId?: string) { + const now = Math.floor(Date.now() / 1000) return this.dbs.Update('InvoiceSwap', { swap_operation_id: swapOperationId }, { used: true, failure_reason: failureReason, + completed_at_unix: now, }, txId) } From a10cf126b36ce88a8499cc55396d96697724e96e Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Tue, 17 Feb 2026 13:53:02 -0500 Subject: [PATCH 24/49] comment --- datasource.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/datasource.js b/datasource.js index 9c291b25..7126f7de 100644 --- a/datasource.js +++ b/datasource.js @@ -1,3 +1,14 @@ +/** + * TypeORM DataSource used only by the TypeORM CLI (e.g. migration:generate). + * + * Migrations at runtime are run from src/services/storage/migrations/runner.ts (allMigrations), + * not from this file. The app never uses this DataSource to run migrations. + * + * Workflow: update the migrations array in this file *before* running + * migration:generate, so TypeORM knows the current schema (entities + existing migrations). + * We do not update this file immediately after adding a new migration; update it when you + * are about to generate the next migration. + */ import { DataSource } from "typeorm" import { User } from "./build/src/services/storage/entity/User.js" import { UserReceivingInvoice } from "./build/src/services/storage/entity/UserReceivingInvoice.js" From e32dff939ee58c796a332dfbccd14fdb77e4ed63 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Tue, 17 Feb 2026 21:29:34 +0000 Subject: [PATCH 25/49] paid at unix default --- src/services/lnd/swaps/swaps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index 3a21d2f8..c5abad05 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -83,7 +83,7 @@ export class Swaps { service_url: s.service_url, swap_fee_sats: s.swap_fee_sats, tx_id: s.tx_id, - paid_at_unix: s.paid_at_unix, + paid_at_unix: s.paid_at_unix || (s.tx_id ? 1 : 0), expires_at_block_height: s.timeout_block_height, } } From 6da84340731755ca93495378877ec7b43595c72e Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 19 Feb 2026 18:11:41 +0000 Subject: [PATCH 26/49] account for root ops and change --- metricsDatasource.js | 8 ++- src/services/main/adminManager.ts | 2 +- src/services/main/watchdog.ts | 51 ++++++++++++++++--- src/services/storage/entity/RootOperation.ts | 3 ++ src/services/storage/metricsStorage.ts | 18 +++++-- .../1771524665409-root_op_pending.ts | 20 ++++++++ src/services/storage/migrations/runner.ts | 5 +- 7 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 src/services/storage/migrations/1771524665409-root_op_pending.ts diff --git a/metricsDatasource.js b/metricsDatasource.js index 226b9dac..c1c3f493 100644 --- a/metricsDatasource.js +++ b/metricsDatasource.js @@ -8,12 +8,16 @@ import { LndMetrics1703170330183 } from './build/src/services/storage/migrations import { ChannelRouting1709316653538 } from './build/src/services/storage/migrations/1709316653538-channel_routing.js' import { HtlcCount1724266887195 } from './build/src/services/storage/migrations/1724266887195-htlc_count.js' import { BalanceEvents1724860966825 } from './build/src/services/storage/migrations/1724860966825-balance_events.js' +import { RootOps1732566440447 } from './build/src/services/storage/migrations/1732566440447-root_ops.js' +import { RootOpsTime1745428134124 } from './build/src/services/storage/migrations/1745428134124-root_ops_time.js' +import { ChannelEvents1750777346411 } from './build/src/services/storage/migrations/1750777346411-channel_events.js' export default new DataSource({ type: "better-sqlite3", database: "metrics.sqlite", entities: [BalanceEvent, ChannelBalanceEvent, ChannelRouting, RootOperation, ChannelEvent], - migrations: [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825] + migrations: [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, + RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] }); -//npx typeorm migration:generate ./src/services/storage/migrations/channel_events -d ./metricsDatasource.js \ No newline at end of file +//npx typeorm migration:generate ./src/services/storage/migrations/root_op_pending -d ./metricsDatasource.js \ No newline at end of file diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index 4449e9ca..e8147aa6 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -276,7 +276,7 @@ export class AdminManager { this.swaps.PayInvoiceSwap("admin", req.swap_operation_id, req.sat_per_v_byte, async (addr, amt) => { const tx = await this.lnd.PayAddress(addr, amt, req.sat_per_v_byte, "", { useProvider: false, from: 'system' }) this.log("paid admin invoice swap", { swapOpId: req.swap_operation_id, txId: tx.txid }) - await this.storage.metricsStorage.AddRootOperation("chain_payment", tx.txid, amt) + await this.storage.metricsStorage.AddRootOperation("chain_payment", tx.txid, amt, true) // Fetch the full transaction hex for potential refunds let lockupTxHex: string | undefined diff --git a/src/services/main/watchdog.ts b/src/services/main/watchdog.ts index 778e09ce..b02b9678 100644 --- a/src/services/main/watchdog.ts +++ b/src/services/main/watchdog.ts @@ -29,6 +29,7 @@ export class Watchdog { ready = false interval: NodeJS.Timer; lndPubKey: string; + lastHandlerRootOpsAtUnix = 0 constructor(settings: SettingsManager, liquidityManager: LiquidityManager, lnd: LND, storage: Storage, utils: Utils, rugPullTracker: RugPullTracker) { this.lnd = lnd; this.settings = settings; @@ -67,7 +68,7 @@ export class Watchdog { await this.getTracker() const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() this.utils.stateBundler.AddBalancePoint('usersBalance', totalUsersBalance) - const { totalExternal, otherExternal } = await this.getAggregatedExternalBalance() + const { totalExternal } = await this.getAggregatedExternalBalance() this.initialLndBalance = totalExternal this.initialUsersBalance = totalUsersBalance const fwEvents = await this.lnd.GetForwardingHistory(0, this.startedAtUnix) @@ -76,8 +77,6 @@ export class Watchdog { const paymentFound = await this.storage.paymentStorage.GetMaxPaymentIndex() const knownMaxIndex = paymentFound.length > 0 ? Math.max(paymentFound[0].paymentIndex, 0) : 0 this.latestPaymentIndexOffset = await this.lnd.GetLatestPaymentIndex(knownMaxIndex) - const other = { ilnd: this.initialLndBalance, hf: this.accumulatedHtlcFees, iu: this.initialUsersBalance, tu: totalUsersBalance, oext: otherExternal } - //getLogger({ component: 'watchdog_debug2' })(JSON.stringify({ deltaLnd: 0, deltaUsers: 0, totalExternal, latestIndex: this.latestPaymentIndexOffset, other })) this.interval = setInterval(() => { if (this.latestCheckStart + (1000 * 58) < Date.now()) { this.PaymentRequested() @@ -93,7 +92,44 @@ export class Watchdog { fwEvents.forwardingEvents.forEach((event) => { this.accumulatedHtlcFees += Number(event.fee) }) + } + handleRootOperations = async () => { + let pendingChange = 0 + const pendingChainPayments = await this.storage.metricsStorage.GetPendingChainPayments() + for (const payment of pendingChainPayments) { + const tx = await this.lnd.GetTx(payment.operation_identifier) + if (tx.numConfirmations > 0) { + await this.storage.metricsStorage.SetRootOpConfirmed(payment.serial_id) + continue + } + tx.outputDetails.forEach(o => pendingChange += o.isOurAddress ? Number(o.amount) : 0) + } + let newReceived = 0 + let newSpent = 0 + if (this.lastHandlerRootOpsAtUnix === 0) { + this.lastHandlerRootOpsAtUnix = Math.floor(Date.now() / 1000) + return { newReceived, newSpent, pendingChange } + } + + const newOps = await this.storage.metricsStorage.GetRootOperations({ from: this.lastHandlerRootOpsAtUnix }) + newOps.forEach(o => { + switch (o.operation_type) { + case 'chain_payment': + newSpent += Number(o.operation_amount) + break + case 'invoice_payment': + newSpent += Number(o.operation_amount) + break + case 'chain': + newReceived += Number(o.operation_amount) + break + case 'invoice': + newReceived += Number(o.operation_amount) + break + } + }) + return { newReceived, newSpent, pendingChange } } getAggregatedExternalBalance = async () => { @@ -101,8 +137,9 @@ export class Watchdog { const feesPaidForLiquidity = this.liquidityManager.GetPaidFees() const pb = await this.rugPullTracker.CheckProviderBalance() const providerBalance = pb.prevBalance || pb.balance - const otherExternal = { pb: providerBalance, f: feesPaidForLiquidity, lnd: totalLndBalance, olnd: othersFromLnd } - return { totalExternal: totalLndBalance + providerBalance + feesPaidForLiquidity, otherExternal } + const { newReceived, newSpent, pendingChange } = await this.handleRootOperations() + const opsTotal = newReceived + pendingChange - newSpent + return { totalExternal: totalLndBalance + providerBalance + feesPaidForLiquidity + opsTotal } } checkBalanceUpdate = async (deltaLnd: number, deltaUsers: number) => { @@ -187,7 +224,7 @@ export class Watchdog { } const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() this.utils.stateBundler.AddBalancePoint('usersBalance', totalUsersBalance) - const { totalExternal, otherExternal } = await this.getAggregatedExternalBalance() + const { totalExternal } = await this.getAggregatedExternalBalance() this.utils.stateBundler.AddBalancePoint('accumulatedHtlcFees', this.accumulatedHtlcFees) const deltaLnd = totalExternal - (this.initialLndBalance + this.accumulatedHtlcFees) const deltaUsers = totalUsersBalance - this.initialUsersBalance @@ -196,8 +233,6 @@ export class Watchdog { const knownMaxIndex = Math.max(maxFromDb, this.latestPaymentIndexOffset) const newLatest = await this.lnd.GetLatestPaymentIndex(knownMaxIndex) const historyMismatch = newLatest > knownMaxIndex - const other = { ilnd: this.initialLndBalance, hf: this.accumulatedHtlcFees, iu: this.initialUsersBalance, tu: totalUsersBalance, km: knownMaxIndex, nl: newLatest, oext: otherExternal } - //getLogger({ component: 'watchdog_debug2' })(JSON.stringify({ deltaLnd, deltaUsers, totalExternal, other })) const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers) if (historyMismatch) { getLogger({ component: 'bark' })("History mismatch detected in absolute update, locking outgoing operations") diff --git a/src/services/storage/entity/RootOperation.ts b/src/services/storage/entity/RootOperation.ts index 3a2bd1f9..ec4914bc 100644 --- a/src/services/storage/entity/RootOperation.ts +++ b/src/services/storage/entity/RootOperation.ts @@ -17,6 +17,9 @@ export class RootOperation { @Column({ default: 0 }) at_unix: number + @Column({ default: false }) + pending: boolean + @CreateDateColumn() created_at: Date diff --git a/src/services/storage/metricsStorage.ts b/src/services/storage/metricsStorage.ts index 44da304c..b388a587 100644 --- a/src/services/storage/metricsStorage.ts +++ b/src/services/storage/metricsStorage.ts @@ -10,6 +10,7 @@ import { StorageInterface } from "./db/storageInterface.js"; import { Utils } from "../helpers/utilsWrapper.js"; import { Channel, ChannelEventUpdate } from "../../../proto/lnd/lightning.js"; import { ChannelEvent } from "./entity/ChannelEvent.js"; +export type RootOperationType = 'chain' | 'invoice' | 'chain_payment' | 'invoice_payment' export default class { //DB: DataSource | EntityManager settings: StorageSettings @@ -145,14 +146,25 @@ export default class { } } - async AddRootOperation(opType: string, id: string, amount: number, txId?: string) { - return this.dbs.CreateAndSave('RootOperation', { operation_type: opType, operation_amount: amount, operation_identifier: id, at_unix: Math.floor(Date.now() / 1000) }, txId) + async AddRootOperation(opType: RootOperationType, id: string, amount: number, pending = false, dbTxId?: string) { + return this.dbs.CreateAndSave('RootOperation', { + operation_type: opType, operation_amount: amount, + operation_identifier: id, at_unix: Math.floor(Date.now() / 1000), pending + }, dbTxId) } - async GetRootOperation(opType: string, id: string, txId?: string) { + async GetRootOperation(opType: RootOperationType, id: string, txId?: string) { return this.dbs.FindOne('RootOperation', { where: { operation_type: opType, operation_identifier: id } }, txId) } + async GetPendingChainPayments() { + return this.dbs.Find('RootOperation', { where: { operation_type: 'chain_payment', pending: true } }) + } + + async SetRootOpConfirmed(serialId: number) { + return this.dbs.Update('RootOperation', serialId, { pending: false }) + } + async GetRootOperations({ from, to }: { from?: number, to?: number }, txId?: string) { const q = getTimeQuery({ from, to }) return this.dbs.Find('RootOperation', q, txId) diff --git a/src/services/storage/migrations/1771524665409-root_op_pending.ts b/src/services/storage/migrations/1771524665409-root_op_pending.ts new file mode 100644 index 00000000..e65461f3 --- /dev/null +++ b/src/services/storage/migrations/1771524665409-root_op_pending.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RootOpPending1771524665409 implements MigrationInterface { + name = 'RootOpPending1771524665409' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_root_operation" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "operation_type" varchar NOT NULL, "operation_amount" integer NOT NULL, "operation_identifier" varchar NOT NULL, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "at_unix" integer NOT NULL DEFAULT (0), "pending" boolean NOT NULL DEFAULT (0))`); + await queryRunner.query(`INSERT INTO "temporary_root_operation"("serial_id", "operation_type", "operation_amount", "operation_identifier", "created_at", "updated_at", "at_unix") SELECT "serial_id", "operation_type", "operation_amount", "operation_identifier", "created_at", "updated_at", "at_unix" FROM "root_operation"`); + await queryRunner.query(`DROP TABLE "root_operation"`); + await queryRunner.query(`ALTER TABLE "temporary_root_operation" RENAME TO "root_operation"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "root_operation" RENAME TO "temporary_root_operation"`); + await queryRunner.query(`CREATE TABLE "root_operation" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "operation_type" varchar NOT NULL, "operation_amount" integer NOT NULL, "operation_identifier" varchar NOT NULL, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "at_unix" integer NOT NULL DEFAULT (0))`); + await queryRunner.query(`INSERT INTO "root_operation"("serial_id", "operation_type", "operation_amount", "operation_identifier", "created_at", "updated_at", "at_unix") SELECT "serial_id", "operation_type", "operation_amount", "operation_identifier", "created_at", "updated_at", "at_unix" FROM "temporary_root_operation"`); + await queryRunner.query(`DROP TABLE "temporary_root_operation"`); + } + +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index eb5d7c19..d68e3698 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -35,7 +35,7 @@ import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url. import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js' import { InvoiceSwapsFixes1769805357459 } from './1769805357459-invoice_swaps_fixes.js' import { SwapTimestamps1771347307798 } from './1771347307798-swap_timestamps.js' - +import { RootOpPending1771524665409 } from './1771524665409-root_op_pending.js' export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, @@ -44,7 +44,8 @@ export const allMigrations = [Initial1703170309875, LspOrder1718387847693, Liqui TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, SwapTimestamps1771347307798] -export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] +export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, + RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411, RootOpPending1771524665409] /* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise => { await connectAndMigrate(log, storageManager, allMigrations, allMetricsMigrations) return false From 305b268d73e58b22c8097f0f47c50656c3ee3f02 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 19 Feb 2026 18:53:24 +0000 Subject: [PATCH 27/49] fixes --- metricsDatasource.js | 15 +++++++++++++-- src/services/main/watchdog.ts | 15 ++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/metricsDatasource.js b/metricsDatasource.js index c1c3f493..aaf5ddbc 100644 --- a/metricsDatasource.js +++ b/metricsDatasource.js @@ -1,3 +1,14 @@ +/** + * TypeORM DataSource used only by the TypeORM CLI (e.g. migration:generate). + * + * Migrations at runtime are run from src/services/storage/migrations/runner.ts (allMigrations), + * not from this file. The app never uses this DataSource to run migrations. + * + * Workflow: update the migrations array in this file *before* running + * migration:generate, so TypeORM knows the current schema (entities + existing migrations). + * We do not update this file immediately after adding a new migration; update it when you + * are about to generate the next migration. + */ import { DataSource } from "typeorm" import { BalanceEvent } from "./build/src/services/storage/entity/BalanceEvent.js" import { ChannelBalanceEvent } from "./build/src/services/storage/entity/ChannelsBalanceEvent.js" @@ -11,13 +22,13 @@ import { BalanceEvents1724860966825 } from './build/src/services/storage/migrati import { RootOps1732566440447 } from './build/src/services/storage/migrations/1732566440447-root_ops.js' import { RootOpsTime1745428134124 } from './build/src/services/storage/migrations/1745428134124-root_ops_time.js' import { ChannelEvents1750777346411 } from './build/src/services/storage/migrations/1750777346411-channel_events.js' - +import { RootOpPending1771524665409 } from './build/src/services/storage/migrations/1771524665409-root_op_pending.js' export default new DataSource({ type: "better-sqlite3", database: "metrics.sqlite", entities: [BalanceEvent, ChannelBalanceEvent, ChannelRouting, RootOperation, ChannelEvent], migrations: [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, - RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] + RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411, RootOpPending1771524665409] }); //npx typeorm migration:generate ./src/services/storage/migrations/root_op_pending -d ./metricsDatasource.js \ No newline at end of file diff --git a/src/services/main/watchdog.ts b/src/services/main/watchdog.ts index b02b9678..d9d585ba 100644 --- a/src/services/main/watchdog.ts +++ b/src/services/main/watchdog.ts @@ -98,12 +98,17 @@ export class Watchdog { let pendingChange = 0 const pendingChainPayments = await this.storage.metricsStorage.GetPendingChainPayments() for (const payment of pendingChainPayments) { - const tx = await this.lnd.GetTx(payment.operation_identifier) - if (tx.numConfirmations > 0) { - await this.storage.metricsStorage.SetRootOpConfirmed(payment.serial_id) - continue + try { + const tx = await this.lnd.GetTx(payment.operation_identifier) + if (tx.numConfirmations > 0) { + await this.storage.metricsStorage.SetRootOpConfirmed(payment.serial_id) + continue + } + tx.outputDetails.forEach(o => pendingChange += o.isOurAddress ? Number(o.amount) : 0) + } catch (err: any) { + this.log("Error getting tx for root operation", err.message || err) } - tx.outputDetails.forEach(o => pendingChange += o.isOurAddress ? Number(o.amount) : 0) + } let newReceived = 0 let newSpent = 0 From 875d1274de057c6f91420941ea9e16196ea4058a Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 19 Feb 2026 21:24:24 +0000 Subject: [PATCH 28/49] await tx added to db --- src/services/main/paymentManager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index ec351091..cb9b31cf 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -302,8 +302,11 @@ export default class { const amount = Number(output.amount) const outputIndex = Number(output.outputIndex) log(`processing missed chain tx: address=${output.address}, txHash=${tx.txHash}, amount=${amount}, outputIndex=${outputIndex}`) - this.addressPaidCb({ hash: tx.txHash, index: outputIndex }, output.address, amount, 'lnd', startHeight) - .catch(err => log(ERROR, "failed to process user address output:", err.message || err)) + try { + await this.addressPaidCb({ hash: tx.txHash, index: outputIndex }, output.address, amount, 'lnd', startHeight) + } catch (err: any) { + log(ERROR, "failed to process user address output:", err.message || err) + } return true } From 8ada2ac1654829f3ae91d104e5ddaaace26c9b05 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 19 Feb 2026 21:45:31 +0000 Subject: [PATCH 29/49] new block handler logs --- src/services/main/index.ts | 2 ++ src/services/main/paymentManager.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 965c2eba..9e90d2c2 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -161,6 +161,7 @@ export default class { NewBlockHandler = async (height: number, skipMetrics?: boolean) => { let confirmed: (PendingTx & { confs: number; })[] let log = getLogger({}) + log("NewBlockHandler called", JSON.stringify({ height, skipMetrics })) this.storage.paymentStorage.DeleteExpiredTransactionSwaps(height) .catch(err => log(ERROR, "failed to delete expired transaction swaps", err.message || err)) this.storage.paymentStorage.DeleteExpiredInvoiceSwaps(height) @@ -176,6 +177,7 @@ export default class { log(ERROR, "failed to check transactions after new block", err.message || err) return } + log("NewBlockHandler new confirmed transactions", confirmed.length) await Promise.all(confirmed.map(async c => { if (c.type === 'outgoing') { await this.storage.paymentStorage.UpdateUserTransactionPayment(c.tx.serial_id, { confs: c.confs }) diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index cb9b31cf..63999116 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -980,7 +980,8 @@ export default class { const pending = await this.storage.paymentStorage.GetPendingTransactions() let lowestHeight = height const map: Record = {} - + let log = getLogger({}) + log("CheckNewlyConfirmedTxs ", pending.incoming.length, "incoming", pending.outgoing.length, "outgoing") const checkTx = (t: PendingTx) => { if (t.tx.broadcast_height < lowestHeight) { lowestHeight = t.tx.broadcast_height } map[t.tx.tx_hash] = t From c146d46c59c08d7b4a182ac5b6a86573959934f3 Mon Sep 17 00:00:00 2001 From: Mothana Date: Wed, 4 Feb 2026 22:30:24 +0400 Subject: [PATCH 30/49] notification types and topic id --- datasource.js | 8 +- proto/autogenerated/client.md | 9 ++ proto/autogenerated/go/types.go | 21 +++++ proto/autogenerated/ts/types.ts | 89 +++++++++++++++++++ proto/service/structs.proto | 16 ++++ src/services/main/appUserManager.ts | 3 +- src/services/main/applicationManager.ts | 6 +- src/services/main/index.ts | 29 +++++- src/services/storage/applicationStorage.ts | 3 +- .../storage/entity/ApplicationUser.ts | 3 + ...1770038768784-application_user_topic_id.ts | 24 +++++ src/services/storage/migrations/runner.ts | 9 +- 12 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 src/services/storage/migrations/1770038768784-application_user_topic_id.ts diff --git a/datasource.js b/datasource.js index 7126f7de..0ad041a1 100644 --- a/datasource.js +++ b/datasource.js @@ -62,6 +62,10 @@ import { TrackedProviderHeight1766504040000 } from './build/src/services/storage import { SwapsServiceUrl1768413055036 } from './build/src/services/storage/migrations/1768413055036-swaps_service_url.js' import { InvoiceSwaps1769529793283 } from './build/src/services/storage/migrations/1769529793283-invoice_swaps.js' import { InvoiceSwapsFixes1769805357459 } from './build/src/services/storage/migrations/1769805357459-invoice_swaps_fixes.js' +import { ApplicationUserTopicId1770038768784 } from './build/src/services/storage/migrations/1770038768784-application_user_topic_id.js' +import { SwapTimestamps1771347307798 } from './build/src/services/storage/migrations/1771347307798-swap_timestamps.js' + + export default new DataSource({ type: "better-sqlite3", @@ -71,7 +75,9 @@ export default new DataSource({ PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459], + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, + ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798], + entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment, UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo, diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index 679e0d30..d5b9baab 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -1670,6 +1670,14 @@ The nostr server will send back a message response, and inside the body there wi ### ProvidersDisruption - __disruptions__: ARRAY of: _[ProviderDisruption](#ProviderDisruption)_ +### PushNotificationEnvelope + - __app_npub_hex__: _string_ + - __encrypted_payload__: _string_ + - __topic_id__: _string_ + +### PushNotificationPayload + - __data__: _[PushNotificationPayload_data](#PushNotificationPayload_data)_ + ### RefundAdminInvoiceSwapRequest - __sat_per_v_byte__: _number_ - __swap_operation_id__: _string_ @@ -1797,6 +1805,7 @@ The nostr server will send back a message response, and inside the body there wi - __nmanage__: _string_ - __noffer__: _string_ - __service_fee_bps__: _number_ + - __topic_id__: _string_ - __userId__: _string_ - __user_identifier__: _string_ diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index a59adaaf..d0efb918 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -647,6 +647,14 @@ type ProviderDisruption struct { type ProvidersDisruption struct { Disruptions []ProviderDisruption `json:"disruptions"` } +type PushNotificationEnvelope struct { + App_npub_hex string `json:"app_npub_hex"` + Encrypted_payload string `json:"encrypted_payload"` + Topic_id string `json:"topic_id"` +} +type PushNotificationPayload struct { + Data *PushNotificationPayload_data `json:"data"` +} type RefundAdminInvoiceSwapRequest struct { Sat_per_v_byte int64 `json:"sat_per_v_byte"` Swap_operation_id string `json:"swap_operation_id"` @@ -774,6 +782,7 @@ type UserInfo struct { Nmanage string `json:"nmanage"` Noffer string `json:"noffer"` Service_fee_bps int64 `json:"service_fee_bps"` + Topic_id string `json:"topic_id"` Userid string `json:"userId"` User_identifier string `json:"user_identifier"` } @@ -872,6 +881,18 @@ type NPubLinking_state struct { Linking_token *string `json:"linking_token"` Unlinked *Empty `json:"unlinked"` } +type PushNotificationPayload_data_type string + +const ( + RECEIVED_OPERATION PushNotificationPayload_data_type = "received_operation" + SENT_OPERATION PushNotificationPayload_data_type = "sent_operation" +) + +type PushNotificationPayload_data struct { + Type PushNotificationPayload_data_type `json:"type"` + Received_operation *UserOperation `json:"received_operation"` + Sent_operation *UserOperation `json:"sent_operation"` +} type UpdateChannelPolicyRequest_update_type string const ( diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index 82f2c1fe..f62fea43 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -3843,6 +3843,53 @@ export const ProvidersDisruptionValidate = (o?: ProvidersDisruption, opts: Provi return null } +export type PushNotificationEnvelope = { + app_npub_hex: string + encrypted_payload: string + topic_id: string +} +export const PushNotificationEnvelopeOptionalFields: [] = [] +export type PushNotificationEnvelopeOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + app_npub_hex_CustomCheck?: (v: string) => boolean + encrypted_payload_CustomCheck?: (v: string) => boolean + topic_id_CustomCheck?: (v: string) => boolean +} +export const PushNotificationEnvelopeValidate = (o?: PushNotificationEnvelope, opts: PushNotificationEnvelopeOptions = {}, path: string = 'PushNotificationEnvelope::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') + + if (typeof o.app_npub_hex !== 'string') return new Error(`${path}.app_npub_hex: is not a string`) + if (opts.app_npub_hex_CustomCheck && !opts.app_npub_hex_CustomCheck(o.app_npub_hex)) return new Error(`${path}.app_npub_hex: custom check failed`) + + if (typeof o.encrypted_payload !== 'string') return new Error(`${path}.encrypted_payload: is not a string`) + if (opts.encrypted_payload_CustomCheck && !opts.encrypted_payload_CustomCheck(o.encrypted_payload)) return new Error(`${path}.encrypted_payload: custom check failed`) + + if (typeof o.topic_id !== 'string') return new Error(`${path}.topic_id: is not a string`) + if (opts.topic_id_CustomCheck && !opts.topic_id_CustomCheck(o.topic_id)) return new Error(`${path}.topic_id: custom check failed`) + + return null +} + +export type PushNotificationPayload = { + data: PushNotificationPayload_data +} +export const PushNotificationPayloadOptionalFields: [] = [] +export type PushNotificationPayloadOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + data_Options?: PushNotificationPayload_dataOptions +} +export const PushNotificationPayloadValidate = (o?: PushNotificationPayload, opts: PushNotificationPayloadOptions = {}, path: string = 'PushNotificationPayload::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 dataErr = PushNotificationPayload_dataValidate(o.data, opts.data_Options, `${path}.data`) + if (dataErr !== null) return dataErr + + + return null +} + export type RefundAdminInvoiceSwapRequest = { sat_per_v_byte: number swap_operation_id: string @@ -4539,6 +4586,7 @@ export type UserInfo = { nmanage: string noffer: string service_fee_bps: number + topic_id: string userId: string user_identifier: string } @@ -4555,6 +4603,7 @@ export type UserInfoOptions = OptionsBaseMessage & { nmanage_CustomCheck?: (v: string) => boolean noffer_CustomCheck?: (v: string) => boolean service_fee_bps_CustomCheck?: (v: number) => boolean + topic_id_CustomCheck?: (v: string) => boolean userId_CustomCheck?: (v: string) => boolean user_identifier_CustomCheck?: (v: string) => boolean } @@ -4592,6 +4641,9 @@ export const UserInfoValidate = (o?: UserInfo, opts: UserInfoOptions = {}, path: if (typeof o.service_fee_bps !== 'number') return new Error(`${path}.service_fee_bps: is not a number`) if (opts.service_fee_bps_CustomCheck && !opts.service_fee_bps_CustomCheck(o.service_fee_bps)) return new Error(`${path}.service_fee_bps: custom check failed`) + if (typeof o.topic_id !== 'string') return new Error(`${path}.topic_id: is not a string`) + if (opts.topic_id_CustomCheck && !opts.topic_id_CustomCheck(o.topic_id)) return new Error(`${path}.topic_id: custom check failed`) + if (typeof o.userId !== 'string') return new Error(`${path}.userId: is not a string`) if (opts.userId_CustomCheck && !opts.userId_CustomCheck(o.userId)) return new Error(`${path}.userId: custom check failed`) @@ -5009,6 +5061,43 @@ export const NPubLinking_stateValidate = (o?: NPubLinking_state, opts:NPubLinkin if (unlinkedErr !== null) return unlinkedErr + break + default: + return new Error(path + ': unknown type '+ stringType) + } + return null +} +export enum PushNotificationPayload_data_type { + RECEIVED_OPERATION = 'received_operation', + SENT_OPERATION = 'sent_operation', +} +export const enumCheckPushNotificationPayload_data_type = (e?: PushNotificationPayload_data_type): boolean => { + for (const v in PushNotificationPayload_data_type) if (e === v) return true + return false +} +export type PushNotificationPayload_data = + {type:PushNotificationPayload_data_type.RECEIVED_OPERATION, received_operation:UserOperation}| + {type:PushNotificationPayload_data_type.SENT_OPERATION, sent_operation:UserOperation} + +export type PushNotificationPayload_dataOptions = { + received_operation_Options?: UserOperationOptions + sent_operation_Options?: UserOperationOptions +} +export const PushNotificationPayload_dataValidate = (o?: PushNotificationPayload_data, opts:PushNotificationPayload_dataOptions = {}, path: string = 'PushNotificationPayload_data::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 PushNotificationPayload_data_type.RECEIVED_OPERATION: + const received_operationErr = UserOperationValidate(o.received_operation, opts.received_operation_Options, `${path}.received_operation`) + if (received_operationErr !== null) return received_operationErr + + + break + case PushNotificationPayload_data_type.SENT_OPERATION: + const sent_operationErr = UserOperationValidate(o.sent_operation, opts.sent_operation_Options, `${path}.sent_operation`) + if (sent_operationErr !== null) return sent_operationErr + + break default: return new Error(path + ': unknown type '+ stringType) diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 07b930da..fb1f0867 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -541,6 +541,7 @@ message UserInfo{ string callback_url = 10; string bridge_url = 11; string nmanage = 12; + string topic_id = 13; } @@ -935,4 +936,19 @@ message BeaconData { optional string avatarUrl = 3; optional string nextRelay = 4; optional CumulativeFees fees = 5; +} + + +message PushNotificationEnvelope { + string topic_id = 1; + string app_npub_hex = 2; + string encrypted_payload = 3; // encrypted PushNotificationPayload +} + + +message PushNotificationPayload { + oneof data { + UserOperation received_operation = 1; + UserOperation sent_operation = 2; + } } \ No newline at end of file diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index 168258f1..e1db3637 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -82,7 +82,8 @@ export default class { 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] }), callback_url: appUser.callback_url, - bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl + bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl, + topic_id: appUser.topic_id } } diff --git a/src/services/main/applicationManager.ts b/src/services/main/applicationManager.ts index aaafd8a7..ecfc7b85 100644 --- a/src/services/main/applicationManager.ts +++ b/src/services/main/applicationManager.ts @@ -169,7 +169,8 @@ export default class { 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] }), callback_url: u.callback_url, - bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl + bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl, + topic_id: u.topic_id }, max_withdrawable: max @@ -227,7 +228,8 @@ export default class { 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] }), callback_url: user.callback_url, - bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl + bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl, + topic_id: user.topic_id }, } } diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 9e90d2c2..6a931cc6 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -424,13 +424,36 @@ export default class { if (devices.length === 0 || !app.nostr_public_key || !app.nostr_private_key || !appUser.nostr_public_key) { return } + const tokens = devices.map(d => d.firebase_messaging_token) const ck = nip44.getConversationKey(Buffer.from(app.nostr_private_key, 'hex'), appUser.nostr_public_key) - const j = JSON.stringify(op) + + let payloadToEncrypt: Types.PushNotificationPayload; + if (op.inbound) { + payloadToEncrypt = { + data: { + type: Types.PushNotificationPayload_data_type.RECEIVED_OPERATION, + received_operation: op + } + } + } else { + payloadToEncrypt = { + data: { + type: Types.PushNotificationPayload_data_type.SENT_OPERATION, + sent_operation: op + } + } + } + const j = JSON.stringify(payloadToEncrypt) const encrypted = nip44.encrypt(j, ck) - const encryptedData: { encrypted: string, app_npub_hex: string } = { encrypted, app_npub_hex: app.nostr_public_key } + + const envelope: Types.PushNotificationEnvelope = { + topic_id: appUser.topic_id, + app_npub_hex: app.nostr_public_key, + encrypted_payload: encrypted + } const notification: ShockPushNotification = { - message: JSON.stringify(encryptedData), + message: JSON.stringify(envelope), body, title } diff --git a/src/services/storage/applicationStorage.ts b/src/services/storage/applicationStorage.ts index 21f6f9c9..08aad37a 100644 --- a/src/services/storage/applicationStorage.ts +++ b/src/services/storage/applicationStorage.ts @@ -72,7 +72,8 @@ export default class { user: user, application, identifier: userIdentifier, - nostr_public_key: nostrPub + nostr_public_key: nostrPub, + topic_id: crypto.randomBytes(32).toString('hex') }, txId) }) } diff --git a/src/services/storage/entity/ApplicationUser.ts b/src/services/storage/entity/ApplicationUser.ts index 2284b9bd..c4717721 100644 --- a/src/services/storage/entity/ApplicationUser.ts +++ b/src/services/storage/entity/ApplicationUser.ts @@ -26,6 +26,9 @@ export class ApplicationUser { @Column({ default: "" }) callback_url: string + @Column({ unique: true }) + topic_id: string; + @CreateDateColumn() created_at: Date diff --git a/src/services/storage/migrations/1770038768784-application_user_topic_id.ts b/src/services/storage/migrations/1770038768784-application_user_topic_id.ts new file mode 100644 index 00000000..b520cbe9 --- /dev/null +++ b/src/services/storage/migrations/1770038768784-application_user_topic_id.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ApplicationUserTopicId1770038768784 implements MigrationInterface { + name = 'ApplicationUserTopicId1770038768784' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_0a0dbb25a73306b037dec82251"`); + await queryRunner.query(`CREATE TABLE "temporary_application_user" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "identifier" varchar NOT NULL, "nostr_public_key" varchar, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "applicationSerialId" integer, "callback_url" varchar NOT NULL DEFAULT (''), "topic_id" varchar NOT NULL, CONSTRAINT "UQ_3175dc397c8285d1e532554dea5" UNIQUE ("nostr_public_key"), CONSTRAINT "REL_0796a381bcc624f52e9a155712" UNIQUE ("userSerialId"), CONSTRAINT "UQ_bd1a42f39fd7b4218bed5cc63d9" UNIQUE ("topic_id"), CONSTRAINT "FK_0796a381bcc624f52e9a155712b" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_1b3bdb6f660cd99533a1e673ef1" FOREIGN KEY ("applicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_application_user"("serial_id", "identifier", "nostr_public_key", "created_at", "updated_at", "userSerialId", "applicationSerialId", "callback_url", "topic_id") SELECT "serial_id", "identifier", "nostr_public_key", "created_at", "updated_at", "userSerialId", "applicationSerialId", "callback_url", lower(hex(randomblob(32))) FROM "application_user"`); + await queryRunner.query(`DROP TABLE "application_user"`); + await queryRunner.query(`ALTER TABLE "temporary_application_user" RENAME TO "application_user"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0a0dbb25a73306b037dec82251" ON "application_user" ("identifier") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_0a0dbb25a73306b037dec82251"`); + await queryRunner.query(`ALTER TABLE "application_user" RENAME TO "temporary_application_user"`); + await queryRunner.query(`CREATE TABLE "application_user" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "identifier" varchar NOT NULL, "nostr_public_key" varchar, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "applicationSerialId" integer, "callback_url" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_3175dc397c8285d1e532554dea5" UNIQUE ("nostr_public_key"), CONSTRAINT "REL_0796a381bcc624f52e9a155712" UNIQUE ("userSerialId"), CONSTRAINT "FK_0796a381bcc624f52e9a155712b" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_1b3bdb6f660cd99533a1e673ef1" FOREIGN KEY ("applicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "application_user"("serial_id", "identifier", "nostr_public_key", "created_at", "updated_at", "userSerialId", "applicationSerialId", "callback_url") SELECT "serial_id", "identifier", "nostr_public_key", "created_at", "updated_at", "userSerialId", "applicationSerialId", "callback_url" FROM "temporary_application_user"`); + await queryRunner.query(`DROP TABLE "temporary_application_user"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0a0dbb25a73306b037dec82251" ON "application_user" ("identifier") `); + } + +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index d68e3698..1b5537d6 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -32,17 +32,24 @@ import { TxSwapAddress1764779178945 } from './1764779178945-tx_swap_address.js' import { ClinkRequester1765497600000 } from './1765497600000-clink_requester.js' import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_provider_height.js' import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' + import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js' import { InvoiceSwapsFixes1769805357459 } from './1769805357459-invoice_swaps_fixes.js' +import { ApplicationUserTopicId1770038768784 } from './1770038768784-application_user_topic_id.js' import { SwapTimestamps1771347307798 } from './1771347307798-swap_timestamps.js' import { RootOpPending1771524665409 } from './1771524665409-root_op_pending.js' + + + + export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, - InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, SwapTimestamps1771347307798] + InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798] + export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411, RootOpPending1771524665409] From c83028c41923f182b9f47b943690e3fce83bb40b Mon Sep 17 00:00:00 2001 From: boufni95 Date: Fri, 20 Feb 2026 18:40:40 +0000 Subject: [PATCH 31/49] fetch each pending tx to validate --- src/services/main/index.ts | 2 +- src/services/main/paymentManager.ts | 61 +++++++++++++---------------- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 9e90d2c2..8d831e91 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -171,7 +171,7 @@ export default class { if (!skipMetrics) { await this.metricsManager.NewBlockCb(height, balanceEvents) } - confirmed = await this.paymentManager.CheckNewlyConfirmedTxs(height) + confirmed = await this.paymentManager.CheckNewlyConfirmedTxs() await this.liquidityManager.onNewBlock() } catch (err: any) { log(ERROR, "failed to check transactions after new block", err.message || err) diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 63999116..38c7658b 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -201,24 +201,11 @@ export default class { } else { log("no missed chain transactions found") } - await this.reprocessStuckPendingTx(log, currentHeight) } catch (err: any) { log(ERROR, "failed to check for missed chain transactions:", err.message || err) } } - reprocessStuckPendingTx = async (log: PubLogger, currentHeight: number) => { - const { incoming } = await this.storage.paymentStorage.GetPendingTransactions() - const found = incoming.find(t => t.broadcast_height < currentHeight - 100) - if (found) { - log("found a possibly stuck pending transaction, reprocessing with full transaction history") - // There is a pending transaction more than 100 blocks old, this is likely a transaction - // that has a broadcast height higher than it actually is, so its not getting picked up when being processed - // by calling new block cb with height of 1, we make sure that even if the transaction has a newer height, it will still be processed - await this.newBlockCb(1, true) - } - } - private async getLatestTransactions(log: PubLogger): Promise<{ txs: Transaction[], currentHeight: number, lndPubkey: string, startHeight: number }> { const lndInfo = await this.lnd.GetInfo() const lndPubkey = lndInfo.identityPubkey @@ -273,7 +260,7 @@ export default class { private async processRootAddressOutput(output: OutputDetail, tx: Transaction, addresses: LndAddress[], log: PubLogger): Promise { const addr = addresses.find(a => a.address === output.address) if (!addr) { - throw new Error(`address ${output.address} not found in list of addresses`) + throw new Error(`root address ${output.address} not found in list of addresses`) } if (addr.change) { log(`ignoring change address ${output.address}`) @@ -976,30 +963,38 @@ export default class { return { amount: payment.paid_amount, fees: payment.service_fees } } - async CheckNewlyConfirmedTxs(height: number) { + private async getTxConfs(txHash: string): Promise { + try { + const info = await this.lnd.GetTx(txHash) + const { numConfirmations: confs, amount: amt } = info + if (confs > 2 || (amt <= confInTwo && confs > 1) || (amt <= confInOne && confs > 0)) { + return confs + } + } catch (err: any) { + getLogger({})("failed to get tx info", err.message || err) + } + return 0 + } + + async CheckNewlyConfirmedTxs() { const pending = await this.storage.paymentStorage.GetPendingTransactions() - let lowestHeight = height - const map: Record = {} let log = getLogger({}) log("CheckNewlyConfirmedTxs ", pending.incoming.length, "incoming", pending.outgoing.length, "outgoing") - const checkTx = (t: PendingTx) => { - if (t.tx.broadcast_height < lowestHeight) { lowestHeight = t.tx.broadcast_height } - map[t.tx.tx_hash] = t + const confirmedIncoming: (PendingTx & { confs: number })[] = [] + const confirmedOutgoing: (PendingTx & { confs: number })[] = [] + for (const tx of pending.incoming) { + const confs = await this.getTxConfs(tx.tx_hash) + if (confs > 0) { + confirmedIncoming.push({ type: "incoming", tx: tx, confs }) + } } - pending.incoming.forEach(t => checkTx({ type: "incoming", tx: t })) - pending.outgoing.forEach(t => checkTx({ type: "outgoing", tx: t })) - const { transactions } = await this.lnd.GetTransactions(lowestHeight) - const newlyConfirmedTxs = transactions.map(tx => { - const { txHash, numConfirmations: confs, amount: amt } = tx - const t = map[txHash] - if (!t || confs === 0) { - return + for (const tx of pending.outgoing) { + const confs = await this.getTxConfs(tx.tx_hash) + if (confs > 0) { + confirmedOutgoing.push({ type: "outgoing", tx: tx, confs }) } - if (confs > 2 || (amt <= confInTwo && confs > 1) || (amt <= confInOne && confs > 0)) { - return { ...t, confs } - } - }) - return newlyConfirmedTxs.filter(t => t !== undefined) as (PendingTx & { confs: number })[] + } + return confirmedIncoming.concat(confirmedOutgoing) } async CleanupOldUnpaidInvoices() { From 9e66f7d72e979e6fd3217f46783b2d7f1b710bd6 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Fri, 20 Feb 2026 19:37:11 +0000 Subject: [PATCH 32/49] use debug log level, no level = ERROR --- src/services/helpers/logger.ts | 16 ++-- src/services/lnd/lnd.ts | 137 +++++++++++++++------------------ 2 files changed, 69 insertions(+), 84 deletions(-) diff --git a/src/services/helpers/logger.ts b/src/services/helpers/logger.ts index 4d6601c9..62a87eac 100644 --- a/src/services/helpers/logger.ts +++ b/src/services/helpers/logger.ts @@ -1,17 +1,17 @@ import fs from 'fs' export const DEBUG = Symbol("DEBUG") export const ERROR = Symbol("ERROR") -export const WARN = Symbol("WARN") +export const INFO = Symbol("INFO") type LoggerParams = { appName?: string, userId?: string, component?: string } export type PubLogger = (...message: (string | number | object | symbol)[]) => void type Writer = (message: string) => void const logsDir = process.env.LOGS_DIR || "logs" -const logLevel = process.env.LOG_LEVEL || "DEBUG" +const logLevel = process.env.LOG_LEVEL || "INFO" try { fs.mkdirSync(logsDir) } catch { } -if (logLevel !== "DEBUG" && logLevel !== "WARN" && logLevel !== "ERROR") { - throw new Error("Invalid log level " + logLevel + " must be one of (DEBUG, WARN, ERROR)") +if (logLevel !== "DEBUG" && logLevel !== "INFO" && logLevel !== "ERROR") { + throw new Error("Invalid log level " + logLevel + " must be one of (DEBUG, INFO, ERROR)") } const z = (n: number) => n < 10 ? `0${n}` : `${n}` // Sanitize filename to remove invalid characters for filesystem @@ -67,19 +67,17 @@ export const getLogger = (params: LoggerParams): PubLogger => { } message[0] = "DEBUG" break; - case WARN: + case INFO: if (logLevel === "ERROR") { return } - message[0] = "WARN" + message[0] = "INFO" break; case ERROR: message[0] = "ERROR" break; default: - if (logLevel !== "DEBUG") { - return - } + // treats logs without a level as ERROR level, without prefix so it can be found and fixed if needed } const now = new Date() const timestamp = `${now.getFullYear()}-${z(now.getMonth() + 1)}-${z(now.getDate())} ${z(now.getHours())}:${z(now.getMinutes())}:${z(now.getSeconds())}` diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index 3d66b268..6d71d424 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -15,7 +15,7 @@ import { AddInvoiceReq } from './addInvoiceReq.js'; import { PayInvoiceReq } from './payInvoiceReq.js'; import { SendCoinsReq } from './sendCoinsReq.js'; import { AddressPaidCb, InvoicePaidCb, NodeInfo, Invoice, DecodedInvoice, PaidInvoice, NewBlockCb, HtlcCb, BalanceInfo, ChannelEventCb } from './settings.js'; -import { ERROR, getLogger } from '../helpers/logger.js'; +import { ERROR, getLogger, DEBUG, INFO } from '../helpers/logger.js'; import { HtlcEvent_EventType } from '../../../proto/lnd/router.js'; import { LiquidityProvider } from '../main/liquidityProvider.js'; import { Utils } from '../helpers/utilsWrapper.js'; @@ -69,7 +69,7 @@ export default class { // Skip LND client initialization if using only liquidity provider if (liquidProvider.getSettings().useOnlyLiquidityProvider) { - this.log("USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND client initialization") + this.log(INFO, "USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND client initialization") // Create minimal dummy clients - they won't be used but prevent null reference errors // Use insecure credentials directly (can't combine them) const { lndAddr } = this.getSettings().lndNodeSettings @@ -126,14 +126,13 @@ export default class { } async Warmup() { - this.log("Warming up LND") + this.log(INFO, "Warming up LND") // Skip LND warmup if using only liquidity provider if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { - this.log("USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND warmup") + this.log(INFO, "USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND warmup") this.ready = true return } - // console.log("Warming up LND") this.SubscribeAddressPaid() this.SubscribeInvoicePaid() await this.SubscribeNewBlock() @@ -148,7 +147,7 @@ export default class { this.ready = true res() } catch (err) { - this.log("LND is not ready yet, will try again in 1 second") + this.log(INFO, "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")) } @@ -177,28 +176,26 @@ export default class { uris: [] } } - // console.log("Getting info") const res = await this.lightning.getInfo({}, DeadLineMetadata()) return res.response } async ListPendingChannels(): Promise { - this.log("Listing pending channels") + this.log(DEBUG, "Listing pending channels") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { pendingOpenChannels: [], pendingClosingChannels: [], pendingForceClosingChannels: [], waitingCloseChannels: [], totalLimboBalance: 0n } } - // console.log("Listing pending channels") const res = await this.lightning.pendingChannels({ includeRawTx: false }, DeadLineMetadata()) return res.response } async ListChannels(peerLookup = false): Promise { - this.log("Listing channels") + this.log(DEBUG, "Listing channels") const res = await this.lightning.listChannels({ activeOnly: false, inactiveOnly: false, privateOnly: false, publicOnly: false, peer: Buffer.alloc(0), peerAliasLookup: peerLookup }, DeadLineMetadata()) return res.response } async ListClosedChannels(): Promise { - this.log("Listing closed channels") + this.log(DEBUG, "Listing closed channels") const res = await this.lightning.closedChannels({ abandoned: true, breach: true, @@ -215,7 +212,6 @@ export default class { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return } - // console.log("Checking health") if (!this.ready) { throw new Error("not ready") } @@ -226,69 +222,69 @@ export default class { } RestartStreams() { - this.log("Restarting streams") + this.log(INFO, "Restarting streams") if (!this.ready || this.abortController.signal.aborted) { return } - this.log("LND is dead, will try to reconnect in", deadLndRetrySeconds, "seconds") + this.log(INFO, "LND is dead, will try to reconnect in", deadLndRetrySeconds, "seconds") const interval = setInterval(async () => { try { await this.unlockLnd() - this.log("LND is back online") + this.log(INFO, "LND is back online") clearInterval(interval) await this.Warmup() } catch (err) { - this.log("LND still dead, will try again in", deadLndRetrySeconds, "seconds") + this.log(INFO, "LND still dead, will try again in", deadLndRetrySeconds, "seconds") } }, deadLndRetrySeconds * 1000) } async SubscribeChannelEvents() { - this.log("Subscribing to channel events") + this.log(DEBUG, "Subscribing to channel events") const stream = this.lightning.subscribeChannelEvents({}, { abort: this.abortController.signal }) stream.responses.onMessage(async channel => { const channels = await this.ListChannels() this.channelEventCb(channel, channels.channels) }) stream.responses.onError(error => { - this.log("Error with subscribeChannelEvents stream") + this.log(ERROR, "Error with subscribeChannelEvents stream") }) stream.responses.onComplete(() => { - this.log("subscribeChannelEvents stream closed") + this.log(INFO, "subscribeChannelEvents stream closed") }) } async SubscribeHtlcEvents() { - this.log("Subscribing to htlc events") + this.log(DEBUG, "Subscribing to htlc events") 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") + this.log(ERROR, "Error with subscribeHtlcEvents stream") }) stream.responses.onComplete(() => { - this.log("subscribeHtlcEvents stream closed") + this.log(INFO, "subscribeHtlcEvents stream closed") }) } async SubscribeNewBlock() { - this.log("Subscribing to new block") + this.log(DEBUG, "Subscribing to new block") 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 new block stream") + this.log(ERROR, "Error with new block stream") }) stream.responses.onComplete(() => { - this.log("new block stream closed") + this.log(INFO, "new block stream closed") }) } SubscribeAddressPaid(): void { - this.log("Subscribing to address paid") + this.log(DEBUG, "Subscribing to address paid") const stream = this.lightning.subscribeTransactions({ account: "", endHeight: 0, @@ -307,15 +303,15 @@ export default class { } }) stream.responses.onError(error => { - this.log("Error with onchain tx stream") + this.log(ERROR, "Error with onchain tx stream") }) stream.responses.onComplete(() => { - this.log("onchain tx stream closed") + this.log(INFO, "onchain tx stream closed") }) } SubscribeInvoicePaid(): void { - this.log("Subscribing to invoice paid") + this.log(DEBUG, "Subscribing to invoice paid") const stream = this.lightning.subscribeInvoices({ settleIndex: BigInt(this.latestKnownSettleIndex), addIndex: 0n, @@ -328,14 +324,14 @@ export default class { }) let restarted = false stream.responses.onError(error => { - this.log("Error with invoice stream") + this.log(ERROR, "Error with invoice stream") if (!restarted) { restarted = true this.RestartStreams() } }) stream.responses.onComplete(() => { - this.log("invoice stream closed") + this.log(INFO, "invoice stream closed") if (!restarted) { restarted = true this.RestartStreams() @@ -357,7 +353,7 @@ export default class { } async ListAddresses(): Promise { - this.log("Listing addresses") + this.log(DEBUG, "Listing addresses") const res = await this.walletKit.listAddresses({ accountName: "", showCustomAccounts: false }, DeadLineMetadata()) const addresses = res.response.accountWithAddresses.map(a => a.addresses.map(a => ({ address: a.address, change: a.isInternal }))).flat() addresses.forEach(a => this.addressesCache[a.address] = { isChange: a.change }) @@ -365,12 +361,11 @@ export default class { } async NewAddress(addressType: Types.AddressType, { useProvider, from }: TxActionOptions): Promise { - this.log("Creating new address") + this.log(DEBUG, "Creating new address") // Force use of provider when bypass is enabled (addresses not supported by provider, but we should fail gracefully) if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { throw new Error("Address generation not supported when USE_ONLY_LIQUIDITY_PROVIDER is enabled") } - // console.log("Creating new address") let lndAddressType: AddressType switch (addressType) { case Types.AddressType.NESTED_PUBKEY_HASH: @@ -400,11 +395,11 @@ export default class { } async NewInvoice(value: number, memo: string, expiry: number, { useProvider, from }: TxActionOptions, blind = false): Promise { - this.log("Creating new invoice") + this.log(DEBUG, "Creating new invoice") // Force use of provider when bypass is enabled const mustUseProvider = this.liquidProvider.getSettings().useOnlyLiquidityProvider || useProvider if (mustUseProvider) { - console.log("using provider") + this.log(INFO, "using provider") const invoice = await this.liquidProvider.AddInvoice(value, memo, from, expiry) const providerPubkey = this.liquidProvider.GetProviderPubkey() return { payRequest: invoice, providerPubkey } @@ -420,7 +415,7 @@ export default class { } async DecodeInvoice(paymentRequest: string): Promise { - this.log("Decoding invoice") + this.log(DEBUG, "Decoding invoice") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { // Use light-bolt11-decoder when LND is bypassed try { @@ -446,25 +441,23 @@ export default class { throw new Error(`Failed to decode invoice: ${err.message}`) } } - // console.log("Decoding invoice") const res = await this.lightning.decodePayReq({ payReq: paymentRequest }, DeadLineMetadata()) return { numSatoshis: Number(res.response.numSatoshis), paymentHash: res.response.paymentHash } } async ChannelBalance(): Promise<{ local: number, remote: number }> { - this.log("Getting channel balance") + this.log(DEBUG, "Getting channel balance") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { local: 0, remote: 0 } } - // console.log("Getting channel balance") 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, { routingFeeLimit, serviceFee }: { routingFeeLimit: number, serviceFee: number }, decodedAmount: number, { useProvider, from }: TxActionOptions, paymentIndexCb?: (index: number) => void): Promise { - this.log("Paying invoice") + this.log(DEBUG, "Paying invoice") if (this.outgoingOpsLocked) { - this.log("outgoing ops locked, rejecting payment request") + this.log(ERROR, "outgoing ops locked, rejecting payment request") throw new Error("lnd node is currently out of sync") } // Force use of provider when bypass is enabled @@ -481,7 +474,7 @@ export default class { const stream = this.router.sendPaymentV2(req, { abort: abortController.signal }) return new Promise((res, rej) => { stream.responses.onError(error => { - this.log("invoice payment failed", error) + this.log(ERROR, "invoice payment failed", error) rej(error) }) let indexSent = false @@ -493,7 +486,7 @@ export default class { } switch (payment.status) { case Payment_PaymentStatus.FAILED: - this.log("invoice payment failed", payment.failureReason) + this.log(ERROR, "invoice payment failed", payment.failureReason) rej(PaymentFailureReason[payment.failureReason]) return case Payment_PaymentStatus.SUCCEEDED: @@ -511,7 +504,7 @@ export default class { } async EstimateChainFees(address: string, amount: number, targetConf: number): Promise { - this.log("Estimating chain fees") + this.log(DEBUG, "Estimating chain fees") await this.Health() const res = await this.lightning.estimateFee({ addrToAmount: { [address]: BigInt(amount) }, @@ -524,14 +517,13 @@ export default class { } async PayAddress(address: string, amount: number, satPerVByte: number, label = "", { useProvider, from }: TxActionOptions): Promise { - this.log("Paying address") + this.log(DEBUG, "Paying address") // Address payments not supported when bypass is enabled if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { throw new Error("Address payments not supported when USE_ONLY_LIQUIDITY_PROVIDER is enabled") } - // console.log("Paying address") if (this.outgoingOpsLocked) { - this.log("outgoing ops locked, rejecting payment request") + this.log(ERROR, "outgoing ops locked, rejecting payment request") throw new Error("lnd node is currently out of sync") } if (useProvider) { @@ -549,19 +541,19 @@ export default class { } async GetTransactions(startHeight: number): Promise { - this.log("Getting transactions") + this.log(DEBUG, "Getting transactions") const res = await this.lightning.getTransactions({ startHeight, endHeight: 0, account: "" }, DeadLineMetadata()) return res.response } async GetChannelInfo(chanId: string) { - this.log("Getting channel info") + this.log(DEBUG, "Getting channel info") const res = await this.lightning.getChanInfo({ chanId, chanPoint: "" }, DeadLineMetadata()) return res.response } async UpdateChannelPolicy(chanPoint: string, policy: Types.ChannelPolicy) { - this.log("Updating channel policy") + this.log(DEBUG, "Updating channel policy") const split = chanPoint.split(':') const res = await this.lightning.updateChannelPolicy({ @@ -579,19 +571,19 @@ export default class { } async GetChannelBalance() { - this.log("Getting channel balance") + this.log(DEBUG, "Getting channel balance") const res = await this.lightning.channelBalance({}, DeadLineMetadata()) return res.response } async GetWalletBalance() { - this.log("Getting wallet balance") + this.log(DEBUG, "Getting wallet balance") const res = await this.lightning.walletBalance({ account: "", minConfs: 1 }, DeadLineMetadata()) return res.response } async GetTotalBalace() { - this.log("Getting total balance") + this.log(DEBUG, "Getting total balance") const walletBalance = await this.GetWalletBalance() const confirmedWalletBalance = Number(walletBalance.confirmedBalance) this.utils.stateBundler.AddBalancePoint('walletBalance', confirmedWalletBalance) @@ -606,11 +598,10 @@ export default class { } async GetBalance(): Promise { // TODO: remove this - this.log("Getting balance") + this.log(DEBUG, "Getting balance") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { confirmedBalance: 0, unconfirmedBalance: 0, totalBalance: 0, channelsBalance: [] } } - // console.log("Getting balance") const wRes = await this.lightning.walletBalance({ account: "", minConfs: 1 }, DeadLineMetadata()) const { confirmedBalance, unconfirmedBalance, totalBalance } = wRes.response const { response } = await this.lightning.listChannels({ @@ -626,36 +617,33 @@ export default class { } async GetForwardingHistory(indexOffset: number, startTime = 0, endTime = 0): Promise { - this.log("Getting forwarding history") + this.log(DEBUG, "Getting forwarding history") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { forwardingEvents: [], lastOffsetIndex: indexOffset } } - // console.log("Getting forwarding history") const { response } = await this.lightning.forwardingHistory({ indexOffset, numMaxEvents: 0, startTime: BigInt(startTime), endTime: BigInt(endTime), peerAliasLookup: false }, DeadLineMetadata()) return response } async GetAllPaidInvoices(max: number) { - this.log("Getting all paid invoices") + this.log(DEBUG, "Getting all paid invoices") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { invoices: [] } } - // console.log("Getting all paid invoices") const res = await this.lightning.listInvoices({ indexOffset: 0n, numMaxInvoices: BigInt(max), pendingOnly: false, reversed: true, creationDateEnd: 0n, creationDateStart: 0n }, DeadLineMetadata()) return res.response } async GetAllPayments(max: number) { - this.log("Getting all payments") + this.log(DEBUG, "Getting all payments") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { payments: [] } } - // console.log("Getting all payments") const res = await this.lightning.listPayments({ countTotalPayments: false, includeIncomplete: false, indexOffset: 0n, maxPayments: BigInt(max), reversed: true, creationDateEnd: 0n, creationDateStart: 0n }) return res.response } async GetPayment(paymentIndex: number) { - this.log("Getting payment") + this.log(DEBUG, "Getting payment") if (paymentIndex === 0) { throw new Error("payment index starts from 1") } @@ -667,11 +655,10 @@ export default class { } async GetLatestPaymentIndex(from = 0) { - this.log("Getting latest payment index") + this.log(DEBUG, "Getting latest payment index") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return from } - // console.log("Getting latest payment index") let indexOffset = BigInt(from) while (true) { const res = await this.lightning.listPayments({ countTotalPayments: false, includeIncomplete: false, indexOffset, maxPayments: 0n, reversed: false, creationDateEnd: 0n, creationDateStart: 0n }, DeadLineMetadata()) @@ -683,7 +670,7 @@ export default class { } async ConnectPeer(addr: { pubkey: string, host: string }) { - this.log("Connecting to peer") + this.log(DEBUG, "Connecting to peer") const res = await this.lightning.connectPeer({ addr, perm: true, @@ -693,7 +680,7 @@ export default class { } async GetPaymentFromHash(paymentHash: string): Promise { - this.log("Getting payment from hash") + this.log(DEBUG, "Getting payment from hash") const abortController = new AbortController() const stream = this.router.trackPaymentV2({ paymentHash: Buffer.from(paymentHash, 'hex'), @@ -720,7 +707,7 @@ export default class { } async AddPeer(pub: string, host: string, port: number) { - this.log("Adding peer") + this.log(DEBUG, "Adding peer") const res = await this.lightning.connectPeer({ addr: { pubkey: pub, @@ -733,19 +720,19 @@ export default class { } async ListPeers() { - this.log("Listing peers") + this.log(DEBUG, "Listing peers") const res = await this.lightning.listPeers({ latestError: true }, DeadLineMetadata()) return res.response } async OpenChannel(destination: string, closeAddress: string, fundingAmount: number, pushSats: number, satsPerVByte: number): Promise { - this.log("Opening channel") + this.log(DEBUG, "Opening channel") const abortController = new AbortController() const req = OpenChannelReq(destination, closeAddress, fundingAmount, pushSats, satsPerVByte) const stream = this.lightning.openChannel(req, { abort: abortController.signal }) return new Promise((res, rej) => { stream.responses.onMessage(message => { - console.log("message", message) + this.log(DEBUG, "open channel message", message) switch (message.update.oneofKind) { case 'chanPending': res(message) @@ -753,14 +740,14 @@ export default class { } }) stream.responses.onError(error => { - console.log("error", error) + this.log(ERROR, "open channel error", error) rej(error) }) }) } async CloseChannel(fundingTx: string, outputIndex: number, force: boolean, satPerVByte: number): Promise { - this.log("Closing channel") + this.log(DEBUG, "Closing channel") const stream = this.lightning.closeChannel({ deliveryAddress: "", force: force, @@ -779,7 +766,7 @@ export default class { }, DeadLineMetadata()) return new Promise((res, rej) => { stream.responses.onMessage(message => { - console.log("message", message) + this.log(DEBUG, "close channel message", message) switch (message.update.oneofKind) { case 'closePending': res(message.update.closePending) @@ -787,7 +774,7 @@ export default class { } }) stream.responses.onError(error => { - console.log("error", error) + this.log(ERROR, "close channel error", error) rej(error) }) }) From f8fe946b406830368008d3eaa042bc09216f690b Mon Sep 17 00:00:00 2001 From: boufni95 Date: Tue, 24 Feb 2026 18:36:03 +0000 Subject: [PATCH 33/49] tx swaps polish --- datasource.js | 21 +++++--- proto/autogenerated/client.md | 9 ++-- proto/autogenerated/go/types.go | 15 +++--- proto/autogenerated/ts/types.ts | 49 ++++++++++------- proto/service/structs.proto | 10 ++-- src/services/lnd/swaps/swaps.ts | 52 +++++++++++-------- src/services/main/paymentManager.ts | 2 + .../storage/db/serializationHelpers.ts | 25 ++++++--- src/services/storage/db/storageInterface.ts | 16 +++--- .../storage/entity/TransactionSwap.ts | 6 +++ .../1771878683383-tx_swap_timestamps.ts | 20 +++++++ src/services/storage/migrations/runner.ts | 23 ++++---- src/services/storage/paymentStorage.ts | 11 ++++ 13 files changed, 174 insertions(+), 85 deletions(-) create mode 100644 src/services/storage/migrations/1771878683383-tx_swap_timestamps.ts diff --git a/datasource.js b/datasource.js index 0ad041a1..4badca25 100644 --- a/datasource.js +++ b/datasource.js @@ -37,8 +37,9 @@ import { InvoiceSwap } from "./build/src/services/storage/entity/InvoiceSwap.js" import { Initial1703170309875 } from './build/src/services/storage/migrations/1703170309875-initial.js' import { LspOrder1718387847693 } from './build/src/services/storage/migrations/1718387847693-lsp_order.js' -import { LndNodeInfo1720187506189 } from './build/src/services/storage/migrations/1720187506189-lnd_node_info.js' import { LiquidityProvider1719335699480 } from './build/src/services/storage/migrations/1719335699480-liquidity_provider.js' +import { LndNodeInfo1720187506189 } from './build/src/services/storage/migrations/1720187506189-lnd_node_info.js' +import { TrackedProvider1720814323679 } from './build/src/services/storage/migrations/1720814323679-tracked_provider.js' import { CreateInviteTokenTable1721751414878 } from './build/src/services/storage/migrations/1721751414878-create_invite_token_table.js' import { PaymentIndex1721760297610 } from './build/src/services/storage/migrations/1721760297610-payment_index.js' import { DebitAccess1726496225078 } from './build/src/services/storage/migrations/1726496225078-debit_access.js' @@ -47,6 +48,7 @@ import { DebitToPub1727105758354 } from './build/src/services/storage/migrations import { UserCbUrl1727112281043 } from './build/src/services/storage/migrations/1727112281043-user_cb_url.js' import { UserOffer1733502626042 } from './build/src/services/storage/migrations/1733502626042-user_offer.js' import { ManagementGrant1751307732346 } from './build/src/services/storage/migrations/1751307732346-management_grant.js' +import { ManagementGrantBanned1751989251513 } from './build/src/services/storage/migrations/1751989251513-management_grant_banned.js' import { InvoiceCallbackUrls1752425992291 } from './build/src/services/storage/migrations/1752425992291-invoice_callback_urls.js' import { OldSomethingLeftover1753106599604 } from './build/src/services/storage/migrations/1753106599604-old_something_leftover.js' import { UserReceivingInvoiceIdx1753109184611 } from './build/src/services/storage/migrations/1753109184611-user_receiving_invoice_idx.js' @@ -67,16 +69,19 @@ import { SwapTimestamps1771347307798 } from './build/src/services/storage/migrat + export default new DataSource({ type: "better-sqlite3", database: "db.sqlite", // logging: true, - migrations: [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, CreateInviteTokenTable1721751414878, - PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, - UserOffer1733502626042, ManagementGrant1751307732346, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, - AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, - ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798], + migrations: [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, + TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, + DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, + InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, + UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, + InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798 + ], entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment, @@ -84,4 +89,4 @@ export default new DataSource({ TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings, TransactionSwap, InvoiceSwap], // synchronize: true, }) -//npx typeorm migration:generate ./src/services/storage/migrations/swap_timestamps -d ./datasource.js \ No newline at end of file +//npx typeorm migration:generate ./src/services/storage/migrations/tx_swap_timestamps -d ./datasource.js \ No newline at end of file diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index d5b9baab..106804e1 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -1740,7 +1740,10 @@ The nostr server will send back a message response, and inside the body there wi ### TransactionSwapQuote - __chain_fee_sats__: _number_ + - __completed_at_unix__: _number_ + - __expires_at_block_height__: _number_ - __invoice_amount_sats__: _number_ + - __paid_at_unix__: _number_ - __service_fee_sats__: _number_ - __service_url__: _string_ - __swap_fee_sats__: _number_ @@ -1754,13 +1757,13 @@ The nostr server will send back a message response, and inside the body there wi - __transaction_amount_sats__: _number_ ### TxSwapOperation - - __address_paid__: _string_ + - __address_paid__: _string_ *this field is optional - __failure_reason__: _string_ *this field is optional - __operation_payment__: _[UserOperation](#UserOperation)_ *this field is optional - - __swap_operation_id__: _string_ + - __quote__: _[TransactionSwapQuote](#TransactionSwapQuote)_ + - __tx_id__: _string_ *this field is optional ### TxSwapsList - - __quotes__: ARRAY of: _[TransactionSwapQuote](#TransactionSwapQuote)_ - __swaps__: ARRAY of: _[TxSwapOperation](#TxSwapOperation)_ ### UpdateChannelPolicyRequest diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index d0efb918..76c662cf 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -717,7 +717,10 @@ type SingleMetricReq struct { } type TransactionSwapQuote struct { Chain_fee_sats int64 `json:"chain_fee_sats"` + Completed_at_unix int64 `json:"completed_at_unix"` + Expires_at_block_height int64 `json:"expires_at_block_height"` Invoice_amount_sats int64 `json:"invoice_amount_sats"` + Paid_at_unix int64 `json:"paid_at_unix"` Service_fee_sats int64 `json:"service_fee_sats"` Service_url string `json:"service_url"` Swap_fee_sats int64 `json:"swap_fee_sats"` @@ -731,14 +734,14 @@ type TransactionSwapRequest struct { Transaction_amount_sats int64 `json:"transaction_amount_sats"` } type TxSwapOperation struct { - Address_paid string `json:"address_paid"` - Failure_reason string `json:"failure_reason"` - Operation_payment *UserOperation `json:"operation_payment"` - Swap_operation_id string `json:"swap_operation_id"` + Address_paid string `json:"address_paid"` + Failure_reason string `json:"failure_reason"` + Operation_payment *UserOperation `json:"operation_payment"` + Quote *TransactionSwapQuote `json:"quote"` + Tx_id string `json:"tx_id"` } type TxSwapsList struct { - Quotes []TransactionSwapQuote `json:"quotes"` - Swaps []TxSwapOperation `json:"swaps"` + Swaps []TxSwapOperation `json:"swaps"` } type UpdateChannelPolicyRequest struct { Policy *ChannelPolicy `json:"policy"` diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index f62fea43..64ee28f9 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -4232,7 +4232,10 @@ export const SingleMetricReqValidate = (o?: SingleMetricReq, opts: SingleMetricR export type TransactionSwapQuote = { chain_fee_sats: number + completed_at_unix: number + expires_at_block_height: number invoice_amount_sats: number + paid_at_unix: number service_fee_sats: number service_url: string swap_fee_sats: number @@ -4243,7 +4246,10 @@ export const TransactionSwapQuoteOptionalFields: [] = [] export type TransactionSwapQuoteOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] chain_fee_sats_CustomCheck?: (v: number) => boolean + completed_at_unix_CustomCheck?: (v: number) => boolean + expires_at_block_height_CustomCheck?: (v: number) => boolean invoice_amount_sats_CustomCheck?: (v: number) => boolean + paid_at_unix_CustomCheck?: (v: number) => boolean service_fee_sats_CustomCheck?: (v: number) => boolean service_url_CustomCheck?: (v: string) => boolean swap_fee_sats_CustomCheck?: (v: number) => boolean @@ -4257,9 +4263,18 @@ export const TransactionSwapQuoteValidate = (o?: TransactionSwapQuote, opts: Tra if (typeof o.chain_fee_sats !== 'number') return new Error(`${path}.chain_fee_sats: is not a number`) if (opts.chain_fee_sats_CustomCheck && !opts.chain_fee_sats_CustomCheck(o.chain_fee_sats)) return new Error(`${path}.chain_fee_sats: custom check failed`) + if (typeof o.completed_at_unix !== 'number') return new Error(`${path}.completed_at_unix: is not a number`) + if (opts.completed_at_unix_CustomCheck && !opts.completed_at_unix_CustomCheck(o.completed_at_unix)) return new Error(`${path}.completed_at_unix: custom check failed`) + + if (typeof o.expires_at_block_height !== 'number') return new Error(`${path}.expires_at_block_height: is not a number`) + if (opts.expires_at_block_height_CustomCheck && !opts.expires_at_block_height_CustomCheck(o.expires_at_block_height)) return new Error(`${path}.expires_at_block_height: custom check failed`) + if (typeof o.invoice_amount_sats !== 'number') return new Error(`${path}.invoice_amount_sats: is not a number`) if (opts.invoice_amount_sats_CustomCheck && !opts.invoice_amount_sats_CustomCheck(o.invoice_amount_sats)) return new Error(`${path}.invoice_amount_sats: custom check failed`) + if (typeof o.paid_at_unix !== 'number') return new Error(`${path}.paid_at_unix: is not a number`) + if (opts.paid_at_unix_CustomCheck && !opts.paid_at_unix_CustomCheck(o.paid_at_unix)) return new Error(`${path}.paid_at_unix: custom check failed`) + if (typeof o.service_fee_sats !== 'number') return new Error(`${path}.service_fee_sats: is not a number`) if (opts.service_fee_sats_CustomCheck && !opts.service_fee_sats_CustomCheck(o.service_fee_sats)) return new Error(`${path}.service_fee_sats: custom check failed`) @@ -4320,25 +4335,27 @@ export const TransactionSwapRequestValidate = (o?: TransactionSwapRequest, opts: } export type TxSwapOperation = { - address_paid: string + address_paid?: string failure_reason?: string operation_payment?: UserOperation - swap_operation_id: string + quote: TransactionSwapQuote + tx_id?: string } -export type TxSwapOperationOptionalField = 'failure_reason' | 'operation_payment' -export const TxSwapOperationOptionalFields: TxSwapOperationOptionalField[] = ['failure_reason', 'operation_payment'] +export type TxSwapOperationOptionalField = 'address_paid' | 'failure_reason' | 'operation_payment' | 'tx_id' +export const TxSwapOperationOptionalFields: TxSwapOperationOptionalField[] = ['address_paid', 'failure_reason', 'operation_payment', 'tx_id'] export type TxSwapOperationOptions = OptionsBaseMessage & { checkOptionalsAreSet?: TxSwapOperationOptionalField[] - address_paid_CustomCheck?: (v: string) => boolean + address_paid_CustomCheck?: (v?: string) => boolean failure_reason_CustomCheck?: (v?: string) => boolean operation_payment_Options?: UserOperationOptions - swap_operation_id_CustomCheck?: (v: string) => boolean + quote_Options?: TransactionSwapQuoteOptions + tx_id_CustomCheck?: (v?: string) => boolean } export const TxSwapOperationValidate = (o?: TxSwapOperation, opts: TxSwapOperationOptions = {}, path: string = 'TxSwapOperation::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') - if (typeof o.address_paid !== 'string') return new Error(`${path}.address_paid: is not a string`) + if ((o.address_paid || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('address_paid')) && typeof o.address_paid !== 'string') return new Error(`${path}.address_paid: is not a string`) if (opts.address_paid_CustomCheck && !opts.address_paid_CustomCheck(o.address_paid)) return new Error(`${path}.address_paid: custom check failed`) if ((o.failure_reason || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('failure_reason')) && typeof o.failure_reason !== 'string') return new Error(`${path}.failure_reason: is not a string`) @@ -4350,21 +4367,22 @@ export const TxSwapOperationValidate = (o?: TxSwapOperation, opts: TxSwapOperati } - if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) - if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) + const quoteErr = TransactionSwapQuoteValidate(o.quote, opts.quote_Options, `${path}.quote`) + if (quoteErr !== null) return quoteErr + + + if ((o.tx_id || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('tx_id')) && typeof o.tx_id !== 'string') return new Error(`${path}.tx_id: is not a string`) + if (opts.tx_id_CustomCheck && !opts.tx_id_CustomCheck(o.tx_id)) return new Error(`${path}.tx_id: custom check failed`) return null } export type TxSwapsList = { - quotes: TransactionSwapQuote[] swaps: TxSwapOperation[] } export const TxSwapsListOptionalFields: [] = [] export type TxSwapsListOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] - quotes_ItemOptions?: TransactionSwapQuoteOptions - quotes_CustomCheck?: (v: TransactionSwapQuote[]) => boolean swaps_ItemOptions?: TxSwapOperationOptions swaps_CustomCheck?: (v: TxSwapOperation[]) => boolean } @@ -4372,13 +4390,6 @@ export const TxSwapsListValidate = (o?: TxSwapsList, opts: TxSwapsListOptions = 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') - if (!Array.isArray(o.quotes)) return new Error(`${path}.quotes: is not an array`) - for (let index = 0; index < o.quotes.length; index++) { - const quotesErr = TransactionSwapQuoteValidate(o.quotes[index], opts.quotes_ItemOptions, `${path}.quotes[${index}]`) - if (quotesErr !== null) return quotesErr - } - if (opts.quotes_CustomCheck && !opts.quotes_CustomCheck(o.quotes)) return new Error(`${path}.quotes: custom check failed`) - if (!Array.isArray(o.swaps)) return new Error(`${path}.swaps: is not an array`) for (let index = 0; index < o.swaps.length; index++) { const swapsErr = TxSwapOperationValidate(o.swaps[index], opts.swaps_ItemOptions, `${path}.swaps[${index}]`) diff --git a/proto/service/structs.proto b/proto/service/structs.proto index fb1f0867..694a6442 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -902,6 +902,10 @@ message TransactionSwapQuote { int64 chain_fee_sats = 5; int64 service_fee_sats = 7; string service_url = 8; + + int64 expires_at_block_height = 9; + int64 paid_at_unix = 10; + int64 completed_at_unix = 11; } message TransactionSwapQuoteList { @@ -914,15 +918,15 @@ message AdminTxSwapResponse { } message TxSwapOperation { - string swap_operation_id = 1; + TransactionSwapQuote quote = 1; optional UserOperation operation_payment = 2; optional string failure_reason = 3; - string address_paid = 4; + optional string address_paid = 4; + optional string tx_id = 5; } message TxSwapsList { repeated TxSwapOperation swaps = 1; - repeated TransactionSwapQuote quotes = 2; } message CumulativeFees { diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index c5abad05..dc1590b4 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -9,6 +9,7 @@ import { UserInvoicePayment } from '../../storage/entity/UserInvoicePayment.js'; import { ReverseSwaps, TransactionSwapData } from './reverseSwaps.js'; import { SubmarineSwaps, InvoiceSwapData } from './submarineSwaps.js'; import { InvoiceSwap } from '../../storage/entity/InvoiceSwap.js'; +import { TransactionSwap } from '../../storage/entity/TransactionSwap.js'; export class Swaps { @@ -271,32 +272,35 @@ export class Swaps { } } + private mapTransactionSwapQuote = (s: TransactionSwap, getServiceFee: (amt: number) => number): Types.TransactionSwapQuote => { + const serviceFee = getServiceFee(s.invoice_amount) + return { + swap_operation_id: s.swap_operation_id, + transaction_amount_sats: s.transaction_amount, + invoice_amount_sats: s.invoice_amount, + chain_fee_sats: s.chain_fee_sats, + service_fee_sats: serviceFee, + swap_fee_sats: s.swap_fee_sats, + expires_at_block_height: s.timeout_block_height, + service_url: s.service_url, + paid_at_unix: s.paid_at_unix, + completed_at_unix: s.completed_at_unix, + } + } + ListTxSwaps = async (appUserId: string, payments: UserInvoicePayment[], newOp: (p: UserInvoicePayment) => Types.UserOperation | undefined, getServiceFee: (amt: number) => number): Promise => { const completedSwaps = await this.storage.paymentStorage.ListCompletedTxSwaps(appUserId, payments) const pendingSwaps = await this.storage.paymentStorage.ListPendingTransactionSwaps(appUserId) + const quotes: Types.TxSwapOperation[] = pendingSwaps.map(s => ({ quote: this.mapTransactionSwapQuote(s, getServiceFee) })) + const swaps: Types.TxSwapOperation[] = completedSwaps.map(s => ({ + quote: this.mapTransactionSwapQuote(s.swap, getServiceFee), + operation_payment: s.payment ? newOp(s.payment) : undefined, + address_paid: s.swap.address_paid, + tx_id: s.swap.tx_id, + failure_reason: s.swap.failure_reason, + })) return { - swaps: completedSwaps.map(s => { - const p = s.payment - const op = p ? newOp(p) : undefined - return { - operation_payment: op, - swap_operation_id: s.swap.swap_operation_id, - address_paid: s.swap.address_paid, - failure_reason: s.swap.failure_reason, - } - }), - quotes: pendingSwaps.map(s => { - const serviceFee = getServiceFee(s.invoice_amount) - return { - swap_operation_id: s.swap_operation_id, - invoice_amount_sats: s.invoice_amount, - transaction_amount_sats: s.transaction_amount, - chain_fee_sats: s.chain_fee_sats, - service_fee_sats: serviceFee, - swap_fee_sats: s.swap_fee_sats, - service_url: s.service_url, - } - }) + swaps: swaps.concat(quotes), } } GetTxSwapQuotes = async (appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise => { @@ -364,6 +368,9 @@ export class Swaps { chain_fee_sats: minerFee, service_fee_sats: serviceFee, service_url: swapper.getHttpUrl(), + expires_at_block_height: res.createdResponse.timeoutBlockHeight, + paid_at_unix: newSwap.paid_at_unix, + completed_at_unix: newSwap.completed_at_unix, } } @@ -411,6 +418,7 @@ export class Swaps { swapResult = result }) try { + await this.storage.paymentStorage.SetTransactionSwapPaid(swapOpId) await payInvoice(txSwap.invoice, txSwap.invoice_amount) if (!swapResult.ok) { this.log("invoice payment successful, but swap failed") diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 38c7658b..617641f4 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -625,7 +625,9 @@ export default class { } async ListTxSwaps(ctx: Types.UserContext): Promise { + console.log("listing tx swaps", { appUserId: ctx.app_user_id }) const payments = await this.storage.paymentStorage.ListTxSwapPayments(ctx.app_user_id) + console.log("payments", payments.length) const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const isManagedUser = ctx.user_id !== app.owner.user_id return this.swaps.ListTxSwaps(ctx.app_user_id, payments, p => { diff --git a/src/services/storage/db/serializationHelpers.ts b/src/services/storage/db/serializationHelpers.ts index eb27f678..ee19201f 100644 --- a/src/services/storage/db/serializationHelpers.ts +++ b/src/services/storage/db/serializationHelpers.ts @@ -8,10 +8,19 @@ type SerializedFindOperator = { } export function serializeFindOperator(operator: FindOperator): SerializedFindOperator { + let value: any; + if (Array.isArray(operator['value']) && operator['type'] !== 'between') { + value = operator['value'].map(serializeFindOperator); + } else if ((operator as any).child !== undefined) { + // Not(IsNull()) etc.: TypeORM's .value getter unwraps nested FindOperators, so we'd lose the inner operator. Use .child to serialize the nested operator. + value = serializeFindOperator((operator as any).child); + } else { + value = operator['value']; + } return { _type: 'FindOperator', type: operator['type'], - value: (Array.isArray(operator['value']) && operator['type'] !== 'between') ? operator["value"].map(serializeFindOperator) : operator["value"], + value, }; } @@ -51,7 +60,8 @@ export function deserializeFindOperator(serialized: SerializedFindOperator): Fin } } -export function serializeRequest(r: object): T { +export function serializeRequest(r: object, debug = false): T { + if (debug) console.log("serializeRequest", r) if (!r || typeof r !== 'object') { return r; } @@ -61,23 +71,24 @@ export function serializeRequest(r: object): T { } if (Array.isArray(r)) { - return r.map(item => serializeRequest(item)) as any; + return r.map(item => serializeRequest(item, debug)) as any; } const result: any = {}; for (const [key, value] of Object.entries(r)) { - result[key] = serializeRequest(value); + result[key] = serializeRequest(value, debug); } return result; } -export function deserializeRequest(r: object): T { +export function deserializeRequest(r: object, debug = false): T { + if (debug) console.log("deserializeRequest", r) if (!r || typeof r !== 'object') { return r; } if (Array.isArray(r)) { - return r.map(item => deserializeRequest(item)) as any; + return r.map(item => deserializeRequest(item, debug)) as any; } if (r && typeof r === 'object' && (r as any)._type === 'FindOperator') { @@ -86,7 +97,7 @@ export function deserializeRequest(r: object): T { const result: any = {}; for (const [key, value] of Object.entries(r)) { - result[key] = deserializeRequest(value); + result[key] = deserializeRequest(value, debug); } return result; } diff --git a/src/services/storage/db/storageInterface.ts b/src/services/storage/db/storageInterface.ts index 85b757f5..976ec20d 100644 --- a/src/services/storage/db/storageInterface.ts +++ b/src/services/storage/db/storageInterface.ts @@ -104,9 +104,10 @@ export class StorageInterface extends EventEmitter { return this.handleOp(findOp) } - Find(entity: DBNames, q: QueryOptions, txId?: string): Promise { + Find(entity: DBNames, q: QueryOptions, txId?: string, debug = false): Promise { + if (debug) console.log("Find", { entity }) const opId = Math.random().toString() - const findOp: FindOperation = { type: 'find', entity, opId, q, txId } + const findOp: FindOperation = { type: 'find', entity, opId, q, txId, debug } return this.handleOp(findOp) } @@ -166,15 +167,16 @@ export class StorageInterface extends EventEmitter { } private handleOp(op: IStorageOperation): Promise { - if (this.debug) console.log('handleOp', op) + if (this.debug || op.debug) console.log('handleOp', op) this.checkConnected() return new Promise((resolve, reject) => { const responseHandler = (response: OperationResponse) => { - if (this.debug) console.log('responseHandler', response) + if (this.debug || op.debug) console.log('responseHandler', response) if (!response.success) { reject(new Error(response.error)); return } + if (this.debug || op.debug) console.log("response", response, op) if (response.type !== op.type) { reject(new Error('Invalid storage response type')); return @@ -186,12 +188,12 @@ export class StorageInterface extends EventEmitter { }) } - private serializeOperation(operation: IStorageOperation): IStorageOperation { + private serializeOperation(operation: IStorageOperation, debug = false): IStorageOperation { const serialized = { ...operation }; if ('q' in serialized) { - (serialized as any).q = serializeRequest((serialized as any).q); + (serialized as any).q = serializeRequest((serialized as any).q, debug); } - if (this.debug) { + if (this.debug || debug) { serialized.debug = true } return serialized; diff --git a/src/services/storage/entity/TransactionSwap.ts b/src/services/storage/entity/TransactionSwap.ts index 7403e2a1..d2a99f52 100644 --- a/src/services/storage/entity/TransactionSwap.ts +++ b/src/services/storage/entity/TransactionSwap.ts @@ -60,6 +60,12 @@ export class TransactionSwap { @Column({ default: "" }) tx_id: string + @Column({ default: 0 }) + completed_at_unix: number + + @Column({ default: 0 }) + paid_at_unix: number + @Column({ default: "" }) address_paid: string diff --git a/src/services/storage/migrations/1771878683383-tx_swap_timestamps.ts b/src/services/storage/migrations/1771878683383-tx_swap_timestamps.ts new file mode 100644 index 00000000..be9ccade --- /dev/null +++ b/src/services/storage/migrations/1771878683383-tx_swap_timestamps.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class TxSwapTimestamps1771878683383 implements MigrationInterface { + name = 'TxSwapTimestamps1771878683383' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_transaction_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "lockup_address" varchar NOT NULL, "refund_public_key" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "preimage" varchar NOT NULL, "ephemeral_public_key" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "address_paid" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "completed_at_unix" integer NOT NULL DEFAULT (0), "paid_at_unix" integer NOT NULL DEFAULT (0))`); + await queryRunner.query(`INSERT INTO "temporary_transaction_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "lockup_address", "refund_public_key", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "preimage", "ephemeral_public_key", "ephemeral_private_key", "used", "failure_reason", "tx_id", "created_at", "updated_at", "address_paid", "service_url") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "lockup_address", "refund_public_key", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "preimage", "ephemeral_public_key", "ephemeral_private_key", "used", "failure_reason", "tx_id", "created_at", "updated_at", "address_paid", "service_url" FROM "transaction_swap"`); + await queryRunner.query(`DROP TABLE "transaction_swap"`); + await queryRunner.query(`ALTER TABLE "temporary_transaction_swap" RENAME TO "transaction_swap"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transaction_swap" RENAME TO "temporary_transaction_swap"`); + await queryRunner.query(`CREATE TABLE "transaction_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "lockup_address" varchar NOT NULL, "refund_public_key" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "preimage" varchar NOT NULL, "ephemeral_public_key" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "address_paid" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''))`); + await queryRunner.query(`INSERT INTO "transaction_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "lockup_address", "refund_public_key", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "preimage", "ephemeral_public_key", "ephemeral_private_key", "used", "failure_reason", "tx_id", "created_at", "updated_at", "address_paid", "service_url") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "lockup_address", "refund_public_key", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "preimage", "ephemeral_public_key", "ephemeral_private_key", "used", "failure_reason", "tx_id", "created_at", "updated_at", "address_paid", "service_url" FROM "temporary_transaction_swap"`); + await queryRunner.query(`DROP TABLE "temporary_transaction_swap"`); + } + +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index 1b5537d6..c78af2eb 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -1,28 +1,21 @@ 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' import { LiquidityProvider1719335699480 } from './1719335699480-liquidity_provider.js' import { LndNodeInfo1720187506189 } from './1720187506189-lnd_node_info.js' import { TrackedProvider1720814323679 } from './1720814323679-tracked_provider.js' import { CreateInviteTokenTable1721751414878 } from "./1721751414878-create_invite_token_table.js" import { PaymentIndex1721760297610 } from './1721760297610-payment_index.js' -import { HtlcCount1724266887195 } from './1724266887195-htlc_count.js' -import { BalanceEvents1724860966825 } from './1724860966825-balance_events.js' import { DebitAccess1726496225078 } from './1726496225078-debit_access.js' import { DebitAccessFixes1726685229264 } from './1726685229264-debit_access_fixes.js' import { DebitToPub1727105758354 } from './1727105758354-debit_to_pub.js' import { UserCbUrl1727112281043 } from './1727112281043-user_cb_url.js' -import { RootOps1732566440447 } from './1732566440447-root_ops.js' import { UserOffer1733502626042 } from './1733502626042-user_offer.js' -import { RootOpsTime1745428134124 } from './1745428134124-root_ops_time.js' -import { ChannelEvents1750777346411 } from './1750777346411-channel_events.js' import { ManagementGrant1751307732346 } from './1751307732346-management_grant.js' import { ManagementGrantBanned1751989251513 } from './1751989251513-management_grant_banned.js' import { InvoiceCallbackUrls1752425992291 } from './1752425992291-invoice_callback_urls.js' -import { AppUserDevice1753285173175 } from './1753285173175-app_user_device.js' import { OldSomethingLeftover1753106599604 } from './1753106599604-old_something_leftover.js' import { UserReceivingInvoiceIdx1753109184611 } from './1753109184611-user_receiving_invoice_idx.js' +import { AppUserDevice1753285173175 } from './1753285173175-app_user_device.js' import { UserAccess1759426050669 } from './1759426050669-user_access.js' import { AddBlindToUserOffer1760000000000 } from './1760000000000-add_blind_to_user_offer.js' import { ApplicationAvatarUrl1761000001000 } from './1761000001000-application_avatar_url.js' @@ -32,11 +25,20 @@ import { TxSwapAddress1764779178945 } from './1764779178945-tx_swap_address.js' import { ClinkRequester1765497600000 } from './1765497600000-clink_requester.js' import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_provider_height.js' import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' - import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js' import { InvoiceSwapsFixes1769805357459 } from './1769805357459-invoice_swaps_fixes.js' import { ApplicationUserTopicId1770038768784 } from './1770038768784-application_user_topic_id.js' import { SwapTimestamps1771347307798 } from './1771347307798-swap_timestamps.js' +import { TxSwapTimestamps1771878683383 } from './1771878683383-tx_swap_timestamps.js' + +import { LndMetrics1703170330183 } from './1703170330183-lnd_metrics.js' +import { ChannelRouting1709316653538 } from './1709316653538-channel_routing.js' +import { HtlcCount1724266887195 } from './1724266887195-htlc_count.js' +import { BalanceEvents1724860966825 } from './1724860966825-balance_events.js' +import { RootOps1732566440447 } from './1732566440447-root_ops.js' +import { RootOpsTime1745428134124 } from './1745428134124-root_ops_time.js' +import { ChannelEvents1750777346411 } from './1750777346411-channel_events.js' + import { RootOpPending1771524665409 } from './1771524665409-root_op_pending.js' @@ -48,7 +50,8 @@ export const allMigrations = [Initial1703170309875, LspOrder1718387847693, Liqui InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, - InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798] + InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798, + TxSwapTimestamps1771878683383] export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 785f9909..fd6e6b30 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -473,19 +473,30 @@ export default class { return this.dbs.FindOne('TransactionSwap', { where: { swap_operation_id: swapOperationId, used: false, app_user_id: appUserId } }, txId) } + async SetTransactionSwapPaid(swapOperationId: string, txId?: string) { + const now = Math.floor(Date.now() / 1000) + return this.dbs.Update('TransactionSwap', { swap_operation_id: swapOperationId }, { + paid_at_unix: now, + }, txId) + } + async FinalizeTransactionSwap(swapOperationId: string, address: string, chainTxId: string, txId?: string) { + const now = Math.floor(Date.now() / 1000) return this.dbs.Update('TransactionSwap', { swap_operation_id: swapOperationId }, { used: true, tx_id: chainTxId, address_paid: address, + completed_at_unix: now, }, txId) } async FailTransactionSwap(swapOperationId: string, address: string, failureReason: string, txId?: string) { + const now = Math.floor(Date.now() / 1000) return this.dbs.Update('TransactionSwap', { swap_operation_id: swapOperationId }, { used: true, failure_reason: failureReason, address_paid: address, + completed_at_unix: now, }, txId) } From f1d0c521b8553a0329f0c2dd6679803358160ea0 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 25 Feb 2026 11:29:57 -0500 Subject: [PATCH 34/49] Add NIP-47 (Nostr Wallet Connect) support Implements NWC protocol alongside the existing CLINK/Ndebit system, allowing any NWC-compatible wallet (Alby, Amethyst, Damus, etc.) to connect to a Lightning Pub node. Supported NIP-47 methods: pay_invoice, make_invoice, get_balance, get_info, lookup_invoice, list_transactions. New files: - NwcConnection entity with per-connection spending limits - NwcStorage for connection CRUD operations - NwcManager for NIP-47 request handling and connection management - Database migration for nwc_connection table Modified files: - nostrPool: subscribe to kind 23194 events - nostrMiddleware: route kind 23194 to NwcManager - main/index: wire NwcManager, publish kind 13194 info events - storage: register NwcConnection entity and NwcStorage Co-Authored-By: Claude Opus 4.6 --- src/nostrMiddleware.ts | 7 + src/services/main/index.ts | 9 + src/services/main/nwcManager.ts | 346 ++++++++++++++++++ src/services/nostr/nostrPool.ts | 2 +- src/services/storage/db/db.ts | 2 + src/services/storage/entity/NwcConnection.ts | 37 ++ src/services/storage/index.ts | 3 + .../1770000000000-nwc_connection.ts | 17 + src/services/storage/migrations/runner.ts | 3 +- src/services/storage/nwcStorage.ts | 49 +++ 10 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 src/services/main/nwcManager.ts create mode 100644 src/services/storage/entity/NwcConnection.ts create mode 100644 src/services/storage/migrations/1770000000000-nwc_connection.ts create mode 100644 src/services/storage/nwcStorage.ts diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index 034dbce8..250543c7 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -79,6 +79,13 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett const nmanageReq = j as NmanageRequest mainHandler.managementManager.handleRequest(nmanageReq, event); return; + } else if (event.kind === 23194) { + if (event.relayConstraint === 'provider') { + log("got NWC request on provider only relay, ignoring") + return + } + mainHandler.nwcManager.handleNwcRequest(event.content, event) + return } if (!j.rpcName) { if (event.relayConstraint === 'service') { diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 3d885681..df943d62 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -26,6 +26,7 @@ import { OfferManager } from "./offerManager.js" import { parse } from "uri-template" import webRTC from "../webRTC/index.js" import { ManagementManager } from "./managementManager.js" +import { NwcManager } from "./nwcManager.js" import { Agent } from "https" import { NotificationsManager } from "./notificationsManager.js" import { ApplicationUser } from '../storage/entity/ApplicationUser.js' @@ -58,6 +59,7 @@ export default class { debitManager: DebitManager offerManager: OfferManager managementManager: ManagementManager + nwcManager: NwcManager utils: Utils rugPullTracker: RugPullTracker unlocker: Unlocker @@ -88,6 +90,7 @@ export default class { this.debitManager = new DebitManager(this.storage, this.lnd, this.applicationManager) this.offerManager = new OfferManager(this.storage, this.settings, this.lnd, this.applicationManager, this.productManager, this.liquidityManager) this.managementManager = new ManagementManager(this.storage, this.settings) + this.nwcManager = new NwcManager(this.storage, this.settings, this.lnd, this.applicationManager) this.notificationsManager = new NotificationsManager(this.settings) //this.webRTC = new webRTC(this.storage, this.utils) } @@ -103,6 +106,9 @@ export default class { StartBeacons() { this.applicationManager.StartAppsServiceBeacon((app, fees) => { this.UpdateBeacon(app, { type: 'service', name: app.name, avatarUrl: app.avatar_url, fees }) + if (app.nostr_public_key) { + this.nwcManager.publishNwcInfo(app.app_id, app.nostr_public_key) + } }) } @@ -504,6 +510,9 @@ export default class { const fees = this.paymentManager.GetFees() for (const app of apps) { await this.UpdateBeacon(app, { type: 'service', name: app.name, avatarUrl: app.avatar_url, nextRelay, fees }) + if (app.nostr_public_key) { + this.nwcManager.publishNwcInfo(app.app_id, app.nostr_public_key) + } } const defaultNames = ['wallet', 'wallet-test', this.settings.getSettings().serviceSettings.defaultAppName] diff --git a/src/services/main/nwcManager.ts b/src/services/main/nwcManager.ts new file mode 100644 index 00000000..d1800b1c --- /dev/null +++ b/src/services/main/nwcManager.ts @@ -0,0 +1,346 @@ +import { generateSecretKey, getPublicKey, UnsignedEvent } from 'nostr-tools' +import { bytesToHex } from '@noble/hashes/utils' +import ApplicationManager from "./applicationManager.js" +import Storage from '../storage/index.js' +import LND from "../lnd/lnd.js" +import { ERROR, getLogger } from "../helpers/logger.js" +import { NostrEvent } from '../nostr/nostrPool.js' +import SettingsManager from "./settingsManager.js" + +type NwcRequest = { + method: string + params: Record +} + +type NwcResponse = { + result_type: string + error?: { code: string, message: string } + result?: Record +} + +const NWC_REQUEST_KIND = 23194 +const NWC_RESPONSE_KIND = 23195 +const NWC_INFO_KIND = 13194 + +const SUPPORTED_METHODS = [ + 'pay_invoice', + 'make_invoice', + 'get_balance', + 'get_info', + 'lookup_invoice', + 'list_transactions', +] + +const NWC_ERRORS = { + UNAUTHORIZED: 'UNAUTHORIZED', + RESTRICTED: 'RESTRICTED', + PAYMENT_FAILED: 'PAYMENT_FAILED', + QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', + NOT_FOUND: 'NOT_FOUND', + INTERNAL: 'INTERNAL', + OTHER: 'OTHER', +} as const + +const newNwcResponse = (content: string, event: { pub: string, id: string }): UnsignedEvent => { + return { + content, + created_at: Math.floor(Date.now() / 1000), + kind: NWC_RESPONSE_KIND, + pubkey: "", + tags: [ + ['p', event.pub], + ['e', event.id], + ], + } +} + +export class NwcManager { + applicationManager: ApplicationManager + storage: Storage + settings: SettingsManager + lnd: LND + logger = getLogger({ component: 'NwcManager' }) + + constructor(storage: Storage, settings: SettingsManager, lnd: LND, applicationManager: ApplicationManager) { + this.storage = storage + this.settings = settings + this.lnd = lnd + this.applicationManager = applicationManager + } + + handleNwcRequest = async (content: string, event: NostrEvent) => { + if (!this.storage.NostrSender().IsReady()) { + this.logger(ERROR, "Nostr sender not ready, dropping NWC request") + return + } + + let request: NwcRequest + try { + request = JSON.parse(content) + } catch { + this.logger(ERROR, "invalid NWC request JSON") + this.sendError(event, 'unknown', NWC_ERRORS.OTHER, 'Invalid request JSON') + return + } + + const { method, params } = request + if (!method) { + this.sendError(event, 'unknown', NWC_ERRORS.OTHER, 'Missing method') + return + } + + const connection = await this.storage.nwcStorage.GetConnection(event.appId, event.pub) + if (!connection) { + this.logger("NWC request from unknown client pubkey:", event.pub) + this.sendError(event, method, NWC_ERRORS.UNAUTHORIZED, 'Unknown connection') + return + } + + if (connection.expires_at > 0 && connection.expires_at < Math.floor(Date.now() / 1000)) { + this.logger("NWC connection expired for client:", event.pub) + this.sendError(event, method, NWC_ERRORS.UNAUTHORIZED, 'Connection expired') + return + } + + if (connection.permissions && connection.permissions.length > 0 && !connection.permissions.includes(method)) { + this.sendError(event, method, NWC_ERRORS.RESTRICTED, `Method ${method} not permitted`) + return + } + + try { + switch (method) { + case 'pay_invoice': + await this.handlePayInvoice(event, params, connection.app_user_id, connection.max_amount, connection.total_spent) + break + case 'make_invoice': + await this.handleMakeInvoice(event, params, connection.app_user_id) + break + case 'get_balance': + await this.handleGetBalance(event, connection.app_user_id) + break + case 'get_info': + await this.handleGetInfo(event) + break + case 'lookup_invoice': + await this.handleLookupInvoice(event, params) + break + case 'list_transactions': + await this.handleListTransactions(event, params, connection.app_user_id) + break + default: + this.sendError(event, method, NWC_ERRORS.NOT_IMPLEMENTED, `Method ${method} not supported`) + } + } catch (e: any) { + this.logger(ERROR, `NWC ${method} failed:`, e.message || e) + this.sendError(event, method, NWC_ERRORS.INTERNAL, e.message || 'Internal error') + } + } + + private handlePayInvoice = async (event: NostrEvent, params: Record, appUserId: string, maxAmount: number, totalSpent: number) => { + const { invoice } = params + if (!invoice) { + this.sendError(event, 'pay_invoice', NWC_ERRORS.OTHER, 'Missing invoice parameter') + return + } + + if (maxAmount > 0) { + const decoded = await this.lnd.DecodeInvoice(invoice) + const amountSats = decoded.numSatoshis + if (amountSats > 0 && (totalSpent + amountSats) > maxAmount) { + this.sendError(event, 'pay_invoice', NWC_ERRORS.QUOTA_EXCEEDED, 'Spending limit exceeded') + return + } + } + + try { + const paid = await this.applicationManager.PayAppUserInvoice(event.appId, { + amount: 0, + invoice, + user_identifier: appUserId, + debit_npub: event.pub, + }) + await this.storage.nwcStorage.IncrementTotalSpent(event.appId, event.pub, paid.amount_paid + paid.service_fee) + this.sendResult(event, 'pay_invoice', { preimage: paid.preimage }) + } catch (e: any) { + this.sendError(event, 'pay_invoice', NWC_ERRORS.PAYMENT_FAILED, e.message || 'Payment failed') + } + } + + private handleMakeInvoice = async (event: NostrEvent, params: Record, appUserId: string) => { + const amountMsats = params.amount + if (amountMsats === undefined || amountMsats === null) { + this.sendError(event, 'make_invoice', NWC_ERRORS.OTHER, 'Missing amount parameter') + return + } + const amountSats = Math.floor(amountMsats / 1000) + const description = params.description || '' + const expiry = params.expiry || undefined + + const result = await this.applicationManager.AddAppUserInvoice(event.appId, { + receiver_identifier: appUserId, + payer_identifier: '', + http_callback_url: '', + invoice_req: { + amountSats, + memo: description, + expiry, + }, + }) + + this.sendResult(event, 'make_invoice', { + type: 'incoming', + invoice: result.invoice, + description, + description_hash: '', + preimage: '', + payment_hash: '', + amount: amountMsats, + fees_paid: 0, + created_at: Math.floor(Date.now() / 1000), + expires_at: Math.floor(Date.now() / 1000) + (expiry || 3600), + metadata: {}, + }) + } + + private handleGetBalance = async (event: NostrEvent, appUserId: string) => { + const app = await this.storage.applicationStorage.GetApplication(event.appId) + const appUser = await this.storage.applicationStorage.GetApplicationUser(app, appUserId) + const balanceMsats = appUser.user.balance_sats * 1000 + this.sendResult(event, 'get_balance', { balance: balanceMsats }) + } + + private handleGetInfo = async (event: NostrEvent) => { + const info = await this.lnd.GetInfo() + this.sendResult(event, 'get_info', { + alias: info.alias, + color: '', + pubkey: info.identityPubkey, + network: 'mainnet', + block_height: info.blockHeight, + block_hash: info.blockHash, + methods: SUPPORTED_METHODS, + }) + } + + private handleLookupInvoice = async (event: NostrEvent, params: Record) => { + const { invoice, payment_hash } = params + if (!invoice && !payment_hash) { + this.sendError(event, 'lookup_invoice', NWC_ERRORS.OTHER, 'Missing invoice or payment_hash parameter') + return + } + + if (invoice) { + const found = await this.storage.paymentStorage.GetInvoiceOwner(invoice) + if (!found) { + this.sendError(event, 'lookup_invoice', NWC_ERRORS.NOT_FOUND, 'Invoice not found') + return + } + this.sendResult(event, 'lookup_invoice', { + type: 'incoming', + invoice: found.invoice, + description: '', + description_hash: '', + preimage: '', + payment_hash: '', + amount: found.paid_amount * 1000, + fees_paid: 0, + created_at: Math.floor(found.created_at.getTime() / 1000), + settled_at: found.paid_at_unix > 0 ? found.paid_at_unix : undefined, + metadata: {}, + }) + } else { + this.sendError(event, 'lookup_invoice', NWC_ERRORS.NOT_IMPLEMENTED, 'Lookup by payment_hash not supported') + } + } + + private handleListTransactions = async (event: NostrEvent, params: Record, appUserId: string) => { + const app = await this.storage.applicationStorage.GetApplication(event.appId) + const appUser = await this.storage.applicationStorage.GetApplicationUser(app, appUserId) + const from = params.from || 0 + const limit = Math.min(params.limit || 50, 50) + + const invoices = await this.storage.paymentStorage.GetUserInvoicesFlaggedAsPaid(appUser.user.serial_id, 0, from, limit) + const transactions = invoices.map(inv => ({ + type: 'incoming' as const, + invoice: inv.invoice, + description: '', + description_hash: '', + preimage: '', + payment_hash: '', + amount: inv.paid_amount * 1000, + fees_paid: 0, + created_at: Math.floor(inv.created_at.getTime() / 1000), + settled_at: inv.paid_at_unix > 0 ? inv.paid_at_unix : undefined, + metadata: {}, + })) + + this.sendResult(event, 'list_transactions', { transactions }) + } + + // --- Connection management methods --- + + createConnection = async (appId: string, appUserId: string, permissions?: string[], options?: { maxAmount?: number, expiresAt?: number }) => { + const secretBytes = generateSecretKey() + const clientPubkey = getPublicKey(secretBytes) + const secret = bytesToHex(secretBytes) + + await this.storage.nwcStorage.AddConnection({ + app_id: appId, + app_user_id: appUserId, + client_pubkey: clientPubkey, + permissions, + max_amount: options?.maxAmount, + expires_at: options?.expiresAt, + }) + + const app = await this.storage.applicationStorage.GetApplication(appId) + const relays = this.settings.getSettings().nostrRelaySettings.relays + const relay = relays[0] || '' + const uri = `nostr+walletconnect://${app.nostr_public_key}?relay=${encodeURIComponent(relay)}&secret=${secret}` + return { uri, clientPubkey, secret } + } + + listConnections = async (appUserId: string) => { + return this.storage.nwcStorage.GetUserConnections(appUserId) + } + + revokeConnection = async (appId: string, clientPubkey: string) => { + return this.storage.nwcStorage.DeleteConnectionByPubkey(appId, clientPubkey) + } + + // --- Kind 13194 info event --- + + publishNwcInfo = (appId: string, appPubkey: string) => { + const content = SUPPORTED_METHODS.join(' ') + const event: UnsignedEvent = { + content, + created_at: Math.floor(Date.now() / 1000), + kind: NWC_INFO_KIND, + pubkey: appPubkey, + tags: [], + } + this.storage.NostrSender().Send({ type: 'app', appId }, { type: 'event', event }) + } + + // --- Response helpers --- + + private sendResult = (event: NostrEvent, resultType: string, result: Record) => { + const response: NwcResponse = { result_type: resultType, result } + const e = newNwcResponse(JSON.stringify(response), event) + this.storage.NostrSender().Send( + { type: 'app', appId: event.appId }, + { type: 'event', event: e, encrypt: { toPub: event.pub } } + ) + } + + private sendError = (event: NostrEvent, resultType: string, code: string, message: string) => { + const response: NwcResponse = { result_type: resultType, error: { code, message } } + const e = newNwcResponse(JSON.stringify(response), event) + this.storage.NostrSender().Send( + { type: 'app', appId: event.appId }, + { type: 'event', event: e, encrypt: { toPub: event.pub } } + ) + } +} diff --git a/src/services/nostr/nostrPool.ts b/src/services/nostr/nostrPool.ts index d41da382..1d3c0e5e 100644 --- a/src/services/nostr/nostrPool.ts +++ b/src/services/nostr/nostrPool.ts @@ -48,7 +48,7 @@ const splitContent = (content: string, maxLength: number) => { } return parts } -const actionKinds = [21000, 21001, 21002, 21003] +const actionKinds = [21000, 21001, 21002, 21003, 23194] const beaconKind = 30078 const appTag = "Lightning.Pub" export class NostrPool { diff --git a/src/services/storage/db/db.ts b/src/services/storage/db/db.ts index 335f6848..ed129e03 100644 --- a/src/services/storage/db/db.ts +++ b/src/services/storage/db/db.ts @@ -21,6 +21,7 @@ import { LndNodeInfo } from "../entity/LndNodeInfo.js" import { TrackedProvider } from "../entity/TrackedProvider.js" import { InviteToken } from "../entity/InviteToken.js" import { DebitAccess } from "../entity/DebitAccess.js" +import { NwcConnection } from "../entity/NwcConnection.js" import { RootOperation } from "../entity/RootOperation.js" import { UserOffer } from "../entity/UserOffer.js" import { ManagementGrant } from "../entity/ManagementGrant.js" @@ -70,6 +71,7 @@ export const MainDbEntities = { 'TrackedProvider': TrackedProvider, 'InviteToken': InviteToken, 'DebitAccess': DebitAccess, + 'NwcConnection': NwcConnection, 'UserOffer': UserOffer, 'Product': Product, 'ManagementGrant': ManagementGrant, diff --git a/src/services/storage/entity/NwcConnection.ts b/src/services/storage/entity/NwcConnection.ts new file mode 100644 index 00000000..628b49a2 --- /dev/null +++ b/src/services/storage/entity/NwcConnection.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from "typeorm" + +@Entity() +@Index("unique_nwc_connection", ["app_id", "client_pubkey"], { unique: true }) +@Index("idx_nwc_app_user", ["app_user_id"]) +export class NwcConnection { + + @PrimaryGeneratedColumn() + serial_id: number + + @Column() + app_id: string + + @Column() + app_user_id: string + + @Column() + client_pubkey: string + + @Column({ type: 'simple-json', default: null, nullable: true }) + permissions: string[] | null + + @Column({ default: 0 }) + max_amount: number + + @Column({ default: 0 }) + expires_at: number + + @Column({ default: 0 }) + total_spent: number + + @CreateDateColumn() + created_at: Date + + @UpdateDateColumn() + updated_at: Date +} diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index fea4c8c9..e7741c84 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -9,6 +9,7 @@ import MetricsEventStorage from "./tlv/metricsEventStorage.js"; import EventsLogManager from "./eventsLog.js"; import { LiquidityStorage } from "./liquidityStorage.js"; import DebitStorage from "./debitStorage.js" +import NwcStorage from "./nwcStorage.js" import OfferStorage from "./offerStorage.js" import { ManagementStorage } from "./managementStorage.js"; import { StorageInterface, TX } from "./db/storageInterface.js"; @@ -78,6 +79,7 @@ export default class { metricsEventStorage: MetricsEventStorage liquidityStorage: LiquidityStorage debitStorage: DebitStorage + nwcStorage: NwcStorage offerStorage: OfferStorage managementStorage: ManagementStorage eventsLog: EventsLogManager @@ -103,6 +105,7 @@ export default class { this.metricsEventStorage = new MetricsEventStorage(this.settings, this.utils.tlvStorageFactory) this.liquidityStorage = new LiquidityStorage(this.dbs) this.debitStorage = new DebitStorage(this.dbs) + this.nwcStorage = new NwcStorage(this.dbs) this.offerStorage = new OfferStorage(this.dbs) this.managementStorage = new ManagementStorage(this.dbs); try { if (this.settings.dataDir) fs.mkdirSync(this.settings.dataDir) } catch (e) { } diff --git a/src/services/storage/migrations/1770000000000-nwc_connection.ts b/src/services/storage/migrations/1770000000000-nwc_connection.ts new file mode 100644 index 00000000..f5644b86 --- /dev/null +++ b/src/services/storage/migrations/1770000000000-nwc_connection.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NwcConnection1770000000000 implements MigrationInterface { + name = 'NwcConnection1770000000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "nwc_connection" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "app_id" varchar NOT NULL, "app_user_id" varchar NOT NULL, "client_pubkey" varchar NOT NULL, "permissions" text, "max_amount" integer NOT NULL DEFAULT (0), "expires_at" integer NOT NULL DEFAULT (0), "total_spent" integer NOT NULL DEFAULT (0), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`); + await queryRunner.query(`CREATE UNIQUE INDEX "unique_nwc_connection" ON "nwc_connection" ("app_id", "client_pubkey") `); + await queryRunner.query(`CREATE INDEX "idx_nwc_app_user" ON "nwc_connection" ("app_user_id") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "idx_nwc_app_user"`); + await queryRunner.query(`DROP INDEX "unique_nwc_connection"`); + await queryRunner.query(`DROP TABLE "nwc_connection"`); + } +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index d14b8381..5a8aba94 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -32,6 +32,7 @@ import { TxSwapAddress1764779178945 } from './1764779178945-tx_swap_address.js' import { ClinkRequester1765497600000 } from './1765497600000-clink_requester.js' import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_provider_height.js' import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' +import { NwcConnection1770000000000 } from './1770000000000-nwc_connection.js' export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, @@ -39,7 +40,7 @@ export const allMigrations = [Initial1703170309875, LspOrder1718387847693, Liqui DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036] + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, NwcConnection1770000000000] export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] /* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise => { diff --git a/src/services/storage/nwcStorage.ts b/src/services/storage/nwcStorage.ts new file mode 100644 index 00000000..73aaad17 --- /dev/null +++ b/src/services/storage/nwcStorage.ts @@ -0,0 +1,49 @@ +import { NwcConnection } from "./entity/NwcConnection.js"; +import { StorageInterface } from "./db/storageInterface.js"; + +type ConnectionToAdd = { + app_id: string + app_user_id: string + client_pubkey: string + permissions?: string[] + max_amount?: number + expires_at?: number +} + +export default class { + dbs: StorageInterface + constructor(dbs: StorageInterface) { + this.dbs = dbs + } + + async AddConnection(connection: ConnectionToAdd) { + return this.dbs.CreateAndSave('NwcConnection', { + app_id: connection.app_id, + app_user_id: connection.app_user_id, + client_pubkey: connection.client_pubkey, + permissions: connection.permissions || null, + max_amount: connection.max_amount || 0, + expires_at: connection.expires_at || 0, + }) + } + + async GetConnection(appId: string, clientPubkey: string, txId?: string) { + return this.dbs.FindOne('NwcConnection', { where: { app_id: appId, client_pubkey: clientPubkey } }, txId) + } + + async GetUserConnections(appUserId: string, txId?: string) { + return this.dbs.Find('NwcConnection', { where: { app_user_id: appUserId } }, txId) + } + + async DeleteConnection(serialId: number, txId?: string) { + return this.dbs.Delete('NwcConnection', { serial_id: serialId }, txId) + } + + async DeleteConnectionByPubkey(appId: string, clientPubkey: string, txId?: string) { + return this.dbs.Delete('NwcConnection', { app_id: appId, client_pubkey: clientPubkey }, txId) + } + + async IncrementTotalSpent(appId: string, clientPubkey: string, amount: number, txId?: string) { + return this.dbs.Increment('NwcConnection', { app_id: appId, client_pubkey: clientPubkey }, 'total_spent', amount, txId) + } +} From 2c8d57dd6e186f33297ce3b43e00f9a1f7816cd3 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 25 Feb 2026 18:14:56 +0000 Subject: [PATCH 35/49] address amt validation --- src/services/main/paymentManager.ts | 6 ++++++ src/services/serverMethods/index.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 617641f4..07021be5 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -595,9 +595,15 @@ export default class { async PayInternalAddress(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise { this.log("paying internal address") + let amount = req.amountSats if (req.swap_operation_id) { + const swap = await this.storage.paymentStorage.GetTransactionSwap(req.swap_operation_id, ctx.app_user_id) + amount = amount > 0 ? amount : swap?.invoice_amount || 0 await this.storage.paymentStorage.DeleteTransactionSwap(req.swap_operation_id) } + if (amount <= 0) { + throw new Error("invalid tx amount") + } const { blockHeight } = await this.lnd.GetInfo() const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const isManagedUser = ctx.user_id !== app.owner.user_id diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index 21668449..6783e03d 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -184,7 +184,7 @@ export default (mainHandler: Main): Types.ServerMethods => { PayAddress: async ({ ctx, req }) => { const err = Types.PayAddressRequestValidate(req, { address_CustomCheck: addr => addr !== '', - amountSats_CustomCheck: amt => amt > 0, + // amountSats_CustomCheck: amt => amt > 0, // satsPerVByte_CustomCheck: spb => spb > 0 }) if (err != null) throw new Error(err.message) From ef14ec9ddfbd1762fff12d6a3dfaa51ff3f27be4 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 26 Feb 2026 21:54:28 +0000 Subject: [PATCH 36/49] assets and liabilities --- proto/autogenerated/client.md | 58 +++++ proto/autogenerated/go/http_client.go | 30 +++ proto/autogenerated/go/types.go | 49 ++++ proto/autogenerated/ts/express_server.ts | 22 ++ proto/autogenerated/ts/http_client.ts | 14 + proto/autogenerated/ts/nostr_client.ts | 15 ++ proto/autogenerated/ts/nostr_transport.ts | 16 ++ proto/autogenerated/ts/types.ts | 303 +++++++++++++++++++++- proto/service/methods.proto | 7 + proto/service/structs.proto | 57 ++++ src/services/lnd/lnd.ts | 4 +- src/services/main/adminManager.ts | 256 ++++++++++++++++++ src/services/main/index.ts | 1 + src/services/main/liquidityProvider.ts | 4 +- src/services/main/paymentManager.ts | 2 +- src/services/main/sanityChecker.ts | 2 +- src/services/metrics/index.ts | 4 +- src/services/serverMethods/index.ts | 3 + src/services/storage/metricsStorage.ts | 3 + src/services/storage/paymentStorage.ts | 4 + 20 files changed, 843 insertions(+), 11 deletions(-) diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index d5b9baab..47db491b 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -108,6 +108,11 @@ The nostr server will send back a message response, and inside the body there wi - input: [AppsMetricsRequest](#AppsMetricsRequest) - output: [AppsMetrics](#AppsMetrics) +- GetAssetsAndLiabilities + - auth type: __Admin__ + - input: [AssetsAndLiabilitiesReq](#AssetsAndLiabilitiesReq) + - output: [AssetsAndLiabilities](#AssetsAndLiabilities) + - GetBundleMetrics - auth type: __Metrics__ - input: [LatestBundleMetricReq](#LatestBundleMetricReq) @@ -602,6 +607,13 @@ The nostr server will send back a message response, and inside the body there wi - input: [AppsMetricsRequest](#AppsMetricsRequest) - output: [AppsMetrics](#AppsMetrics) +- GetAssetsAndLiabilities + - auth type: __Admin__ + - http method: __post__ + - http route: __/api/admin/assets/liabilities__ + - input: [AssetsAndLiabilitiesReq](#AssetsAndLiabilitiesReq) + - output: [AssetsAndLiabilities](#AssetsAndLiabilities) + - GetBundleMetrics - auth type: __Metrics__ - http method: __post__ @@ -1186,6 +1198,21 @@ The nostr server will send back a message response, and inside the body there wi - __include_operations__: _boolean_ *this field is optional - __to_unix__: _number_ *this field is optional +### AssetOperation + - __amount__: _number_ + - __tracked__: _[TrackedOperation](#TrackedOperation)_ *this field is optional + - __ts__: _number_ + +### AssetsAndLiabilities + - __liquidity_providers__: ARRAY of: _[LiquidityAssetProvider](#LiquidityAssetProvider)_ + - __lnds__: ARRAY of: _[LndAssetProvider](#LndAssetProvider)_ + - __users_balance__: _number_ + +### AssetsAndLiabilitiesReq + - __limit_invoices__: _number_ *this field is optional + - __limit_payments__: _number_ *this field is optional + - __limit_providers__: _number_ *this field is optional + ### AuthApp - __app__: _[Application](#Application)_ - __auth_token__: _string_ @@ -1421,6 +1448,10 @@ The nostr server will send back a message response, and inside the body there wi ### LinkNPubThroughTokenRequest - __token__: _string_ +### LiquidityAssetProvider + - __pubkey__: _string_ + - __tracked__: _[TrackedLiquidityProvider](#TrackedLiquidityProvider)_ *this field is optional + ### LiveDebitRequest - __debit__: _[LiveDebitRequest_debit](#LiveDebitRequest_debit)_ - __npub__: _string_ @@ -1434,6 +1465,10 @@ The nostr server will send back a message response, and inside the body there wi - __latest_balance__: _number_ - __operation__: _[UserOperation](#UserOperation)_ +### LndAssetProvider + - __pubkey__: _string_ + - __tracked__: _[TrackedLndProvider](#TrackedLndProvider)_ *this field is optional + ### LndChannels - __open_channels__: ARRAY of: _[OpenChannel](#OpenChannel)_ @@ -1738,6 +1773,25 @@ The nostr server will send back a message response, and inside the body there wi - __page__: _number_ - __request_id__: _number_ *this field is optional +### TrackedLiquidityProvider + - __balance__: _number_ + - __invoices__: ARRAY of: _[AssetOperation](#AssetOperation)_ + - __payments__: ARRAY of: _[AssetOperation](#AssetOperation)_ + +### TrackedLndProvider + - __channels_balance__: _number_ + - __confirmed_balance__: _number_ + - __incoming_tx__: ARRAY of: _[AssetOperation](#AssetOperation)_ + - __invoices__: ARRAY of: _[AssetOperation](#AssetOperation)_ + - __outgoing_tx__: ARRAY of: _[AssetOperation](#AssetOperation)_ + - __payments__: ARRAY of: _[AssetOperation](#AssetOperation)_ + - __unconfirmed_balance__: _number_ + +### TrackedOperation + - __amount__: _number_ + - __ts__: _number_ + - __type__: _[TrackedOperationType](#TrackedOperationType)_ + ### TransactionSwapQuote - __chain_fee_sats__: _number_ - __invoice_amount_sats__: _number_ @@ -1870,6 +1924,10 @@ The nostr server will send back a message response, and inside the body there wi - __BUNDLE_METRIC__ - __USAGE_METRIC__ +### TrackedOperationType + - __ROOT__ + - __USER__ + ### UserOperationType - __INCOMING_INVOICE__ - __INCOMING_TX__ diff --git a/proto/autogenerated/go/http_client.go b/proto/autogenerated/go/http_client.go index 98c9c648..1d5219a6 100644 --- a/proto/autogenerated/go/http_client.go +++ b/proto/autogenerated/go/http_client.go @@ -80,6 +80,7 @@ type Client struct { GetAppUser func(req GetAppUserRequest) (*AppUser, error) GetAppUserLNURLInfo func(req GetAppUserLNURLInfoRequest) (*LnurlPayInfoResponse, error) GetAppsMetrics func(req AppsMetricsRequest) (*AppsMetrics, error) + GetAssetsAndLiabilities func(req AssetsAndLiabilitiesReq) (*AssetsAndLiabilities, error) GetBundleMetrics func(req LatestBundleMetricReq) (*BundleMetrics, error) GetDebitAuthorizations func() (*DebitAuthorizations, error) GetErrorStats func() (*ErrorStats, error) @@ -842,6 +843,35 @@ func NewClient(params ClientParams) *Client { } return &res, nil }, + GetAssetsAndLiabilities: func(req AssetsAndLiabilitiesReq) (*AssetsAndLiabilities, error) { + auth, err := params.RetrieveAdminAuth() + if err != nil { + return nil, err + } + finalRoute := "/api/admin/assets/liabilities" + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) + if err != nil { + return nil, err + } + result := ResultError{} + err = json.Unmarshal(resBody, &result) + if err != nil { + return nil, err + } + if result.Status == "ERROR" { + return nil, fmt.Errorf(result.Reason) + } + res := AssetsAndLiabilities{} + err = json.Unmarshal(resBody, &res) + if err != nil { + return nil, err + } + return &res, nil + }, GetBundleMetrics: func(req LatestBundleMetricReq) (*BundleMetrics, error) { auth, err := params.RetrieveMetricsAuth() if err != nil { diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index d0efb918..e8a0c431 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -79,6 +79,13 @@ const ( USAGE_METRIC SingleMetricType = "USAGE_METRIC" ) +type TrackedOperationType string + +const ( + ROOT TrackedOperationType = "ROOT" + USER TrackedOperationType = "USER" +) + type UserOperationType string const ( @@ -163,6 +170,21 @@ type AppsMetricsRequest struct { Include_operations bool `json:"include_operations"` To_unix int64 `json:"to_unix"` } +type AssetOperation struct { + Amount int64 `json:"amount"` + Tracked *TrackedOperation `json:"tracked"` + Ts int64 `json:"ts"` +} +type AssetsAndLiabilities struct { + Liquidity_providers []LiquidityAssetProvider `json:"liquidity_providers"` + Lnds []LndAssetProvider `json:"lnds"` + Users_balance int64 `json:"users_balance"` +} +type AssetsAndLiabilitiesReq struct { + Limit_invoices int64 `json:"limit_invoices"` + Limit_payments int64 `json:"limit_payments"` + Limit_providers int64 `json:"limit_providers"` +} type AuthApp struct { App *Application `json:"app"` Auth_token string `json:"auth_token"` @@ -398,6 +420,10 @@ type LatestUsageMetricReq struct { type LinkNPubThroughTokenRequest struct { Token string `json:"token"` } +type LiquidityAssetProvider struct { + Pubkey string `json:"pubkey"` + Tracked *TrackedLiquidityProvider `json:"tracked"` +} type LiveDebitRequest struct { Debit *LiveDebitRequest_debit `json:"debit"` Npub string `json:"npub"` @@ -411,6 +437,10 @@ type LiveUserOperation struct { Latest_balance int64 `json:"latest_balance"` Operation *UserOperation `json:"operation"` } +type LndAssetProvider struct { + Pubkey string `json:"pubkey"` + Tracked *TrackedLndProvider `json:"tracked"` +} type LndChannels struct { Open_channels []OpenChannel `json:"open_channels"` } @@ -715,6 +745,25 @@ type SingleMetricReq struct { Page int64 `json:"page"` Request_id int64 `json:"request_id"` } +type TrackedLiquidityProvider struct { + Balance int64 `json:"balance"` + Invoices []AssetOperation `json:"invoices"` + Payments []AssetOperation `json:"payments"` +} +type TrackedLndProvider struct { + Channels_balance int64 `json:"channels_balance"` + Confirmed_balance int64 `json:"confirmed_balance"` + Incoming_tx []AssetOperation `json:"incoming_tx"` + Invoices []AssetOperation `json:"invoices"` + Outgoing_tx []AssetOperation `json:"outgoing_tx"` + Payments []AssetOperation `json:"payments"` + Unconfirmed_balance int64 `json:"unconfirmed_balance"` +} +type TrackedOperation struct { + Amount int64 `json:"amount"` + Ts int64 `json:"ts"` + Type TrackedOperationType `json:"type"` +} type TransactionSwapQuote struct { Chain_fee_sats int64 `json:"chain_fee_sats"` Invoice_amount_sats int64 `json:"invoice_amount_sats"` diff --git a/proto/autogenerated/ts/express_server.ts b/proto/autogenerated/ts/express_server.ts index 03ff728a..c0378ffa 100644 --- a/proto/autogenerated/ts/express_server.ts +++ b/proto/autogenerated/ts/express_server.ts @@ -998,6 +998,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { 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 } }) + if (!opts.allowNotImplementedMethods && !methods.GetAssetsAndLiabilities) throw new Error('method: GetAssetsAndLiabilities is not implemented') + app.post('/api/admin/assets/liabilities', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'GetAssetsAndLiabilities', batch: false, nostr: false, batchSize: 0} + const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } + let authCtx: Types.AuthContext = {} + try { + if (!methods.GetAssetsAndLiabilities) throw new Error('method: GetAssetsAndLiabilities is not implemented') + const authContext = await opts.AdminAuthGuard(req.headers['authorization']) + authCtx = authContext + stats.guard = process.hrtime.bigint() + const request = req.body + const error = Types.AssetsAndLiabilitiesReqValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback) + const query = req.query + const params = req.params + const response = await methods.GetAssetsAndLiabilities({rpcName:'GetAssetsAndLiabilities', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res.json({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 } + }) if (!opts.allowNotImplementedMethods && !methods.GetBundleMetrics) throw new Error('method: GetBundleMetrics is not implemented') app.post('/api/reports/bundle', async (req, res) => { const info: Types.RequestInfo = { rpcName: 'GetBundleMetrics', batch: false, nostr: false, batchSize: 0} diff --git a/proto/autogenerated/ts/http_client.ts b/proto/autogenerated/ts/http_client.ts index 195737be..ed68348f 100644 --- a/proto/autogenerated/ts/http_client.ts +++ b/proto/autogenerated/ts/http_client.ts @@ -357,6 +357,20 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, + GetAssetsAndLiabilities: async (request: Types.AssetsAndLiabilitiesReq): Promise => { + const auth = await params.retrieveAdminAuth() + if (auth === null) throw new Error('retrieveAdminAuth() returned null') + let finalRoute = '/api/admin/assets/liabilities' + const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.AssetsAndLiabilitiesValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, GetBundleMetrics: async (request: Types.LatestBundleMetricReq): 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 d174d247..b00e8f1a 100644 --- a/proto/autogenerated/ts/nostr_client.ts +++ b/proto/autogenerated/ts/nostr_client.ts @@ -275,6 +275,21 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, + GetAssetsAndLiabilities: async (request: Types.AssetsAndLiabilitiesReq): Promise => { + const auth = await params.retrieveNostrAdminAuth() + if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') + const nostrRequest: NostrRequest = {} + nostrRequest.body = request + const data = await send(params.pubDestination, {rpcName:'GetAssetsAndLiabilities',authIdentifier:auth, ...nostrRequest }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.AssetsAndLiabilitiesValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, GetBundleMetrics: async (request: Types.LatestBundleMetricReq): 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 4f9307b3..c58b3283 100644 --- a/proto/autogenerated/ts/nostr_transport.ts +++ b/proto/autogenerated/ts/nostr_transport.ts @@ -735,6 +735,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 'GetAssetsAndLiabilities': + try { + if (!methods.GetAssetsAndLiabilities) throw new Error('method: GetAssetsAndLiabilities is not implemented') + const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + const request = req.body + const error = Types.AssetsAndLiabilitiesReqValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback) + const response = await methods.GetAssetsAndLiabilities({rpcName:'GetAssetsAndLiabilities', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + 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 'GetBundleMetrics': try { if (!methods.GetBundleMetrics) throw new Error('method: GetBundleMetrics is not implemented') diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index f62fea43..1f1de167 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -7,8 +7,8 @@ export type RequestMetric = AuthContext & RequestInfo & RequestStats & { error?: export type AdminContext = { admin_id: string } -export type AdminMethodInputs = AddApp_Input | AddPeer_Input | AuthApp_Input | BanUser_Input | CloseChannel_Input | CreateOneTimeInviteLink_Input | GetAdminInvoiceSwapQuotes_Input | GetAdminTransactionSwapQuotes_Input | GetInviteLinkState_Input | GetSeed_Input | ListAdminInvoiceSwaps_Input | ListAdminTxSwaps_Input | ListChannels_Input | LndGetInfo_Input | OpenChannel_Input | PayAdminInvoiceSwap_Input | PayAdminTransactionSwap_Input | RefundAdminInvoiceSwap_Input | UpdateChannelPolicy_Input -export type AdminMethodOutputs = AddApp_Output | AddPeer_Output | AuthApp_Output | BanUser_Output | CloseChannel_Output | CreateOneTimeInviteLink_Output | GetAdminInvoiceSwapQuotes_Output | GetAdminTransactionSwapQuotes_Output | GetInviteLinkState_Output | GetSeed_Output | ListAdminInvoiceSwaps_Output | ListAdminTxSwaps_Output | ListChannels_Output | LndGetInfo_Output | OpenChannel_Output | PayAdminInvoiceSwap_Output | PayAdminTransactionSwap_Output | RefundAdminInvoiceSwap_Output | UpdateChannelPolicy_Output +export type AdminMethodInputs = AddApp_Input | AddPeer_Input | AuthApp_Input | BanUser_Input | CloseChannel_Input | CreateOneTimeInviteLink_Input | GetAdminInvoiceSwapQuotes_Input | GetAdminTransactionSwapQuotes_Input | GetAssetsAndLiabilities_Input | GetInviteLinkState_Input | GetSeed_Input | ListAdminInvoiceSwaps_Input | ListAdminTxSwaps_Input | ListChannels_Input | LndGetInfo_Input | OpenChannel_Input | PayAdminInvoiceSwap_Input | PayAdminTransactionSwap_Input | RefundAdminInvoiceSwap_Input | UpdateChannelPolicy_Input +export type AdminMethodOutputs = AddApp_Output | AddPeer_Output | AuthApp_Output | BanUser_Output | CloseChannel_Output | CreateOneTimeInviteLink_Output | GetAdminInvoiceSwapQuotes_Output | GetAdminTransactionSwapQuotes_Output | GetAssetsAndLiabilities_Output | GetInviteLinkState_Output | GetSeed_Output | ListAdminInvoiceSwaps_Output | ListAdminTxSwaps_Output | ListChannels_Output | LndGetInfo_Output | OpenChannel_Output | PayAdminInvoiceSwap_Output | PayAdminTransactionSwap_Output | RefundAdminInvoiceSwap_Output | UpdateChannelPolicy_Output export type AppContext = { app_id: string } @@ -117,6 +117,9 @@ export type GetAppUserLNURLInfo_Output = ResultError | ({ status: 'OK' } & Lnurl export type GetAppsMetrics_Input = {rpcName:'GetAppsMetrics', req: AppsMetricsRequest} export type GetAppsMetrics_Output = ResultError | ({ status: 'OK' } & AppsMetrics) +export type GetAssetsAndLiabilities_Input = {rpcName:'GetAssetsAndLiabilities', req: AssetsAndLiabilitiesReq} +export type GetAssetsAndLiabilities_Output = ResultError | ({ status: 'OK' } & AssetsAndLiabilities) + export type GetBundleMetrics_Input = {rpcName:'GetBundleMetrics', req: LatestBundleMetricReq} export type GetBundleMetrics_Output = ResultError | ({ status: 'OK' } & BundleMetrics) @@ -375,6 +378,7 @@ export type ServerMethods = { GetAppUser?: (req: GetAppUser_Input & {ctx: AppContext }) => Promise GetAppUserLNURLInfo?: (req: GetAppUserLNURLInfo_Input & {ctx: AppContext }) => Promise GetAppsMetrics?: (req: GetAppsMetrics_Input & {ctx: MetricsContext }) => Promise + GetAssetsAndLiabilities?: (req: GetAssetsAndLiabilities_Input & {ctx: AdminContext }) => Promise GetBundleMetrics?: (req: GetBundleMetrics_Input & {ctx: MetricsContext }) => Promise GetDebitAuthorizations?: (req: GetDebitAuthorizations_Input & {ctx: UserContext }) => Promise GetErrorStats?: (req: GetErrorStats_Input & {ctx: MetricsContext }) => Promise @@ -481,6 +485,14 @@ export const enumCheckSingleMetricType = (e?: SingleMetricType): boolean => { for (const v in SingleMetricType) if (e === v) return true return false } +export enum TrackedOperationType { + ROOT = 'ROOT', + USER = 'USER', +} +export const enumCheckTrackedOperationType = (e?: TrackedOperationType): boolean => { + for (const v in TrackedOperationType) if (e === v) return true + return false +} export enum UserOperationType { INCOMING_INVOICE = 'INCOMING_INVOICE', INCOMING_TX = 'INCOMING_TX', @@ -929,6 +941,105 @@ export const AppsMetricsRequestValidate = (o?: AppsMetricsRequest, opts: AppsMet return null } +export type AssetOperation = { + amount: number + tracked?: TrackedOperation + ts: number +} +export type AssetOperationOptionalField = 'tracked' +export const AssetOperationOptionalFields: AssetOperationOptionalField[] = ['tracked'] +export type AssetOperationOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: AssetOperationOptionalField[] + amount_CustomCheck?: (v: number) => boolean + tracked_Options?: TrackedOperationOptions + ts_CustomCheck?: (v: number) => boolean +} +export const AssetOperationValidate = (o?: AssetOperation, opts: AssetOperationOptions = {}, path: string = 'AssetOperation::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') + + if (typeof o.amount !== 'number') return new Error(`${path}.amount: is not a number`) + if (opts.amount_CustomCheck && !opts.amount_CustomCheck(o.amount)) return new Error(`${path}.amount: custom check failed`) + + if (typeof o.tracked === 'object' || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('tracked')) { + const trackedErr = TrackedOperationValidate(o.tracked, opts.tracked_Options, `${path}.tracked`) + if (trackedErr !== null) return trackedErr + } + + + if (typeof o.ts !== 'number') return new Error(`${path}.ts: is not a number`) + if (opts.ts_CustomCheck && !opts.ts_CustomCheck(o.ts)) return new Error(`${path}.ts: custom check failed`) + + return null +} + +export type AssetsAndLiabilities = { + liquidity_providers: LiquidityAssetProvider[] + lnds: LndAssetProvider[] + users_balance: number +} +export const AssetsAndLiabilitiesOptionalFields: [] = [] +export type AssetsAndLiabilitiesOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + liquidity_providers_ItemOptions?: LiquidityAssetProviderOptions + liquidity_providers_CustomCheck?: (v: LiquidityAssetProvider[]) => boolean + lnds_ItemOptions?: LndAssetProviderOptions + lnds_CustomCheck?: (v: LndAssetProvider[]) => boolean + users_balance_CustomCheck?: (v: number) => boolean +} +export const AssetsAndLiabilitiesValidate = (o?: AssetsAndLiabilities, opts: AssetsAndLiabilitiesOptions = {}, path: string = 'AssetsAndLiabilities::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') + + if (!Array.isArray(o.liquidity_providers)) return new Error(`${path}.liquidity_providers: is not an array`) + for (let index = 0; index < o.liquidity_providers.length; index++) { + const liquidity_providersErr = LiquidityAssetProviderValidate(o.liquidity_providers[index], opts.liquidity_providers_ItemOptions, `${path}.liquidity_providers[${index}]`) + if (liquidity_providersErr !== null) return liquidity_providersErr + } + if (opts.liquidity_providers_CustomCheck && !opts.liquidity_providers_CustomCheck(o.liquidity_providers)) return new Error(`${path}.liquidity_providers: custom check failed`) + + if (!Array.isArray(o.lnds)) return new Error(`${path}.lnds: is not an array`) + for (let index = 0; index < o.lnds.length; index++) { + const lndsErr = LndAssetProviderValidate(o.lnds[index], opts.lnds_ItemOptions, `${path}.lnds[${index}]`) + if (lndsErr !== null) return lndsErr + } + if (opts.lnds_CustomCheck && !opts.lnds_CustomCheck(o.lnds)) return new Error(`${path}.lnds: custom check failed`) + + if (typeof o.users_balance !== 'number') return new Error(`${path}.users_balance: is not a number`) + if (opts.users_balance_CustomCheck && !opts.users_balance_CustomCheck(o.users_balance)) return new Error(`${path}.users_balance: custom check failed`) + + return null +} + +export type AssetsAndLiabilitiesReq = { + limit_invoices?: number + limit_payments?: number + limit_providers?: number +} +export type AssetsAndLiabilitiesReqOptionalField = 'limit_invoices' | 'limit_payments' | 'limit_providers' +export const AssetsAndLiabilitiesReqOptionalFields: AssetsAndLiabilitiesReqOptionalField[] = ['limit_invoices', 'limit_payments', 'limit_providers'] +export type AssetsAndLiabilitiesReqOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: AssetsAndLiabilitiesReqOptionalField[] + limit_invoices_CustomCheck?: (v?: number) => boolean + limit_payments_CustomCheck?: (v?: number) => boolean + limit_providers_CustomCheck?: (v?: number) => boolean +} +export const AssetsAndLiabilitiesReqValidate = (o?: AssetsAndLiabilitiesReq, opts: AssetsAndLiabilitiesReqOptions = {}, path: string = 'AssetsAndLiabilitiesReq::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') + + if ((o.limit_invoices || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('limit_invoices')) && typeof o.limit_invoices !== 'number') return new Error(`${path}.limit_invoices: is not a number`) + if (opts.limit_invoices_CustomCheck && !opts.limit_invoices_CustomCheck(o.limit_invoices)) return new Error(`${path}.limit_invoices: custom check failed`) + + if ((o.limit_payments || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('limit_payments')) && typeof o.limit_payments !== 'number') return new Error(`${path}.limit_payments: is not a number`) + if (opts.limit_payments_CustomCheck && !opts.limit_payments_CustomCheck(o.limit_payments)) return new Error(`${path}.limit_payments: custom check failed`) + + if ((o.limit_providers || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('limit_providers')) && typeof o.limit_providers !== 'number') return new Error(`${path}.limit_providers: is not a number`) + if (opts.limit_providers_CustomCheck && !opts.limit_providers_CustomCheck(o.limit_providers)) return new Error(`${path}.limit_providers: custom check failed`) + + return null +} + export type AuthApp = { app: Application auth_token: string @@ -2358,6 +2469,33 @@ export const LinkNPubThroughTokenRequestValidate = (o?: LinkNPubThroughTokenRequ return null } +export type LiquidityAssetProvider = { + pubkey: string + tracked?: TrackedLiquidityProvider +} +export type LiquidityAssetProviderOptionalField = 'tracked' +export const LiquidityAssetProviderOptionalFields: LiquidityAssetProviderOptionalField[] = ['tracked'] +export type LiquidityAssetProviderOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: LiquidityAssetProviderOptionalField[] + pubkey_CustomCheck?: (v: string) => boolean + tracked_Options?: TrackedLiquidityProviderOptions +} +export const LiquidityAssetProviderValidate = (o?: LiquidityAssetProvider, opts: LiquidityAssetProviderOptions = {}, path: string = 'LiquidityAssetProvider::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') + + if (typeof o.pubkey !== 'string') return new Error(`${path}.pubkey: is not a string`) + if (opts.pubkey_CustomCheck && !opts.pubkey_CustomCheck(o.pubkey)) return new Error(`${path}.pubkey: custom check failed`) + + if (typeof o.tracked === 'object' || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('tracked')) { + const trackedErr = TrackedLiquidityProviderValidate(o.tracked, opts.tracked_Options, `${path}.tracked`) + if (trackedErr !== null) return trackedErr + } + + + return null +} + export type LiveDebitRequest = { debit: LiveDebitRequest_debit npub: string @@ -2434,6 +2572,33 @@ export const LiveUserOperationValidate = (o?: LiveUserOperation, opts: LiveUserO return null } +export type LndAssetProvider = { + pubkey: string + tracked?: TrackedLndProvider +} +export type LndAssetProviderOptionalField = 'tracked' +export const LndAssetProviderOptionalFields: LndAssetProviderOptionalField[] = ['tracked'] +export type LndAssetProviderOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: LndAssetProviderOptionalField[] + pubkey_CustomCheck?: (v: string) => boolean + tracked_Options?: TrackedLndProviderOptions +} +export const LndAssetProviderValidate = (o?: LndAssetProvider, opts: LndAssetProviderOptions = {}, path: string = 'LndAssetProvider::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') + + if (typeof o.pubkey !== 'string') return new Error(`${path}.pubkey: is not a string`) + if (opts.pubkey_CustomCheck && !opts.pubkey_CustomCheck(o.pubkey)) return new Error(`${path}.pubkey: custom check failed`) + + if (typeof o.tracked === 'object' || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('tracked')) { + const trackedErr = TrackedLndProviderValidate(o.tracked, opts.tracked_Options, `${path}.tracked`) + if (trackedErr !== null) return trackedErr + } + + + return null +} + export type LndChannels = { open_channels: OpenChannel[] } @@ -4230,6 +4395,140 @@ export const SingleMetricReqValidate = (o?: SingleMetricReq, opts: SingleMetricR return null } +export type TrackedLiquidityProvider = { + balance: number + invoices: AssetOperation[] + payments: AssetOperation[] +} +export const TrackedLiquidityProviderOptionalFields: [] = [] +export type TrackedLiquidityProviderOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + balance_CustomCheck?: (v: number) => boolean + invoices_ItemOptions?: AssetOperationOptions + invoices_CustomCheck?: (v: AssetOperation[]) => boolean + payments_ItemOptions?: AssetOperationOptions + payments_CustomCheck?: (v: AssetOperation[]) => boolean +} +export const TrackedLiquidityProviderValidate = (o?: TrackedLiquidityProvider, opts: TrackedLiquidityProviderOptions = {}, path: string = 'TrackedLiquidityProvider::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') + + if (typeof o.balance !== 'number') return new Error(`${path}.balance: is not a number`) + if (opts.balance_CustomCheck && !opts.balance_CustomCheck(o.balance)) return new Error(`${path}.balance: custom check failed`) + + if (!Array.isArray(o.invoices)) return new Error(`${path}.invoices: is not an array`) + for (let index = 0; index < o.invoices.length; index++) { + const invoicesErr = AssetOperationValidate(o.invoices[index], opts.invoices_ItemOptions, `${path}.invoices[${index}]`) + if (invoicesErr !== null) return invoicesErr + } + if (opts.invoices_CustomCheck && !opts.invoices_CustomCheck(o.invoices)) return new Error(`${path}.invoices: custom check failed`) + + if (!Array.isArray(o.payments)) return new Error(`${path}.payments: is not an array`) + for (let index = 0; index < o.payments.length; index++) { + const paymentsErr = AssetOperationValidate(o.payments[index], opts.payments_ItemOptions, `${path}.payments[${index}]`) + if (paymentsErr !== null) return paymentsErr + } + if (opts.payments_CustomCheck && !opts.payments_CustomCheck(o.payments)) return new Error(`${path}.payments: custom check failed`) + + return null +} + +export type TrackedLndProvider = { + channels_balance: number + confirmed_balance: number + incoming_tx: AssetOperation[] + invoices: AssetOperation[] + outgoing_tx: AssetOperation[] + payments: AssetOperation[] + unconfirmed_balance: number +} +export const TrackedLndProviderOptionalFields: [] = [] +export type TrackedLndProviderOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + channels_balance_CustomCheck?: (v: number) => boolean + confirmed_balance_CustomCheck?: (v: number) => boolean + incoming_tx_ItemOptions?: AssetOperationOptions + incoming_tx_CustomCheck?: (v: AssetOperation[]) => boolean + invoices_ItemOptions?: AssetOperationOptions + invoices_CustomCheck?: (v: AssetOperation[]) => boolean + outgoing_tx_ItemOptions?: AssetOperationOptions + outgoing_tx_CustomCheck?: (v: AssetOperation[]) => boolean + payments_ItemOptions?: AssetOperationOptions + payments_CustomCheck?: (v: AssetOperation[]) => boolean + unconfirmed_balance_CustomCheck?: (v: number) => boolean +} +export const TrackedLndProviderValidate = (o?: TrackedLndProvider, opts: TrackedLndProviderOptions = {}, path: string = 'TrackedLndProvider::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') + + if (typeof o.channels_balance !== 'number') return new Error(`${path}.channels_balance: is not a number`) + if (opts.channels_balance_CustomCheck && !opts.channels_balance_CustomCheck(o.channels_balance)) return new Error(`${path}.channels_balance: custom check failed`) + + if (typeof o.confirmed_balance !== 'number') return new Error(`${path}.confirmed_balance: is not a number`) + if (opts.confirmed_balance_CustomCheck && !opts.confirmed_balance_CustomCheck(o.confirmed_balance)) return new Error(`${path}.confirmed_balance: custom check failed`) + + if (!Array.isArray(o.incoming_tx)) return new Error(`${path}.incoming_tx: is not an array`) + for (let index = 0; index < o.incoming_tx.length; index++) { + const incoming_txErr = AssetOperationValidate(o.incoming_tx[index], opts.incoming_tx_ItemOptions, `${path}.incoming_tx[${index}]`) + if (incoming_txErr !== null) return incoming_txErr + } + if (opts.incoming_tx_CustomCheck && !opts.incoming_tx_CustomCheck(o.incoming_tx)) return new Error(`${path}.incoming_tx: custom check failed`) + + if (!Array.isArray(o.invoices)) return new Error(`${path}.invoices: is not an array`) + for (let index = 0; index < o.invoices.length; index++) { + const invoicesErr = AssetOperationValidate(o.invoices[index], opts.invoices_ItemOptions, `${path}.invoices[${index}]`) + if (invoicesErr !== null) return invoicesErr + } + if (opts.invoices_CustomCheck && !opts.invoices_CustomCheck(o.invoices)) return new Error(`${path}.invoices: custom check failed`) + + if (!Array.isArray(o.outgoing_tx)) return new Error(`${path}.outgoing_tx: is not an array`) + for (let index = 0; index < o.outgoing_tx.length; index++) { + const outgoing_txErr = AssetOperationValidate(o.outgoing_tx[index], opts.outgoing_tx_ItemOptions, `${path}.outgoing_tx[${index}]`) + if (outgoing_txErr !== null) return outgoing_txErr + } + if (opts.outgoing_tx_CustomCheck && !opts.outgoing_tx_CustomCheck(o.outgoing_tx)) return new Error(`${path}.outgoing_tx: custom check failed`) + + if (!Array.isArray(o.payments)) return new Error(`${path}.payments: is not an array`) + for (let index = 0; index < o.payments.length; index++) { + const paymentsErr = AssetOperationValidate(o.payments[index], opts.payments_ItemOptions, `${path}.payments[${index}]`) + if (paymentsErr !== null) return paymentsErr + } + if (opts.payments_CustomCheck && !opts.payments_CustomCheck(o.payments)) return new Error(`${path}.payments: custom check failed`) + + if (typeof o.unconfirmed_balance !== 'number') return new Error(`${path}.unconfirmed_balance: is not a number`) + if (opts.unconfirmed_balance_CustomCheck && !opts.unconfirmed_balance_CustomCheck(o.unconfirmed_balance)) return new Error(`${path}.unconfirmed_balance: custom check failed`) + + return null +} + +export type TrackedOperation = { + amount: number + ts: number + type: TrackedOperationType +} +export const TrackedOperationOptionalFields: [] = [] +export type TrackedOperationOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + amount_CustomCheck?: (v: number) => boolean + ts_CustomCheck?: (v: number) => boolean + type_CustomCheck?: (v: TrackedOperationType) => boolean +} +export const TrackedOperationValidate = (o?: TrackedOperation, opts: TrackedOperationOptions = {}, path: string = 'TrackedOperation::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') + + if (typeof o.amount !== 'number') return new Error(`${path}.amount: is not a number`) + if (opts.amount_CustomCheck && !opts.amount_CustomCheck(o.amount)) return new Error(`${path}.amount: custom check failed`) + + if (typeof o.ts !== 'number') return new Error(`${path}.ts: is not a number`) + if (opts.ts_CustomCheck && !opts.ts_CustomCheck(o.ts)) return new Error(`${path}.ts: custom check failed`) + + if (!enumCheckTrackedOperationType(o.type)) return new Error(`${path}.type: is not a valid TrackedOperationType`) + if (opts.type_CustomCheck && !opts.type_CustomCheck(o.type)) return new Error(`${path}.type: custom check failed`) + + return null +} + export type TransactionSwapQuote = { chain_fee_sats: number invoice_amount_sats: number diff --git a/proto/service/methods.proto b/proto/service/methods.proto index 3eeef126..d7f97860 100644 --- a/proto/service/methods.proto +++ b/proto/service/methods.proto @@ -112,6 +112,13 @@ service LightningPub { option (nostr) = true; }; + rpc GetAssetsAndLiabilities(structs.AssetsAndLiabilitiesReq) returns (structs.AssetsAndLiabilities) { + option (auth_type) = "Admin"; + option (http_method) = "post"; + option (http_route) = "/api/admin/assets/liabilities"; + option (nostr) = true; + } + rpc AddApp(structs.AddAppRequest) returns (structs.AuthApp) { option (auth_type) = "Admin"; option (http_method) = "post"; diff --git a/proto/service/structs.proto b/proto/service/structs.proto index fb1f0867..44d17dec 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -19,6 +19,61 @@ message EncryptionExchangeRequest { string deviceId = 2; } +message AssetsAndLiabilitiesReq { + optional int64 limit_invoices = 1; + optional int64 limit_payments = 2; + optional int64 limit_providers = 4; +} + +enum TrackedOperationType { + USER = 0; + ROOT = 1; +} + +message TrackedOperation { + int64 ts = 1; + int64 amount = 2; + TrackedOperationType type = 3; +} + +message AssetOperation { + int64 ts = 1; + int64 amount = 2; + optional TrackedOperation tracked = 3; +} + +message TrackedLndProvider { + int64 confirmed_balance = 1; + int64 unconfirmed_balance = 2; + int64 channels_balance = 3; + repeated AssetOperation payments = 4; + repeated AssetOperation invoices = 5; + repeated AssetOperation incoming_tx = 6; + repeated AssetOperation outgoing_tx = 7; +} + +message TrackedLiquidityProvider { + int64 balance = 1; + repeated AssetOperation payments = 2; + repeated AssetOperation invoices = 3; +} + +message LndAssetProvider { + string pubkey = 1; + optional TrackedLndProvider tracked = 2; +} + +message LiquidityAssetProvider { + string pubkey = 1; + optional TrackedLiquidityProvider tracked = 2; +} + +message AssetsAndLiabilities { + int64 users_balance = 1; + repeated LndAssetProvider lnds = 2; + repeated LiquidityAssetProvider liquidity_providers = 3; +} + message UserHealthState { string downtime_reason = 1; } @@ -37,6 +92,8 @@ message ErrorStats { ErrorStat past1m = 5; } + + message MetricsFile { } diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index 6d71d424..673ff073 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -542,7 +542,7 @@ export default class { async GetTransactions(startHeight: number): Promise { this.log(DEBUG, "Getting transactions") - const res = await this.lightning.getTransactions({ startHeight, endHeight: 0, account: "" }, DeadLineMetadata()) + const res = await this.lightning.getTransactions({ startHeight, endHeight: 0, account: "", }, DeadLineMetadata()) return res.response } @@ -625,7 +625,7 @@ export default class { return response } - async GetAllPaidInvoices(max: number) { + async GetAllInvoices(max: number) { this.log(DEBUG, "Getting all paid invoices") if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { return { invoices: [] } diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index e8147aa6..d605dbe6 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -7,8 +7,48 @@ import LND from "../lnd/lnd.js"; import SettingsManager from "./settingsManager.js"; import { Swaps } from "../lnd/swaps/swaps.js"; import { defaultInvoiceExpiry } from "../storage/paymentStorage.js"; +import { TrackedProvider } from "../storage/entity/TrackedProvider.js"; +import { NodeInfo } from "../lnd/settings.js"; +import { Invoice, Payment, OutputDetail, Transaction, Payment_PaymentStatus, Invoice_InvoiceState } from "../../../proto/lnd/lightning.js"; +import { LiquidityProvider } from "./liquidityProvider.js"; +/* type TrackedOperation = { + ts: number + amount: number + type: 'user' | 'root' +} + +type AssetOperation = { + ts: number + amount: number + tracked?: TrackedOperation +} +type TrackedLndProvider = { + confirmedBalance: number + unconfirmedBalance: number + channelsBalace: number + payments: AssetOperation[] + invoices: AssetOperation[] + incomingTx: AssetOperation[] + outgoingTx: AssetOperation[] +} +type LndAssetProvider = { + pubkey: string + tracked?: TrackedLndProvider +} +type TrackedLiquidityProvider = { + balance: number + payments: AssetOperation[] + invoices: AssetOperation[] +} +type ProviderAssetProvider = { + pubkey: string + tracked?: TrackedLiquidityProvider +} */ +const ROOT_OP = Types.TrackedOperationType.ROOT +const USER_OP = Types.TrackedOperationType.USER export class AdminManager { settings: SettingsManager + liquidityProvider: LiquidityProvider | null = null storage: Storage log = getLogger({ component: "adminManager" }) adminNpub = "" @@ -41,6 +81,10 @@ export class AdminManager { this.start() } + attachLiquidityProvider(liquidityProvider: LiquidityProvider) { + this.liquidityProvider = liquidityProvider + } + attachNostrReset(f: () => Promise) { this.nostrReset = f } @@ -332,6 +376,218 @@ export class AdminManager { network_fee: swap.network_fee, } } + + async GetAssetsAndLiabilities(req: Types.AssetsAndLiabilitiesReq): Promise { + const providers = await this.storage.liquidityStorage.GetTrackedProviders() + + const lnds: Types.LndAssetProvider[] = [] + const liquidityProviders: Types.LiquidityAssetProvider[] = [] + for (const provider of providers) { + if (provider.provider_type === 'lnd') { + const lndEntry = await this.GetLndAssetsAndLiabilities(req, provider) + lnds.push(lndEntry) + } else if (provider.provider_type === 'lnPub') { + const liquidityEntry = await this.GetProviderAssetsAndLiabilities(req, provider) + liquidityProviders.push(liquidityEntry) + } + } + const usersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() + return { + users_balance: usersBalance, + lnds, + liquidity_providers: liquidityProviders, + } + } + + async GetProviderAssetsAndLiabilities(req: Types.AssetsAndLiabilitiesReq, provider: TrackedProvider): Promise { + if (!this.liquidityProvider) { + throw new Error("liquidity provider not attached") + } + if (this.liquidityProvider.GetProviderPubkey() !== provider.provider_pubkey) { + return { pubkey: provider.provider_pubkey, tracked: undefined } + } + const providerOps = await this.liquidityProvider.GetOperations(req.limit_providers || 100) + // we only care about invoices cuz they are the only ops we can generate with a provider + const invoices: Types.AssetOperation[] = [] + const payments: Types.AssetOperation[] = [] + for (const op of providerOps.latestIncomingInvoiceOperations.operations) { + const assetOp = await this.GetProviderInvoiceAssetOperation(op) + invoices.push(assetOp) + } + for (const op of providerOps.latestOutgoingInvoiceOperations.operations) { + const assetOp = await this.GetProviderPaymentAssetOperation(op) + payments.push(assetOp) + } + const balance = await this.liquidityProvider.GetUserState() + return { + pubkey: provider.provider_pubkey, + tracked: { + balance: balance.status === 'OK' ? balance.balance : 0, + payments, + invoices, + } + } + } + + async GetProviderInvoiceAssetOperation(op: Types.UserOperation): Promise { + const ts = Number(op.paidAtUnix) + const amount = Number(op.amount) + const invoice = op.identifier + const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(invoice) + if (userInvoice) { + const tracked: Types.TrackedOperation = { ts: userInvoice.paid_at_unix, amount: userInvoice.paid_amount, type: USER_OP } + return { ts, amount, tracked } + } + const rootOp = await this.storage.metricsStorage.GetRootOperation("invoice", invoice) + if (rootOp) { + const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP } + return { ts, amount, tracked } + } + return { ts, amount, tracked: undefined } + } + + async GetProviderPaymentAssetOperation(op: Types.UserOperation): Promise { + const ts = Number(op.paidAtUnix) + const amount = Number(op.amount) + const invoice = op.identifier + const userInvoice = await this.storage.paymentStorage.GetPaymentOwner(invoice) + if (userInvoice) { + const tracked: Types.TrackedOperation = { ts: userInvoice.paid_at_unix, amount: userInvoice.paid_amount, type: USER_OP } + return { ts, amount, tracked } + } + const rootOp = await this.storage.metricsStorage.GetRootOperation("invoice_payment", invoice) + if (rootOp) { + const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP } + return { ts, amount, tracked } + } + return { ts, amount, tracked: undefined } + } + + async GetLndAssetsAndLiabilities(req: Types.AssetsAndLiabilitiesReq, provider: TrackedProvider): Promise { + const info = await this.lnd.GetInfo() + if (provider.provider_pubkey !== info.identityPubkey) { + return { pubkey: provider.provider_pubkey, tracked: undefined } + } + + const latestLndPayments = await this.lnd.GetAllPayments(req.limit_payments || 50) + const payments: Types.AssetOperation[] = [] + for (const payment of latestLndPayments.payments) { + if (payment.status !== Payment_PaymentStatus.SUCCEEDED) { + continue + } + const assetOp = await this.GetPaymentAssetOperation(payment) + payments.push(assetOp) + } + const invoices: Types.AssetOperation[] = [] + const paidInvoices = await this.lnd.GetAllInvoices(req.limit_invoices || 100) + for (const invoiceEntry of paidInvoices.invoices) { + if (invoiceEntry.state !== Invoice_InvoiceState.SETTLED) { + continue + } + const assetOp = await this.GetInvoiceAssetOperation(invoiceEntry) + invoices.push(assetOp) + } + const latestLndTransactions = await this.lnd.GetTransactions(info.blockHeight) + const txOuts: Types.AssetOperation[] = [] + const txIns: Types.AssetOperation[] = [] + for (const transaction of latestLndTransactions.transactions) { + for (const output of transaction.outputDetails) { + if (output.isOurAddress) { + const assetOp = await this.GetTxOutAssetOperation(transaction, output) + txOuts.push(assetOp) + } + } + // we only produce TXs with a single output + const input = transaction.previousOutpoints.find(p => p.isOurOutput) + if (input) { + const assetOp = await this.GetTxInAssetOperation(transaction) + txIns.push(assetOp) + } + } + const balance = await this.lnd.GetBalance() + const channelsBalance = balance.channelsBalance.reduce((acc, c) => acc + Number(c.localBalanceSats), 0) + return { + pubkey: provider.provider_pubkey, + tracked: { + confirmed_balance: Number(balance.confirmedBalance), + unconfirmed_balance: Number(balance.unconfirmedBalance), + channels_balance: channelsBalance, + payments, + invoices, + incoming_tx: txOuts, // tx outputs, are incoming sats + outgoing_tx: txIns, // tx inputs, are outgoing sats + } + } + } + + async GetPaymentAssetOperation(payment: Payment): Promise { + const invoice = payment.paymentRequest + const userInvoice = await this.storage.paymentStorage.GetPaymentOwner(invoice) + const ts = Number(payment.creationTimeNs / (BigInt(1000_000_000))) + const amount = Number(payment.valueSat) + if (userInvoice) { + const tracked: Types.TrackedOperation = { ts: userInvoice.paid_at_unix, amount: userInvoice.paid_amount, type: USER_OP } + return { ts, amount, tracked } + } + const rootOp = await this.storage.metricsStorage.GetRootOperation("invoice_payment", invoice) + if (rootOp) { + const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP } + return { ts, amount, tracked } + } + return { ts, amount, tracked: undefined } + } + + async GetInvoiceAssetOperation(invoiceEntry: Invoice): Promise { + const invoice = invoiceEntry.paymentRequest + const ts = Number(invoiceEntry.settleDate) + const amount = Number(invoiceEntry.amtPaidSat) + const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(invoice) + if (userInvoice) { + const tracked: Types.TrackedOperation = { ts: userInvoice.paid_at_unix, amount: userInvoice.paid_amount, type: USER_OP } + return { ts, amount, tracked } + } + const rootOp = await this.storage.metricsStorage.GetRootOperation("invoice", invoice) + if (rootOp) { + const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP } + return { ts, amount, tracked } + } + return { ts, amount, tracked: undefined } + } + + async GetTxInAssetOperation(tx: Transaction): Promise { + const ts = Number(tx.timeStamp) + const amount = Number(tx.amount) + const userOp = await this.storage.paymentStorage.GetTxHashPaymentOwner(tx.txHash) + if (userOp) { + // user transaction payments are actually deprecated from lnd, but we keep this for consstency + const tracked: Types.TrackedOperation = { ts: userOp.paid_at_unix, amount: userOp.paid_amount, type: USER_OP } + return { ts, amount, tracked } + } + const rootOp = await this.storage.metricsStorage.GetRootOperation("chain_payment", tx.txHash) + if (rootOp) { + const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP } + return { ts, amount, tracked } + } + return { ts, amount, tracked: undefined } + } + + async GetTxOutAssetOperation(tx: Transaction, output: OutputDetail): Promise { + const ts = Number(tx.timeStamp) + const amount = Number(output.amount) + const outputIndex = Number(output.outputIndex) + const userOp = await this.storage.paymentStorage.GetAddressReceivingTransactionOwner(output.address, tx.txHash) + if (userOp) { + const tracked: Types.TrackedOperation = { ts: userOp.paid_at_unix, amount: userOp.paid_amount, type: USER_OP } + return { ts, amount, tracked } + } + const rootOp = await this.storage.metricsStorage.GetRootAddressTransaction(output.address, tx.txHash, outputIndex) + if (rootOp) { + const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP } + return { ts, amount, tracked } + } + return { ts, amount, tracked: undefined } + } + } const getDataPath = (dataDir: string, dataPath: string) => { diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 3d5a976c..81de5ada 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -72,6 +72,7 @@ export default class { this.unlocker = unlocker const updateProviderBalance = (b: number) => this.storage.liquidityStorage.IncrementTrackedProviderBalance('lnPub', settings.getSettings().liquiditySettings.liquidityProviderPub, b) this.liquidityProvider = new LiquidityProvider(() => this.settings.getSettings().liquiditySettings, this.utils, this.invoicePaidCb, updateProviderBalance) + adminManager.attachLiquidityProvider(this.liquidityProvider) this.rugPullTracker = new RugPullTracker(this.storage, this.liquidityProvider) const lndGetSettings = () => ({ lndSettings: settings.getSettings().lndSettings, diff --git a/src/services/main/liquidityProvider.ts b/src/services/main/liquidityProvider.ts index 1f8fe2dd..a39f86a6 100644 --- a/src/services/main/liquidityProvider.ts +++ b/src/services/main/liquidityProvider.ts @@ -277,14 +277,14 @@ export class LiquidityProvider { return res } - GetOperations = async () => { + GetOperations = async (max = 200) => { if (!this.IsReady()) { 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 }, latestIncomingTx: { ts: 0, id: 0 }, latestOutgoingTx: { ts: 0, id: 0 }, latestIncomingUserToUserPayment: { ts: 0, id: 0 }, - latestOutgoingUserToUserPayment: { ts: 0, id: 0 }, max_size: 200 + latestOutgoingUserToUserPayment: { ts: 0, id: 0 }, max_size: max }) if (res.status === 'ERROR') { this.log("error getting operations", res.reason) diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 38c7658b..964699be 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -267,7 +267,7 @@ export default class { return false } const outputIndex = Number(output.outputIndex) - const existingRootOp = await this.metrics.GetRootAddressTransaction(output.address, tx.txHash, outputIndex) + const existingRootOp = await this.storage.metricsStorage.GetRootAddressTransaction(output.address, tx.txHash, outputIndex) if (existingRootOp) { return false } diff --git a/src/services/main/sanityChecker.ts b/src/services/main/sanityChecker.ts index 6ef7419a..cb0ecb75 100644 --- a/src/services/main/sanityChecker.ts +++ b/src/services/main/sanityChecker.ts @@ -226,7 +226,7 @@ export default class SanityChecker { async VerifyEventsLog() { this.events = await this.storage.eventsLog.GetAllLogs() - this.invoices = (await this.lnd.GetAllPaidInvoices(1000)).invoices + this.invoices = (await this.lnd.GetAllInvoices(1000)).invoices this.payments = (await this.lnd.GetAllPayments(1000)).payments this.incrementSources = {} diff --git a/src/services/metrics/index.ts b/src/services/metrics/index.ts index 20c92a95..9e23b665 100644 --- a/src/services/metrics/index.ts +++ b/src/services/metrics/index.ts @@ -414,9 +414,7 @@ export default class Handler { await this.storage.metricsStorage.AddRootOperation("chain", `${address}:${txOutput.hash}:${txOutput.index}`, amount) } - async GetRootAddressTransaction(address: string, txHash: string, index: number) { - return this.storage.metricsStorage.GetRootOperation("chain", `${address}:${txHash}:${index}`) - } + async AddRootInvoicePaid(paymentRequest: string, amount: number) { await this.storage.metricsStorage.AddRootOperation("invoice", paymentRequest, amount) diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index 21668449..46fc30cc 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -133,6 +133,9 @@ export default (mainHandler: Main): Types.ServerMethods => { if (err != null) throw new Error(err.message) return mainHandler.adminManager.PayAdminInvoiceSwap(req) }, + GetAssetsAndLiabilities: async ({ ctx, req }) => { + return mainHandler.adminManager.GetAssetsAndLiabilities(req) + }, GetProvidersDisruption: async () => { return mainHandler.metricsManager.GetProvidersDisruption() }, diff --git a/src/services/storage/metricsStorage.ts b/src/services/storage/metricsStorage.ts index b388a587..d80d11c9 100644 --- a/src/services/storage/metricsStorage.ts +++ b/src/services/storage/metricsStorage.ts @@ -156,6 +156,9 @@ export default class { async GetRootOperation(opType: RootOperationType, id: string, txId?: string) { return this.dbs.FindOne('RootOperation', { where: { operation_type: opType, operation_identifier: id } }, txId) } + async GetRootAddressTransaction(address: string, txHash: string, index: number) { + return this.GetRootOperation("chain", `${address}:${txHash}:${index}`) + } async GetPendingChainPayments() { return this.dbs.Find('RootOperation', { where: { operation_type: 'chain_payment', pending: true } }) diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 785f9909..fd9fb157 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -152,6 +152,10 @@ export default class { return this.dbs.FindOne('UserTransactionPayment', { where: { address, tx_hash: txHash } }, txId) } + async GetTxHashPaymentOwner(txHash: string, txId?: string): Promise { + return this.dbs.FindOne('UserTransactionPayment', { where: { tx_hash: txHash } }, txId) + } + async GetInvoiceOwner(paymentRequest: string, txId?: string): Promise { return this.dbs.FindOne('UserReceivingInvoice', { where: { invoice: paymentRequest } }, txId) } From 574f229cee322b760f2c4e85dbd8287e6f4ba3d3 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Mon, 2 Mar 2026 18:49:22 +0000 Subject: [PATCH 37/49] activte cleanup --- src/services/main/adminManager.ts | 2 +- src/services/main/appUserManager.ts | 12 ++++++++++-- src/services/storage/paymentStorage.ts | 8 ++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index d605dbe6..e0639547 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -391,7 +391,7 @@ export class AdminManager { liquidityProviders.push(liquidityEntry) } } - const usersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() + const usersBalance = await this.storage.paymentStorage.GetTotalUsersBalance(true) return { users_balance: usersBalance, lnds, diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index e1db3637..2a2ade08 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -132,7 +132,7 @@ export default class { } this.log("Found", toDelete.length, "inactive users to delete") - // await this.RemoveUsers(toDelete) + await this.LockUsers(toDelete.map(u => u.userId)) } async CleanupNeverActiveUsers() { @@ -161,7 +161,15 @@ export default class { } this.log("Found", toDelete.length, "never active users to delete") - // await this.RemoveUsers(toDelete) TODO: activate deletion + await this.RemoveUsers(toDelete) + } + + async LockUsers(toLock: string[]) { + this.log("Locking", toLock.length, "users") + for (const userId of toLock) { + await this.storage.userStorage.BanUser(userId) + } + this.log("Locked users") } async RemoveUsers(toDelete: { userId: string, appUserIds: string[] }[]) { diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 2d5417e4..dc2ec847 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -452,8 +452,12 @@ export default class { } } - async GetTotalUsersBalance(txId?: string) { - const total = await this.dbs.Sum('User', "balance_sats", {}) + async GetTotalUsersBalance(excludeLocked?: boolean, txId?: string) { + const where: { locked?: boolean } = {} + if (excludeLocked) { + where.locked = false + } + const total = await this.dbs.Sum('User', "balance_sats", where, txId) return total || 0 } From 432f9d0b42529961a65e103b14fa3fd130ebbd21 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Mon, 2 Mar 2026 14:33:53 -0500 Subject: [PATCH 38/49] bump never active to 90 --- src/services/main/appUserManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index 2a2ade08..b81f8e2e 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -137,7 +137,7 @@ export default class { async CleanupNeverActiveUsers() { this.log("Cleaning up never active users") - const inactiveUsers = await this.storage.userStorage.GetInactiveUsers(30) + const inactiveUsers = await this.storage.userStorage.GetInactiveUsers(90) const toDelete: { userId: string, appUserIds: string[] }[] = [] for (const u of inactiveUsers) { const user = await this.storage.userStorage.GetUser(u.user_id) From 7af841c33047dcc036030aea2bfcd43b26b1a6d3 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Mon, 2 Mar 2026 14:44:32 -0500 Subject: [PATCH 39/49] cleanup db fix --- src/services/storage/paymentStorage.ts | 20 ++++++++++++++++++-- src/services/storage/productStorage.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index dc2ec847..092ef8df 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -138,7 +138,15 @@ export default class { } async RemoveUserInvoices(userId: string, txId?: string) { - return this.dbs.Delete('UserReceivingInvoice', { user: { user_id: userId } }, txId) + const invoices = await this.dbs.Find('UserReceivingInvoice', { where: { user: { user_id: userId } } }, txId) + if (invoices.length === 0) { + return 0 + } + let deleted = 0 + for (const invoice of invoices) { + deleted += await this.dbs.Delete('UserReceivingInvoice', invoice.serial_id, txId) + } + return deleted } async GetAddressOwner(address: string, txId?: string): Promise { @@ -322,7 +330,15 @@ export default class { } async RemoveUserEphemeralKeys(userId: string, txId?: string) { - return this.dbs.Delete('UserEphemeralKey', { user: { user_id: userId } }, txId) + const keys = await this.dbs.Find('UserEphemeralKey', { where: { user: { user_id: userId } } }, txId) + if (keys.length === 0) { + return 0 + } + let deleted = 0 + for (const key of keys) { + deleted += await this.dbs.Delete('UserEphemeralKey', key.serial_id, txId) + } + return deleted } async AddPendingUserToUserPayment(fromUserId: string, toUserId: string, amount: number, fee: number, linkedApplication: Application, txId: string) { diff --git a/src/services/storage/productStorage.ts b/src/services/storage/productStorage.ts index e6f2f662..75f3da0d 100644 --- a/src/services/storage/productStorage.ts +++ b/src/services/storage/productStorage.ts @@ -21,6 +21,14 @@ export default class { } async RemoveUserProducts(userId: string, txId?: string) { - return this.dbs.Delete('Product', { owner: { user_id: userId } }, txId) + const products = await this.dbs.Find('Product', { where: { owner: { user_id: userId } } }, txId) + if (products.length === 0) { + return 0 + } + let deleted = 0 + for (const product of products) { + deleted += await this.dbs.Delete('Product', { product_id: product.product_id }, txId) + } + return deleted } } \ No newline at end of file From be6f48427fb82b97571928a4b007674a1dbdbec1 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Mon, 2 Mar 2026 14:56:48 -0500 Subject: [PATCH 40/49] clean users table --- src/services/main/appUserManager.ts | 3 ++- src/services/storage/userStorage.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index b81f8e2e..8bb56e21 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -183,11 +183,12 @@ export default class { await this.storage.offerStorage.DeleteUserOffers(appUserId, tx) await this.storage.debitStorage.RemoveUserDebitAccess(appUserId, tx) await this.storage.applicationStorage.RemoveAppUserDevices(appUserId, tx) - } await this.storage.paymentStorage.RemoveUserInvoices(userId, tx) await this.storage.productStorage.RemoveUserProducts(userId, tx) await this.storage.paymentStorage.RemoveUserEphemeralKeys(userId, tx) + await this.storage.userStorage.DeleteUserAccess(userId, tx) + await this.storage.applicationStorage.RemoveAppUsersAndBaseUsers(appUserIds, userId, tx) }) } this.log("Cleaned up inactive users") diff --git a/src/services/storage/userStorage.ts b/src/services/storage/userStorage.ts index d3453346..a1541bac 100644 --- a/src/services/storage/userStorage.ts +++ b/src/services/storage/userStorage.ts @@ -126,4 +126,8 @@ export default class { const lastSeenAtUnix = now - seconds return this.dbs.Find('UserAccess', { where: { last_seen_at_unix: LessThan(lastSeenAtUnix) } }) } + + async DeleteUserAccess(userId: string, txId?: string) { + return this.dbs.Delete('UserAccess', { user_id: userId }, txId) + } } From cfb7dd1e6ecfde1f96ee62d6da3608601df143c2 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Mon, 2 Mar 2026 15:01:12 -0500 Subject: [PATCH 41/49] serial id --- src/services/main/appUserManager.ts | 5 +++++ src/services/storage/applicationStorage.ts | 14 ++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index 8bb56e21..b87a648d 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -176,6 +176,11 @@ export default class { this.log("Deleting", toDelete.length, "inactive users") for (let i = 0; i < toDelete.length; i++) { const { userId, appUserIds } = toDelete[i] + const user = await this.storage.userStorage.FindUser(userId) + if (!user || user.balance_sats > 0) { + if (user) this.log("Skipping user", userId, "has balance", user.balance_sats) + continue + } this.log("Deleting user", userId, "progress", i + 1, "/", toDelete.length) await this.storage.StartTransaction(async tx => { for (const appUserId of appUserIds) { diff --git a/src/services/storage/applicationStorage.ts b/src/services/storage/applicationStorage.ts index 08aad37a..77438ee3 100644 --- a/src/services/storage/applicationStorage.ts +++ b/src/services/storage/applicationStorage.ts @@ -161,10 +161,16 @@ export default class { this.dbs.Remove('User', baseUser, txId) } - async RemoveAppUsersAndBaseUsers(appUserIds: string[],baseUser:string, txId?: string) { - await this.dbs.Delete('ApplicationUser', { identifier: In(appUserIds) }, txId) - await this.dbs.Delete('User', { user_id: baseUser }, txId) - + async RemoveAppUsersAndBaseUsers(appUserIds: string[], baseUser: string, txId?: string) { + if (appUserIds.length > 0) { + const appUsers = await this.dbs.Find('ApplicationUser', { where: { identifier: In(appUserIds) } }, txId) + for (const appUser of appUsers) { + await this.dbs.Delete('ApplicationUser', appUser.serial_id, txId) + } + } + const user = await this.userStorage.FindUser(baseUser, txId) + if (!user) return + await this.dbs.Delete('User', user.serial_id, txId) } From bfa71f743918ccaaf9025c63224a234e8a258bd8 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Mon, 2 Mar 2026 15:08:56 -0500 Subject: [PATCH 42/49] cleanup fix --- src/services/storage/applicationStorage.ts | 12 ++++++------ src/services/storage/paymentStorage.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/services/storage/applicationStorage.ts b/src/services/storage/applicationStorage.ts index 77438ee3..dba5b466 100644 --- a/src/services/storage/applicationStorage.ts +++ b/src/services/storage/applicationStorage.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { Between, FindOperator, IsNull, LessThanOrEqual, MoreThanOrEqual, In } from "typeorm" +import { Between, FindOperator, IsNull, LessThanOrEqual, MoreThanOrEqual } from "typeorm" import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { Application } from "./entity/Application.js" import UserStorage from './userStorage.js'; @@ -162,15 +162,15 @@ export default class { } async RemoveAppUsersAndBaseUsers(appUserIds: string[], baseUser: string, txId?: string) { - if (appUserIds.length > 0) { - const appUsers = await this.dbs.Find('ApplicationUser', { where: { identifier: In(appUserIds) } }, txId) - for (const appUser of appUsers) { - await this.dbs.Delete('ApplicationUser', appUser.serial_id, txId) + for (const appUserId of appUserIds) { + const appUser = await this.dbs.FindOne('ApplicationUser', { where: { identifier: appUserId } }, txId) + if (appUser) { + await this.dbs.Delete('ApplicationUser', { serial_id: appUser.serial_id }, txId) } } const user = await this.userStorage.FindUser(baseUser, txId) if (!user) return - await this.dbs.Delete('User', user.serial_id, txId) + await this.dbs.Delete('User', { serial_id: user.serial_id }, txId) } diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 092ef8df..16f17f75 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -144,7 +144,7 @@ export default class { } let deleted = 0 for (const invoice of invoices) { - deleted += await this.dbs.Delete('UserReceivingInvoice', invoice.serial_id, txId) + deleted += await this.dbs.Delete('UserReceivingInvoice', { serial_id: invoice.serial_id }, txId) } return deleted } @@ -336,7 +336,7 @@ export default class { } let deleted = 0 for (const key of keys) { - deleted += await this.dbs.Delete('UserEphemeralKey', key.serial_id, txId) + deleted += await this.dbs.Delete('UserEphemeralKey', { serial_id: key.serial_id }, txId) } return deleted } From c18b79dc5033cfacd4516cccd2470a0d8427f9b3 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Mon, 2 Mar 2026 15:10:24 -0500 Subject: [PATCH 43/49] use number --- src/services/storage/applicationStorage.ts | 4 ++-- src/services/storage/paymentStorage.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/storage/applicationStorage.ts b/src/services/storage/applicationStorage.ts index dba5b466..402e7773 100644 --- a/src/services/storage/applicationStorage.ts +++ b/src/services/storage/applicationStorage.ts @@ -165,12 +165,12 @@ export default class { for (const appUserId of appUserIds) { const appUser = await this.dbs.FindOne('ApplicationUser', { where: { identifier: appUserId } }, txId) if (appUser) { - await this.dbs.Delete('ApplicationUser', { serial_id: appUser.serial_id }, txId) + await this.dbs.Delete('ApplicationUser', appUser.serial_id, txId) } } const user = await this.userStorage.FindUser(baseUser, txId) if (!user) return - await this.dbs.Delete('User', { serial_id: user.serial_id }, txId) + await this.dbs.Delete('User', user.serial_id, txId) } diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 16f17f75..092ef8df 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -144,7 +144,7 @@ export default class { } let deleted = 0 for (const invoice of invoices) { - deleted += await this.dbs.Delete('UserReceivingInvoice', { serial_id: invoice.serial_id }, txId) + deleted += await this.dbs.Delete('UserReceivingInvoice', invoice.serial_id, txId) } return deleted } @@ -336,7 +336,7 @@ export default class { } let deleted = 0 for (const key of keys) { - deleted += await this.dbs.Delete('UserEphemeralKey', { serial_id: key.serial_id }, txId) + deleted += await this.dbs.Delete('UserEphemeralKey', key.serial_id, txId) } return deleted } From 595e5bb2578ecbc0a4048f6c29609cf35c913f8d Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Mon, 2 Mar 2026 15:16:08 -0500 Subject: [PATCH 44/49] fk fix --- src/services/main/appUserManager.ts | 4 +++ src/services/storage/paymentStorage.ts | 36 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index b87a648d..b9bf8742 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -192,6 +192,10 @@ export default class { await this.storage.paymentStorage.RemoveUserInvoices(userId, tx) await this.storage.productStorage.RemoveUserProducts(userId, tx) await this.storage.paymentStorage.RemoveUserEphemeralKeys(userId, tx) + await this.storage.paymentStorage.RemoveUserInvoicePayments(userId, tx) + await this.storage.paymentStorage.RemoveUserTransactionPayments(userId, tx) + await this.storage.paymentStorage.RemoveUserToUserPayments(userId, tx) + await this.storage.paymentStorage.RemoveUserReceivingAddresses(userId, tx) await this.storage.userStorage.DeleteUserAccess(userId, tx) await this.storage.applicationStorage.RemoveAppUsersAndBaseUsers(appUserIds, userId, tx) }) diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 092ef8df..af96b6c5 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -341,6 +341,42 @@ export default class { return deleted } + async RemoveUserReceivingAddresses(userId: string, txId?: string) { + const addresses = await this.dbs.Find('UserReceivingAddress', { where: { user: { user_id: userId } } }, txId) + for (const addr of addresses) { + const txs = await this.dbs.Find('AddressReceivingTransaction', { where: { user_address: { serial_id: addr.serial_id } } }, txId) + for (const tx of txs) { + await this.dbs.Delete('AddressReceivingTransaction', tx.serial_id, txId) + } + await this.dbs.Delete('UserReceivingAddress', addr.serial_id, txId) + } + } + + async RemoveUserInvoicePayments(userId: string, txId?: string) { + const payments = await this.dbs.Find('UserInvoicePayment', { where: { user: { user_id: userId } } }, txId) + for (const p of payments) { + await this.dbs.Delete('UserInvoicePayment', p.serial_id, txId) + } + } + + async RemoveUserTransactionPayments(userId: string, txId?: string) { + const payments = await this.dbs.Find('UserTransactionPayment', { where: { user: { user_id: userId } } }, txId) + for (const p of payments) { + await this.dbs.Delete('UserTransactionPayment', p.serial_id, txId) + } + } + + async RemoveUserToUserPayments(userId: string, txId?: string) { + const asSender = await this.dbs.Find('UserToUserPayment', { where: { from_user: { user_id: userId } } }, txId) + const asReceiver = await this.dbs.Find('UserToUserPayment', { where: { to_user: { user_id: userId } } }, txId) + const seen = new Set() + for (const p of [...asSender, ...asReceiver]) { + if (seen.has(p.serial_id)) continue + seen.add(p.serial_id) + await this.dbs.Delete('UserToUserPayment', p.serial_id, txId) + } + } + async AddPendingUserToUserPayment(fromUserId: string, toUserId: string, amount: number, fee: number, linkedApplication: Application, txId: string) { return this.dbs.CreateAndSave('UserToUserPayment', { from_user: await this.userStorage.GetUser(fromUserId, txId), From 7c8cca0a557f69cbcccf96176a580dc611660436 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Tue, 3 Mar 2026 19:49:54 +0000 Subject: [PATCH 45/49] refund failed swaps --- src/services/lnd/swaps/submarineSwaps.ts | 48 ++++++++++++++++++------ src/services/lnd/swaps/swaps.ts | 2 + 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index 462f4751..ffd8a5d5 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -140,7 +140,9 @@ export class SubmarineSwaps { privateKey: ECPairInterface, refundAddress: string, feePerVbyte: number, - cooperative: boolean = true + cooperative: boolean = true, + allowUncooperativeFallback: boolean = true, + cooperativeErrorMessage?: string ): Promise<{ ok: true, transaction: Transaction, @@ -192,7 +194,11 @@ export class SubmarineSwaps { ) if (!cooperative) { - return { ok: true, transaction: refundTx } + return { + ok: true, + transaction: refundTx, + cooperativeError: cooperativeErrorMessage, + } } // For cooperative refund, get Boltz's partial signature @@ -210,7 +216,11 @@ export class SubmarineSwaps { ) if (!boltzSigRes.ok) { - this.log(ERROR, 'Failed to get Boltz partial signature, falling back to uncooperative refund') + this.log(ERROR, 'Failed to get Boltz partial signature') + if (!allowUncooperativeFallback) { + return { ok: false, error: `Failed to get Boltz partial signature: ${boltzSigRes.error}` } + } + this.log(ERROR, 'Falling back to uncooperative refund') // Fallback to uncooperative refund return await this.constructTaprootRefund( swapId, @@ -221,7 +231,9 @@ export class SubmarineSwaps { privateKey, refundAddress, feePerVbyte, - false + false, + allowUncooperativeFallback, + boltzSigRes.error ) } @@ -253,6 +265,9 @@ export class SubmarineSwaps { return { ok: true, transaction: refundTx } } catch (error: any) { this.log(ERROR, 'Cooperative refund failed:', error.message) + if (!allowUncooperativeFallback) { + return { ok: false, error: `Cooperative refund failed: ${error.message}` } + } // Fallback to uncooperative refund return await this.constructTaprootRefund( swapId, @@ -263,7 +278,9 @@ export class SubmarineSwaps { privateKey, refundAddress, feePerVbyte, - false + false, + allowUncooperativeFallback, + error.message ) } } @@ -304,9 +321,10 @@ export class SubmarineSwaps { refundAddress: string, currentHeight: number, lockupTxHex?: string, - feePerVbyte?: number + feePerVbyte?: number, + allowEarlyRefund?: boolean }): Promise<{ ok: true, publish: { done: false, txHex: string, txId: string } | { done: true, txId: string } } | { ok: false, error: string }> => { - const { swapId, claimPublicKey, swapTree, timeoutBlockHeight, privateKeyHex, refundAddress, currentHeight, lockupTxHex, feePerVbyte = 2 } = params + const { swapId, claimPublicKey, swapTree, timeoutBlockHeight, privateKeyHex, refundAddress, currentHeight, lockupTxHex, feePerVbyte = 2, allowEarlyRefund = false } = params this.log('Starting refund process for swap:', swapId) @@ -325,14 +343,21 @@ export class SubmarineSwaps { } this.log('Lockup transaction retrieved:', lockupTx.getId()) - // Check if swap has timed out - if (currentHeight < timeoutBlockHeight) { + const hasTimedOut = currentHeight >= timeoutBlockHeight + + // For stuck swaps, only allow refund after timeout. For completed (failed) swaps, + // we may attempt a cooperative refund before timeout. + if (!hasTimedOut && !allowEarlyRefund) { return { ok: false, error: `Swap has not timed out yet. Current height: ${currentHeight}, timeout: ${timeoutBlockHeight}` } } - this.log(`Swap has timed out. Current height: ${currentHeight}, timeout: ${timeoutBlockHeight}`) + if (hasTimedOut) { + this.log(`Swap has timed out. Current height: ${currentHeight}, timeout: ${timeoutBlockHeight}`) + } else { + this.log(`Swap has not timed out yet, attempting cooperative refund`) + } // Parse the private key const privateKey = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKeyHex, 'hex')) @@ -347,7 +372,8 @@ export class SubmarineSwaps { privateKey, refundAddress, feePerVbyte, - true // Try cooperative first + true, // Try cooperative first + hasTimedOut // only allow uncooperative fallback once timeout has passed ) if (!refundTxRes.ok) { diff --git a/src/services/lnd/swaps/swaps.ts b/src/services/lnd/swaps/swaps.ts index dc1590b4..0aaf9e32 100644 --- a/src/services/lnd/swaps/swaps.ts +++ b/src/services/lnd/swaps/swaps.ts @@ -112,6 +112,7 @@ export class Swaps { if (!swap) { throw new Error("Swap not found or already used") } + const allowEarlyRefund = !!swap.failure_reason const swapper = this.subSwappers[swap.service_url] if (!swapper) { throw new Error("swapper service not found") @@ -124,6 +125,7 @@ export class Swaps { refundAddress, swapTree: swap.swap_tree, timeoutBlockHeight: swap.timeout_block_height, + allowEarlyRefund, feePerVbyte: satPerVByte, lockupTxHex: swap.lockup_tx_hex, }) From 169284021f610339912f484bc400d9ebbf26f087 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Mar 2026 17:19:42 +0000 Subject: [PATCH 46/49] bump fee api --- proto/autogenerated/client.md | 17 +++++++++++ proto/autogenerated/go/http_client.go | 25 ++++++++++++++++ proto/autogenerated/go/types.go | 5 ++++ proto/autogenerated/ts/express_server.ts | 22 ++++++++++++++ proto/autogenerated/ts/http_client.ts | 11 +++++++ proto/autogenerated/ts/nostr_client.ts | 12 ++++++++ proto/autogenerated/ts/nostr_transport.ts | 16 ++++++++++ proto/autogenerated/ts/types.ts | 36 +++++++++++++++++++++-- proto/service/methods.proto | 9 +++++- proto/service/structs.proto | 7 +++++ src/services/lnd/lnd.ts | 14 +++++++++ src/services/main/adminManager.ts | 4 +++ 12 files changed, 175 insertions(+), 3 deletions(-) diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index eb71d022..d42fc9ac 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -58,6 +58,11 @@ The nostr server will send back a message response, and inside the body there wi - This methods has an __empty__ __request__ body - This methods has an __empty__ __response__ body +- BumpTx + - auth type: __Admin__ + - input: [BumpTx](#BumpTx) + - This methods has an __empty__ __response__ body + - CloseChannel - auth type: __Admin__ - input: [CloseChannelRequest](#CloseChannelRequest) @@ -509,6 +514,13 @@ The nostr server will send back a message response, and inside the body there wi - This methods has an __empty__ __request__ body - This methods has an __empty__ __response__ body +- BumpTx + - auth type: __Admin__ + - http method: __post__ + - http route: __/api/admin/tx/bump__ + - input: [BumpTx](#BumpTx) + - This methods has an __empty__ __response__ body + - CloseChannel - auth type: __Admin__ - http method: __post__ @@ -1241,6 +1253,11 @@ The nostr server will send back a message response, and inside the body there wi - __nextRelay__: _string_ *this field is optional - __type__: _string_ +### BumpTx + - __output_index__: _number_ + - __sat_per_vbyte__: _number_ + - __txid__: _string_ + ### BundleData - __available_chunks__: ARRAY of: _number_ - __base_64_data__: ARRAY of: _string_ diff --git a/proto/autogenerated/go/http_client.go b/proto/autogenerated/go/http_client.go index 1d5219a6..43d60ce2 100644 --- a/proto/autogenerated/go/http_client.go +++ b/proto/autogenerated/go/http_client.go @@ -66,6 +66,7 @@ type Client struct { BanDebit func(req DebitOperation) error BanUser func(req BanUserRequest) (*BanUserResponse, error) // batching method: BatchUser not implemented + BumpTx func(req BumpTx) error CloseChannel func(req CloseChannelRequest) (*CloseChannelResponse, error) CreateOneTimeInviteLink func(req CreateOneTimeInviteLinkRequest) (*CreateOneTimeInviteLinkResponse, error) DecodeInvoice func(req DecodeInvoiceRequest) (*DecodeInvoiceResponse, error) @@ -465,6 +466,30 @@ func NewClient(params ClientParams) *Client { return &res, nil }, // batching method: BatchUser not implemented + BumpTx: func(req BumpTx) error { + auth, err := params.RetrieveAdminAuth() + if err != nil { + return err + } + finalRoute := "/api/admin/tx/bump" + body, err := json.Marshal(req) + if err != nil { + return err + } + resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) + if err != nil { + return err + } + result := ResultError{} + err = json.Unmarshal(resBody, &result) + if err != nil { + return err + } + if result.Status == "ERROR" { + return fmt.Errorf(result.Reason) + } + return nil + }, CloseChannel: func(req CloseChannelRequest) (*CloseChannelResponse, error) { auth, err := params.RetrieveAdminAuth() if err != nil { diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index fd20980a..cff46783 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -213,6 +213,11 @@ type BeaconData struct { Nextrelay string `json:"nextRelay"` Type string `json:"type"` } +type BumpTx struct { + Output_index int64 `json:"output_index"` + Sat_per_vbyte int64 `json:"sat_per_vbyte"` + Txid string `json:"txid"` +} type BundleData struct { Available_chunks []int64 `json:"available_chunks"` Base_64_data []string `json:"base_64_data"` diff --git a/proto/autogenerated/ts/express_server.ts b/proto/autogenerated/ts/express_server.ts index c0378ffa..aa809166 100644 --- a/proto/autogenerated/ts/express_server.ts +++ b/proto/autogenerated/ts/express_server.ts @@ -693,6 +693,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { opts.metricsCallback([{ ...info, ...stats, ...ctx }, ...callsMetrics]) } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }) + if (!opts.allowNotImplementedMethods && !methods.BumpTx) throw new Error('method: BumpTx is not implemented') + app.post('/api/admin/tx/bump', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'BumpTx', batch: false, nostr: false, batchSize: 0} + const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } + let authCtx: Types.AuthContext = {} + try { + if (!methods.BumpTx) throw new Error('method: BumpTx is not implemented') + const authContext = await opts.AdminAuthGuard(req.headers['authorization']) + authCtx = authContext + stats.guard = process.hrtime.bigint() + const request = req.body + const error = Types.BumpTxValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback) + const query = req.query + const params = req.params + await methods.BumpTx({rpcName:'BumpTx', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res.json({status: 'OK'}) + 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 } + }) if (!opts.allowNotImplementedMethods && !methods.CloseChannel) throw new Error('method: CloseChannel is not implemented') app.post('/api/admin/channel/close', async (req, res) => { const info: Types.RequestInfo = { rpcName: 'CloseChannel', batch: false, nostr: false, batchSize: 0} diff --git a/proto/autogenerated/ts/http_client.ts b/proto/autogenerated/ts/http_client.ts index ed68348f..21597e2c 100644 --- a/proto/autogenerated/ts/http_client.ts +++ b/proto/autogenerated/ts/http_client.ts @@ -176,6 +176,17 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, + BumpTx: async (request: Types.BumpTx): Promise => { + const auth = await params.retrieveAdminAuth() + if (auth === null) throw new Error('retrieveAdminAuth() returned null') + let finalRoute = '/api/admin/tx/bump' + const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + return data + } + return { status: 'ERROR', reason: 'invalid response' } + }, CloseChannel: async (request: Types.CloseChannelRequest): Promise => { const auth = await params.retrieveAdminAuth() if (auth === null) throw new Error('retrieveAdminAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_client.ts b/proto/autogenerated/ts/nostr_client.ts index b00e8f1a..64e517e6 100644 --- a/proto/autogenerated/ts/nostr_client.ts +++ b/proto/autogenerated/ts/nostr_client.ts @@ -137,6 +137,18 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, + BumpTx: async (request: Types.BumpTx): Promise => { + const auth = await params.retrieveNostrAdminAuth() + if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') + const nostrRequest: NostrRequest = {} + nostrRequest.body = request + const data = await send(params.pubDestination, {rpcName:'BumpTx',authIdentifier:auth, ...nostrRequest }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + return data + } + return { status: 'ERROR', reason: 'invalid response' } + }, CloseChannel: async (request: Types.CloseChannelRequest): Promise => { const auth = await params.retrieveNostrAdminAuth() if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_transport.ts b/proto/autogenerated/ts/nostr_transport.ts index c58b3283..80284e10 100644 --- a/proto/autogenerated/ts/nostr_transport.ts +++ b/proto/autogenerated/ts/nostr_transport.ts @@ -575,6 +575,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => { opts.metricsCallback([{ ...info, ...stats, ...ctx }, ...callsMetrics]) }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 'BumpTx': + try { + if (!methods.BumpTx) throw new Error('method: BumpTx is not implemented') + const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + const request = req.body + const error = Types.BumpTxValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback) + await methods.BumpTx({rpcName:'BumpTx', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res({status: 'OK'}) + 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 'CloseChannel': try { if (!methods.CloseChannel) throw new Error('method: CloseChannel is not implemented') diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index 1980e49b..90cd1724 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -7,8 +7,8 @@ export type RequestMetric = AuthContext & RequestInfo & RequestStats & { error?: export type AdminContext = { admin_id: string } -export type AdminMethodInputs = AddApp_Input | AddPeer_Input | AuthApp_Input | BanUser_Input | CloseChannel_Input | CreateOneTimeInviteLink_Input | GetAdminInvoiceSwapQuotes_Input | GetAdminTransactionSwapQuotes_Input | GetAssetsAndLiabilities_Input | GetInviteLinkState_Input | GetSeed_Input | ListAdminInvoiceSwaps_Input | ListAdminTxSwaps_Input | ListChannels_Input | LndGetInfo_Input | OpenChannel_Input | PayAdminInvoiceSwap_Input | PayAdminTransactionSwap_Input | RefundAdminInvoiceSwap_Input | UpdateChannelPolicy_Input -export type AdminMethodOutputs = AddApp_Output | AddPeer_Output | AuthApp_Output | BanUser_Output | CloseChannel_Output | CreateOneTimeInviteLink_Output | GetAdminInvoiceSwapQuotes_Output | GetAdminTransactionSwapQuotes_Output | GetAssetsAndLiabilities_Output | GetInviteLinkState_Output | GetSeed_Output | ListAdminInvoiceSwaps_Output | ListAdminTxSwaps_Output | ListChannels_Output | LndGetInfo_Output | OpenChannel_Output | PayAdminInvoiceSwap_Output | PayAdminTransactionSwap_Output | RefundAdminInvoiceSwap_Output | UpdateChannelPolicy_Output +export type AdminMethodInputs = AddApp_Input | AddPeer_Input | AuthApp_Input | BanUser_Input | BumpTx_Input | CloseChannel_Input | CreateOneTimeInviteLink_Input | GetAdminInvoiceSwapQuotes_Input | GetAdminTransactionSwapQuotes_Input | GetAssetsAndLiabilities_Input | GetInviteLinkState_Input | GetSeed_Input | ListAdminInvoiceSwaps_Input | ListAdminTxSwaps_Input | ListChannels_Input | LndGetInfo_Input | OpenChannel_Input | PayAdminInvoiceSwap_Input | PayAdminTransactionSwap_Input | RefundAdminInvoiceSwap_Input | UpdateChannelPolicy_Input +export type AdminMethodOutputs = AddApp_Output | AddPeer_Output | AuthApp_Output | BanUser_Output | BumpTx_Output | CloseChannel_Output | CreateOneTimeInviteLink_Output | GetAdminInvoiceSwapQuotes_Output | GetAdminTransactionSwapQuotes_Output | GetAssetsAndLiabilities_Output | GetInviteLinkState_Output | GetSeed_Output | ListAdminInvoiceSwaps_Output | ListAdminTxSwaps_Output | ListChannels_Output | LndGetInfo_Output | OpenChannel_Output | PayAdminInvoiceSwap_Output | PayAdminTransactionSwap_Output | RefundAdminInvoiceSwap_Output | UpdateChannelPolicy_Output export type AppContext = { app_id: string } @@ -75,6 +75,9 @@ export type BanUser_Output = ResultError | ({ status: 'OK' } & BanUserResponse) export type BatchUser_Input = UserMethodInputs export type BatchUser_Output = UserMethodOutputs +export type BumpTx_Input = {rpcName:'BumpTx', req: BumpTx} +export type BumpTx_Output = ResultError | { status: 'OK' } + export type CloseChannel_Input = {rpcName:'CloseChannel', req: CloseChannelRequest} export type CloseChannel_Output = ResultError | ({ status: 'OK' } & CloseChannelResponse) @@ -364,6 +367,7 @@ export type ServerMethods = { AuthorizeManage?: (req: AuthorizeManage_Input & {ctx: UserContext }) => Promise BanDebit?: (req: BanDebit_Input & {ctx: UserContext }) => Promise BanUser?: (req: BanUser_Input & {ctx: AdminContext }) => Promise + BumpTx?: (req: BumpTx_Input & {ctx: AdminContext }) => Promise CloseChannel?: (req: CloseChannel_Input & {ctx: AdminContext }) => Promise CreateOneTimeInviteLink?: (req: CreateOneTimeInviteLink_Input & {ctx: AdminContext }) => Promise DecodeInvoice?: (req: DecodeInvoice_Input & {ctx: UserContext }) => Promise @@ -1209,6 +1213,34 @@ export const BeaconDataValidate = (o?: BeaconData, opts: BeaconDataOptions = {}, return null } +export type BumpTx = { + output_index: number + sat_per_vbyte: number + txid: string +} +export const BumpTxOptionalFields: [] = [] +export type BumpTxOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + output_index_CustomCheck?: (v: number) => boolean + sat_per_vbyte_CustomCheck?: (v: number) => boolean + txid_CustomCheck?: (v: string) => boolean +} +export const BumpTxValidate = (o?: BumpTx, opts: BumpTxOptions = {}, path: string = 'BumpTx::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') + + if (typeof o.output_index !== 'number') return new Error(`${path}.output_index: is not a number`) + if (opts.output_index_CustomCheck && !opts.output_index_CustomCheck(o.output_index)) return new Error(`${path}.output_index: custom check failed`) + + if (typeof o.sat_per_vbyte !== 'number') return new Error(`${path}.sat_per_vbyte: is not a number`) + if (opts.sat_per_vbyte_CustomCheck && !opts.sat_per_vbyte_CustomCheck(o.sat_per_vbyte)) return new Error(`${path}.sat_per_vbyte: custom check failed`) + + if (typeof o.txid !== 'string') return new Error(`${path}.txid: is not a string`) + if (opts.txid_CustomCheck && !opts.txid_CustomCheck(o.txid)) return new Error(`${path}.txid: custom check failed`) + + return null +} + export type BundleData = { available_chunks: number[] base_64_data: string[] diff --git a/proto/service/methods.proto b/proto/service/methods.proto index d7f97860..2a54767b 100644 --- a/proto/service/methods.proto +++ b/proto/service/methods.proto @@ -117,7 +117,14 @@ service LightningPub { option (http_method) = "post"; option (http_route) = "/api/admin/assets/liabilities"; option (nostr) = true; - } + }; + + rpc BumpTx(structs.BumpTx) returns (structs.Empty) { + option (auth_type) = "Admin"; + option (http_method) = "post"; + option (http_route) = "/api/admin/tx/bump"; + option (nostr) = true; + }; rpc AddApp(structs.AddAppRequest) returns (structs.AuthApp) { option (auth_type) = "Admin"; diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 8e91d9c4..3d64ef67 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -25,6 +25,13 @@ message AssetsAndLiabilitiesReq { optional int64 limit_providers = 4; } +message BumpTx { + string txid = 1; + int64 output_index = 2; + int64 sat_per_vbyte = 3; +} + + enum TrackedOperationType { USER = 0; ROOT = 1; diff --git a/src/services/lnd/lnd.ts b/src/services/lnd/lnd.ts index 673ff073..eba48f6d 100644 --- a/src/services/lnd/lnd.ts +++ b/src/services/lnd/lnd.ts @@ -642,6 +642,20 @@ export default class { return res.response } + async BumpFee(txId: string, outputIndex: number, satPerVbyte: number) { + this.log(DEBUG, "Bumping fee") + const res = await this.walletKit.bumpFee({ + budget: 0n, immediate: false, targetConf: 0, satPerVbyte: BigInt(satPerVbyte), outpoint: { + txidStr: txId, + outputIndex: outputIndex, + txidBytes: Buffer.alloc(0) + }, + force: false, + satPerByte: 0 + }, DeadLineMetadata()) + return res.response + } + async GetPayment(paymentIndex: number) { this.log(DEBUG, "Getting payment") if (paymentIndex === 0) { diff --git a/src/services/main/adminManager.ts b/src/services/main/adminManager.ts index e0639547..dbde290d 100644 --- a/src/services/main/adminManager.ts +++ b/src/services/main/adminManager.ts @@ -588,6 +588,10 @@ export class AdminManager { return { ts, amount, tracked: undefined } } + async BumpTx(req: Types.BumpTx): Promise { + await this.lnd.BumpFee(req.txid, req.output_index, req.sat_per_vbyte) + } + } const getDataPath = (dataDir: string, dataPath: string) => { From 74513a14122e71830bca7a7e1d2f80fd96ae0153 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Mar 2026 17:57:50 +0000 Subject: [PATCH 47/49] bimp fee --- src/services/serverMethods/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index 483ef0d7..d74e67fd 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -91,6 +91,14 @@ export default (mainHandler: Main): Types.ServerMethods => { if (err != null) throw new Error(err.message) return mainHandler.adminManager.CloseChannel(req) }, + BumpTx: async ({ ctx, req }) => { + const err = Types.BumpTxValidate(req, { + txid_CustomCheck: txid => txid !== '', + sat_per_vbyte_CustomCheck: spv => spv > 0 + }) + if (err != null) throw new Error(err.message) + return mainHandler.adminManager.BumpTx(req) + }, GetAdminTransactionSwapQuotes: async ({ ctx, req }) => { const err = Types.TransactionSwapRequestValidate(req, { transaction_amount_sats_CustomCheck: amt => amt > 0 From 70544bd5514d9565037cc10f51e5d0d2543303cc Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 4 Mar 2026 18:26:21 +0000 Subject: [PATCH 48/49] rate fix --- src/services/lnd/swaps/submarineSwaps.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/services/lnd/swaps/submarineSwaps.ts b/src/services/lnd/swaps/submarineSwaps.ts index ffd8a5d5..6e4beba8 100644 --- a/src/services/lnd/swaps/submarineSwaps.ts +++ b/src/services/lnd/swaps/submarineSwaps.ts @@ -3,7 +3,7 @@ const zkpInit = (secp256k1ZkpModule as any).default || secp256k1ZkpModule; // import bolt11 from 'bolt11'; import { Musig, SwapTreeSerializer, TaprootUtils, constructRefundTransaction, - detectSwap, OutputType + detectSwap, OutputType, targetFee } from 'boltz-core'; import { randomBytes, createHash } from 'crypto'; import { ECPairFactory, ECPairInterface } from 'ecpair'; @@ -184,13 +184,16 @@ export class SubmarineSwaps { } ] const outputScript = address.toOutputScript(refundAddress, network) - // Construct the refund transaction - const refundTx = constructRefundTransaction( - details, - outputScript, - cooperative ? 0 : timeoutBlockHeight, + // Construct the refund transaction: targetFee converts sat/vbyte rate to flat fee + const refundTx = targetFee( feePerVbyte, - true + (fee) => constructRefundTransaction( + details, + outputScript, + cooperative ? 0 : timeoutBlockHeight, + fee, + true + ) ) if (!cooperative) { From 52d727e6c51678a084dc2c06fc091d0c0d7ef772 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 25 Feb 2026 11:29:57 -0500 Subject: [PATCH 49/49] Add NIP-47 (Nostr Wallet Connect) support Implements NWC protocol alongside the existing CLINK/Ndebit system, allowing any NWC-compatible wallet (Alby, Amethyst, Damus, etc.) to connect to a Lightning Pub node. Supported NIP-47 methods: pay_invoice, make_invoice, get_balance, get_info, lookup_invoice, list_transactions. New files: - NwcConnection entity with per-connection spending limits - NwcStorage for connection CRUD operations - NwcManager for NIP-47 request handling and connection management - Database migration for nwc_connection table Modified files: - nostrPool: subscribe to kind 23194 events - nostrMiddleware: route kind 23194 to NwcManager - main/index: wire NwcManager, publish kind 13194 info events - storage: register NwcConnection entity and NwcStorage Co-Authored-By: Claude Opus 4.6 --- src/nostrMiddleware.ts | 7 + src/services/main/index.ts | 9 + src/services/main/nwcManager.ts | 346 ++++++++++++++++++ src/services/nostr/nostrPool.ts | 2 +- src/services/storage/db/db.ts | 2 + src/services/storage/entity/NwcConnection.ts | 37 ++ src/services/storage/index.ts | 3 + .../1770000000000-nwc_connection.ts | 17 + src/services/storage/migrations/runner.ts | 5 +- src/services/storage/nwcStorage.ts | 49 +++ 10 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 src/services/main/nwcManager.ts create mode 100644 src/services/storage/entity/NwcConnection.ts create mode 100644 src/services/storage/migrations/1770000000000-nwc_connection.ts create mode 100644 src/services/storage/nwcStorage.ts diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index 034dbce8..250543c7 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -79,6 +79,13 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett const nmanageReq = j as NmanageRequest mainHandler.managementManager.handleRequest(nmanageReq, event); return; + } else if (event.kind === 23194) { + if (event.relayConstraint === 'provider') { + log("got NWC request on provider only relay, ignoring") + return + } + mainHandler.nwcManager.handleNwcRequest(event.content, event) + return } if (!j.rpcName) { if (event.relayConstraint === 'service') { diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 81de5ada..ebc7e7fb 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -26,6 +26,7 @@ import { OfferManager } from "./offerManager.js" import { parse } from "uri-template" import webRTC from "../webRTC/index.js" import { ManagementManager } from "./managementManager.js" +import { NwcManager } from "./nwcManager.js" import { Agent } from "https" import { NotificationsManager } from "./notificationsManager.js" import { ApplicationUser } from '../storage/entity/ApplicationUser.js' @@ -58,6 +59,7 @@ export default class { debitManager: DebitManager offerManager: OfferManager managementManager: ManagementManager + nwcManager: NwcManager utils: Utils rugPullTracker: RugPullTracker unlocker: Unlocker @@ -89,6 +91,7 @@ export default class { this.debitManager = new DebitManager(this.storage, this.lnd, this.applicationManager) this.offerManager = new OfferManager(this.storage, this.settings, this.lnd, this.applicationManager, this.productManager, this.liquidityManager) this.managementManager = new ManagementManager(this.storage, this.settings) + this.nwcManager = new NwcManager(this.storage, this.settings, this.lnd, this.applicationManager) this.notificationsManager = new NotificationsManager(this.settings) //this.webRTC = new webRTC(this.storage, this.utils) } @@ -104,6 +107,9 @@ export default class { StartBeacons() { this.applicationManager.StartAppsServiceBeacon((app, fees) => { this.UpdateBeacon(app, { type: 'service', name: app.name, avatarUrl: app.avatar_url, fees }) + if (app.nostr_public_key) { + this.nwcManager.publishNwcInfo(app.app_id, app.nostr_public_key) + } }) } @@ -533,6 +539,9 @@ export default class { const fees = this.paymentManager.GetFees() for (const app of apps) { await this.UpdateBeacon(app, { type: 'service', name: app.name, avatarUrl: app.avatar_url, nextRelay, fees }) + if (app.nostr_public_key) { + this.nwcManager.publishNwcInfo(app.app_id, app.nostr_public_key) + } } const defaultNames = ['wallet', 'wallet-test', this.settings.getSettings().serviceSettings.defaultAppName] diff --git a/src/services/main/nwcManager.ts b/src/services/main/nwcManager.ts new file mode 100644 index 00000000..d1800b1c --- /dev/null +++ b/src/services/main/nwcManager.ts @@ -0,0 +1,346 @@ +import { generateSecretKey, getPublicKey, UnsignedEvent } from 'nostr-tools' +import { bytesToHex } from '@noble/hashes/utils' +import ApplicationManager from "./applicationManager.js" +import Storage from '../storage/index.js' +import LND from "../lnd/lnd.js" +import { ERROR, getLogger } from "../helpers/logger.js" +import { NostrEvent } from '../nostr/nostrPool.js' +import SettingsManager from "./settingsManager.js" + +type NwcRequest = { + method: string + params: Record +} + +type NwcResponse = { + result_type: string + error?: { code: string, message: string } + result?: Record +} + +const NWC_REQUEST_KIND = 23194 +const NWC_RESPONSE_KIND = 23195 +const NWC_INFO_KIND = 13194 + +const SUPPORTED_METHODS = [ + 'pay_invoice', + 'make_invoice', + 'get_balance', + 'get_info', + 'lookup_invoice', + 'list_transactions', +] + +const NWC_ERRORS = { + UNAUTHORIZED: 'UNAUTHORIZED', + RESTRICTED: 'RESTRICTED', + PAYMENT_FAILED: 'PAYMENT_FAILED', + QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', + NOT_FOUND: 'NOT_FOUND', + INTERNAL: 'INTERNAL', + OTHER: 'OTHER', +} as const + +const newNwcResponse = (content: string, event: { pub: string, id: string }): UnsignedEvent => { + return { + content, + created_at: Math.floor(Date.now() / 1000), + kind: NWC_RESPONSE_KIND, + pubkey: "", + tags: [ + ['p', event.pub], + ['e', event.id], + ], + } +} + +export class NwcManager { + applicationManager: ApplicationManager + storage: Storage + settings: SettingsManager + lnd: LND + logger = getLogger({ component: 'NwcManager' }) + + constructor(storage: Storage, settings: SettingsManager, lnd: LND, applicationManager: ApplicationManager) { + this.storage = storage + this.settings = settings + this.lnd = lnd + this.applicationManager = applicationManager + } + + handleNwcRequest = async (content: string, event: NostrEvent) => { + if (!this.storage.NostrSender().IsReady()) { + this.logger(ERROR, "Nostr sender not ready, dropping NWC request") + return + } + + let request: NwcRequest + try { + request = JSON.parse(content) + } catch { + this.logger(ERROR, "invalid NWC request JSON") + this.sendError(event, 'unknown', NWC_ERRORS.OTHER, 'Invalid request JSON') + return + } + + const { method, params } = request + if (!method) { + this.sendError(event, 'unknown', NWC_ERRORS.OTHER, 'Missing method') + return + } + + const connection = await this.storage.nwcStorage.GetConnection(event.appId, event.pub) + if (!connection) { + this.logger("NWC request from unknown client pubkey:", event.pub) + this.sendError(event, method, NWC_ERRORS.UNAUTHORIZED, 'Unknown connection') + return + } + + if (connection.expires_at > 0 && connection.expires_at < Math.floor(Date.now() / 1000)) { + this.logger("NWC connection expired for client:", event.pub) + this.sendError(event, method, NWC_ERRORS.UNAUTHORIZED, 'Connection expired') + return + } + + if (connection.permissions && connection.permissions.length > 0 && !connection.permissions.includes(method)) { + this.sendError(event, method, NWC_ERRORS.RESTRICTED, `Method ${method} not permitted`) + return + } + + try { + switch (method) { + case 'pay_invoice': + await this.handlePayInvoice(event, params, connection.app_user_id, connection.max_amount, connection.total_spent) + break + case 'make_invoice': + await this.handleMakeInvoice(event, params, connection.app_user_id) + break + case 'get_balance': + await this.handleGetBalance(event, connection.app_user_id) + break + case 'get_info': + await this.handleGetInfo(event) + break + case 'lookup_invoice': + await this.handleLookupInvoice(event, params) + break + case 'list_transactions': + await this.handleListTransactions(event, params, connection.app_user_id) + break + default: + this.sendError(event, method, NWC_ERRORS.NOT_IMPLEMENTED, `Method ${method} not supported`) + } + } catch (e: any) { + this.logger(ERROR, `NWC ${method} failed:`, e.message || e) + this.sendError(event, method, NWC_ERRORS.INTERNAL, e.message || 'Internal error') + } + } + + private handlePayInvoice = async (event: NostrEvent, params: Record, appUserId: string, maxAmount: number, totalSpent: number) => { + const { invoice } = params + if (!invoice) { + this.sendError(event, 'pay_invoice', NWC_ERRORS.OTHER, 'Missing invoice parameter') + return + } + + if (maxAmount > 0) { + const decoded = await this.lnd.DecodeInvoice(invoice) + const amountSats = decoded.numSatoshis + if (amountSats > 0 && (totalSpent + amountSats) > maxAmount) { + this.sendError(event, 'pay_invoice', NWC_ERRORS.QUOTA_EXCEEDED, 'Spending limit exceeded') + return + } + } + + try { + const paid = await this.applicationManager.PayAppUserInvoice(event.appId, { + amount: 0, + invoice, + user_identifier: appUserId, + debit_npub: event.pub, + }) + await this.storage.nwcStorage.IncrementTotalSpent(event.appId, event.pub, paid.amount_paid + paid.service_fee) + this.sendResult(event, 'pay_invoice', { preimage: paid.preimage }) + } catch (e: any) { + this.sendError(event, 'pay_invoice', NWC_ERRORS.PAYMENT_FAILED, e.message || 'Payment failed') + } + } + + private handleMakeInvoice = async (event: NostrEvent, params: Record, appUserId: string) => { + const amountMsats = params.amount + if (amountMsats === undefined || amountMsats === null) { + this.sendError(event, 'make_invoice', NWC_ERRORS.OTHER, 'Missing amount parameter') + return + } + const amountSats = Math.floor(amountMsats / 1000) + const description = params.description || '' + const expiry = params.expiry || undefined + + const result = await this.applicationManager.AddAppUserInvoice(event.appId, { + receiver_identifier: appUserId, + payer_identifier: '', + http_callback_url: '', + invoice_req: { + amountSats, + memo: description, + expiry, + }, + }) + + this.sendResult(event, 'make_invoice', { + type: 'incoming', + invoice: result.invoice, + description, + description_hash: '', + preimage: '', + payment_hash: '', + amount: amountMsats, + fees_paid: 0, + created_at: Math.floor(Date.now() / 1000), + expires_at: Math.floor(Date.now() / 1000) + (expiry || 3600), + metadata: {}, + }) + } + + private handleGetBalance = async (event: NostrEvent, appUserId: string) => { + const app = await this.storage.applicationStorage.GetApplication(event.appId) + const appUser = await this.storage.applicationStorage.GetApplicationUser(app, appUserId) + const balanceMsats = appUser.user.balance_sats * 1000 + this.sendResult(event, 'get_balance', { balance: balanceMsats }) + } + + private handleGetInfo = async (event: NostrEvent) => { + const info = await this.lnd.GetInfo() + this.sendResult(event, 'get_info', { + alias: info.alias, + color: '', + pubkey: info.identityPubkey, + network: 'mainnet', + block_height: info.blockHeight, + block_hash: info.blockHash, + methods: SUPPORTED_METHODS, + }) + } + + private handleLookupInvoice = async (event: NostrEvent, params: Record) => { + const { invoice, payment_hash } = params + if (!invoice && !payment_hash) { + this.sendError(event, 'lookup_invoice', NWC_ERRORS.OTHER, 'Missing invoice or payment_hash parameter') + return + } + + if (invoice) { + const found = await this.storage.paymentStorage.GetInvoiceOwner(invoice) + if (!found) { + this.sendError(event, 'lookup_invoice', NWC_ERRORS.NOT_FOUND, 'Invoice not found') + return + } + this.sendResult(event, 'lookup_invoice', { + type: 'incoming', + invoice: found.invoice, + description: '', + description_hash: '', + preimage: '', + payment_hash: '', + amount: found.paid_amount * 1000, + fees_paid: 0, + created_at: Math.floor(found.created_at.getTime() / 1000), + settled_at: found.paid_at_unix > 0 ? found.paid_at_unix : undefined, + metadata: {}, + }) + } else { + this.sendError(event, 'lookup_invoice', NWC_ERRORS.NOT_IMPLEMENTED, 'Lookup by payment_hash not supported') + } + } + + private handleListTransactions = async (event: NostrEvent, params: Record, appUserId: string) => { + const app = await this.storage.applicationStorage.GetApplication(event.appId) + const appUser = await this.storage.applicationStorage.GetApplicationUser(app, appUserId) + const from = params.from || 0 + const limit = Math.min(params.limit || 50, 50) + + const invoices = await this.storage.paymentStorage.GetUserInvoicesFlaggedAsPaid(appUser.user.serial_id, 0, from, limit) + const transactions = invoices.map(inv => ({ + type: 'incoming' as const, + invoice: inv.invoice, + description: '', + description_hash: '', + preimage: '', + payment_hash: '', + amount: inv.paid_amount * 1000, + fees_paid: 0, + created_at: Math.floor(inv.created_at.getTime() / 1000), + settled_at: inv.paid_at_unix > 0 ? inv.paid_at_unix : undefined, + metadata: {}, + })) + + this.sendResult(event, 'list_transactions', { transactions }) + } + + // --- Connection management methods --- + + createConnection = async (appId: string, appUserId: string, permissions?: string[], options?: { maxAmount?: number, expiresAt?: number }) => { + const secretBytes = generateSecretKey() + const clientPubkey = getPublicKey(secretBytes) + const secret = bytesToHex(secretBytes) + + await this.storage.nwcStorage.AddConnection({ + app_id: appId, + app_user_id: appUserId, + client_pubkey: clientPubkey, + permissions, + max_amount: options?.maxAmount, + expires_at: options?.expiresAt, + }) + + const app = await this.storage.applicationStorage.GetApplication(appId) + const relays = this.settings.getSettings().nostrRelaySettings.relays + const relay = relays[0] || '' + const uri = `nostr+walletconnect://${app.nostr_public_key}?relay=${encodeURIComponent(relay)}&secret=${secret}` + return { uri, clientPubkey, secret } + } + + listConnections = async (appUserId: string) => { + return this.storage.nwcStorage.GetUserConnections(appUserId) + } + + revokeConnection = async (appId: string, clientPubkey: string) => { + return this.storage.nwcStorage.DeleteConnectionByPubkey(appId, clientPubkey) + } + + // --- Kind 13194 info event --- + + publishNwcInfo = (appId: string, appPubkey: string) => { + const content = SUPPORTED_METHODS.join(' ') + const event: UnsignedEvent = { + content, + created_at: Math.floor(Date.now() / 1000), + kind: NWC_INFO_KIND, + pubkey: appPubkey, + tags: [], + } + this.storage.NostrSender().Send({ type: 'app', appId }, { type: 'event', event }) + } + + // --- Response helpers --- + + private sendResult = (event: NostrEvent, resultType: string, result: Record) => { + const response: NwcResponse = { result_type: resultType, result } + const e = newNwcResponse(JSON.stringify(response), event) + this.storage.NostrSender().Send( + { type: 'app', appId: event.appId }, + { type: 'event', event: e, encrypt: { toPub: event.pub } } + ) + } + + private sendError = (event: NostrEvent, resultType: string, code: string, message: string) => { + const response: NwcResponse = { result_type: resultType, error: { code, message } } + const e = newNwcResponse(JSON.stringify(response), event) + this.storage.NostrSender().Send( + { type: 'app', appId: event.appId }, + { type: 'event', event: e, encrypt: { toPub: event.pub } } + ) + } +} diff --git a/src/services/nostr/nostrPool.ts b/src/services/nostr/nostrPool.ts index d41da382..1d3c0e5e 100644 --- a/src/services/nostr/nostrPool.ts +++ b/src/services/nostr/nostrPool.ts @@ -48,7 +48,7 @@ const splitContent = (content: string, maxLength: number) => { } return parts } -const actionKinds = [21000, 21001, 21002, 21003] +const actionKinds = [21000, 21001, 21002, 21003, 23194] const beaconKind = 30078 const appTag = "Lightning.Pub" export class NostrPool { diff --git a/src/services/storage/db/db.ts b/src/services/storage/db/db.ts index 63397c58..daa43aac 100644 --- a/src/services/storage/db/db.ts +++ b/src/services/storage/db/db.ts @@ -21,6 +21,7 @@ import { LndNodeInfo } from "../entity/LndNodeInfo.js" import { TrackedProvider } from "../entity/TrackedProvider.js" import { InviteToken } from "../entity/InviteToken.js" import { DebitAccess } from "../entity/DebitAccess.js" +import { NwcConnection } from "../entity/NwcConnection.js" import { RootOperation } from "../entity/RootOperation.js" import { UserOffer } from "../entity/UserOffer.js" import { ManagementGrant } from "../entity/ManagementGrant.js" @@ -71,6 +72,7 @@ export const MainDbEntities = { 'TrackedProvider': TrackedProvider, 'InviteToken': InviteToken, 'DebitAccess': DebitAccess, + 'NwcConnection': NwcConnection, 'UserOffer': UserOffer, 'Product': Product, 'ManagementGrant': ManagementGrant, diff --git a/src/services/storage/entity/NwcConnection.ts b/src/services/storage/entity/NwcConnection.ts new file mode 100644 index 00000000..628b49a2 --- /dev/null +++ b/src/services/storage/entity/NwcConnection.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from "typeorm" + +@Entity() +@Index("unique_nwc_connection", ["app_id", "client_pubkey"], { unique: true }) +@Index("idx_nwc_app_user", ["app_user_id"]) +export class NwcConnection { + + @PrimaryGeneratedColumn() + serial_id: number + + @Column() + app_id: string + + @Column() + app_user_id: string + + @Column() + client_pubkey: string + + @Column({ type: 'simple-json', default: null, nullable: true }) + permissions: string[] | null + + @Column({ default: 0 }) + max_amount: number + + @Column({ default: 0 }) + expires_at: number + + @Column({ default: 0 }) + total_spent: number + + @CreateDateColumn() + created_at: Date + + @UpdateDateColumn() + updated_at: Date +} diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index fea4c8c9..e7741c84 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -9,6 +9,7 @@ import MetricsEventStorage from "./tlv/metricsEventStorage.js"; import EventsLogManager from "./eventsLog.js"; import { LiquidityStorage } from "./liquidityStorage.js"; import DebitStorage from "./debitStorage.js" +import NwcStorage from "./nwcStorage.js" import OfferStorage from "./offerStorage.js" import { ManagementStorage } from "./managementStorage.js"; import { StorageInterface, TX } from "./db/storageInterface.js"; @@ -78,6 +79,7 @@ export default class { metricsEventStorage: MetricsEventStorage liquidityStorage: LiquidityStorage debitStorage: DebitStorage + nwcStorage: NwcStorage offerStorage: OfferStorage managementStorage: ManagementStorage eventsLog: EventsLogManager @@ -103,6 +105,7 @@ export default class { this.metricsEventStorage = new MetricsEventStorage(this.settings, this.utils.tlvStorageFactory) this.liquidityStorage = new LiquidityStorage(this.dbs) this.debitStorage = new DebitStorage(this.dbs) + this.nwcStorage = new NwcStorage(this.dbs) this.offerStorage = new OfferStorage(this.dbs) this.managementStorage = new ManagementStorage(this.dbs); try { if (this.settings.dataDir) fs.mkdirSync(this.settings.dataDir) } catch (e) { } diff --git a/src/services/storage/migrations/1770000000000-nwc_connection.ts b/src/services/storage/migrations/1770000000000-nwc_connection.ts new file mode 100644 index 00000000..f5644b86 --- /dev/null +++ b/src/services/storage/migrations/1770000000000-nwc_connection.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NwcConnection1770000000000 implements MigrationInterface { + name = 'NwcConnection1770000000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "nwc_connection" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "app_id" varchar NOT NULL, "app_user_id" varchar NOT NULL, "client_pubkey" varchar NOT NULL, "permissions" text, "max_amount" integer NOT NULL DEFAULT (0), "expires_at" integer NOT NULL DEFAULT (0), "total_spent" integer NOT NULL DEFAULT (0), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`); + await queryRunner.query(`CREATE UNIQUE INDEX "unique_nwc_connection" ON "nwc_connection" ("app_id", "client_pubkey") `); + await queryRunner.query(`CREATE INDEX "idx_nwc_app_user" ON "nwc_connection" ("app_user_id") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "idx_nwc_app_user"`); + await queryRunner.query(`DROP INDEX "unique_nwc_connection"`); + await queryRunner.query(`DROP TABLE "nwc_connection"`); + } +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index c78af2eb..8761edec 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -27,6 +27,7 @@ import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_prov import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js' import { InvoiceSwapsFixes1769805357459 } from './1769805357459-invoice_swaps_fixes.js' +import { NwcConnection1770000000000 } from './1770000000000-nwc_connection.js' import { ApplicationUserTopicId1770038768784 } from './1770038768784-application_user_topic_id.js' import { SwapTimestamps1771347307798 } from './1771347307798-swap_timestamps.js' import { TxSwapTimestamps1771878683383 } from './1771878683383-tx_swap_timestamps.js' @@ -42,15 +43,13 @@ import { ChannelEvents1750777346411 } from './1750777346411-channel_events.js' import { RootOpPending1771524665409 } from './1771524665409-root_op_pending.js' - - export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, - InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798, + InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, NwcConnection1770000000000, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798, TxSwapTimestamps1771878683383] diff --git a/src/services/storage/nwcStorage.ts b/src/services/storage/nwcStorage.ts new file mode 100644 index 00000000..73aaad17 --- /dev/null +++ b/src/services/storage/nwcStorage.ts @@ -0,0 +1,49 @@ +import { NwcConnection } from "./entity/NwcConnection.js"; +import { StorageInterface } from "./db/storageInterface.js"; + +type ConnectionToAdd = { + app_id: string + app_user_id: string + client_pubkey: string + permissions?: string[] + max_amount?: number + expires_at?: number +} + +export default class { + dbs: StorageInterface + constructor(dbs: StorageInterface) { + this.dbs = dbs + } + + async AddConnection(connection: ConnectionToAdd) { + return this.dbs.CreateAndSave('NwcConnection', { + app_id: connection.app_id, + app_user_id: connection.app_user_id, + client_pubkey: connection.client_pubkey, + permissions: connection.permissions || null, + max_amount: connection.max_amount || 0, + expires_at: connection.expires_at || 0, + }) + } + + async GetConnection(appId: string, clientPubkey: string, txId?: string) { + return this.dbs.FindOne('NwcConnection', { where: { app_id: appId, client_pubkey: clientPubkey } }, txId) + } + + async GetUserConnections(appUserId: string, txId?: string) { + return this.dbs.Find('NwcConnection', { where: { app_user_id: appUserId } }, txId) + } + + async DeleteConnection(serialId: number, txId?: string) { + return this.dbs.Delete('NwcConnection', { serial_id: serialId }, txId) + } + + async DeleteConnectionByPubkey(appId: string, clientPubkey: string, txId?: string) { + return this.dbs.Delete('NwcConnection', { app_id: appId, client_pubkey: clientPubkey }, txId) + } + + async IncrementTotalSpent(appId: string, clientPubkey: string, amount: number, txId?: string) { + return this.dbs.Increment('NwcConnection', { app_id: appId, client_pubkey: clientPubkey }, 'total_spent', amount, txId) + } +}