From 0ada70700ffd01bc052e359845c19a707a38dc49 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:17:44 +0200 Subject: [PATCH 01/10] fix(lightning): reuse https Agent across LND requests to fix TLS socket reuse errors (#3854) --- src/integration/lightning/lightning-client.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/integration/lightning/lightning-client.ts b/src/integration/lightning/lightning-client.ts index b8588d384e..0c3e1053ca 100644 --- a/src/integration/lightning/lightning-client.ts +++ b/src/integration/lightning/lightning-client.ts @@ -26,7 +26,11 @@ import { CoinOnly } from 'src/integration/blockchain/shared/util/blockchain-clie import { LightningHelper } from './lightning-helper'; export class LightningClient implements CoinOnly { - constructor(private readonly http: HttpService) {} + private readonly lndAgent: Agent; + + constructor(private readonly http: HttpService) { + this.lndAgent = new Agent({ ca: Config.blockchain.lightning.certificate }); + } // --- LND --- // @@ -341,19 +345,13 @@ export class LightningClient implements CoinOnly { // --- HELPER METHODS --- // private httpLnBitsConfig(params?: any): HttpRequestConfig { return { - httpsAgent: new Agent({ - ca: Config.blockchain.lightning.certificate, - }), params: { 'api-key': Config.blockchain.lightning.lnbits.apiKey, ...params }, }; } private httpLndConfig(): HttpRequestConfig { return { - httpsAgent: new Agent({ - ca: Config.blockchain.lightning.certificate, - }), - + httpsAgent: this.lndAgent, headers: { 'Grpc-Metadata-macaroon': Config.blockchain.lightning.lnd.adminMacaroon }, }; } From 45776c7c43f19a517c9fdca6befffc0052ec30b5 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:35:35 -0300 Subject: [PATCH 02/10] feat(lightning): support loading LND TLS certificate from file (#3861) * feat(lightning): support loading LND TLS certificate from file Add optional LIGHTNING_API_CERTIFICATE_PATH env var. When set and the file is readable, the LND TLS certificate is read from disk (the live cert); otherwise it falls back to the existing LIGHTNING_API_CERTIFICATE env var, unchanged. Fully backward compatible. * Read LND cert from file only, drop env-var fallback Address review: if LIGHTNING_API_CERTIFICATE_PATH is set, read it or throw. The LIGHTNING_API_CERTIFICATE env var was the stale copy this change fixes, so keeping it as a silent fallback only masks a broken mount. Remove it. --- .env.example | 3 ++- src/config/config.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 56203f87cd..fb9eba2506 100644 --- a/.env.example +++ b/.env.example @@ -181,7 +181,8 @@ LIGHTNING_LNBITS_API_KEY= LIGHTNING_LNBITS_LNURLP_URL= LIGHTNING_LND_API_URL= LIGHTNING_LND_ADMIN_MACAROON= -LIGHTNING_API_CERTIFICATE= +# Path to the live LND TLS cert file on disk (mounted into the container) +LIGHTNING_API_CERTIFICATE_PATH= MONERO_WALLET_ADDRESS= MONERO_NODE_URL= diff --git a/src/config/config.ts b/src/config/config.ts index 290d3faef5..b97bfd2d2b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -2,6 +2,7 @@ import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handleba import { Injectable, Optional } from '@nestjs/common'; import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { ConstructorArgs } from 'ccxt'; +import { readFileSync } from 'fs'; import JSZip from 'jszip'; import { I18nOptions } from 'nestjs-i18n'; import { join } from 'path'; @@ -924,7 +925,7 @@ export class Configuration { apiUrl: process.env.LIGHTNING_LND_API_URL, adminMacaroon: process.env.LIGHTNING_LND_ADMIN_MACAROON, }, - certificate: process.env.LIGHTNING_API_CERTIFICATE?.split('
').join('\n'), + certificate: readCert(), }, boltz: { apiUrl: process.env.BOLTZ_API_URL, @@ -1284,6 +1285,15 @@ export class Configuration { : ((process.env.DISABLED_PROCESSES?.split(',') ?? []) as Process[]); } +function readCert(): string | undefined { + const path = process.env.LIGHTNING_API_CERTIFICATE_PATH; + if (!path) return undefined; + + // Path is set: read the live LND cert from disk and let a missing/unreadable file throw, + // so a broken mount surfaces immediately instead of being masked by a stale fallback. + return readFileSync(path, 'utf8'); +} + function splitWithdrawKeys(value?: string): Map { return (value?.split(',') ?? []) .map((k) => k.split(':')) From 457408cebb753747624c186dd7511ac5bce68966 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:24:36 -0300 Subject: [PATCH 03/10] fix: isolate per-entity failures in BuyFiat addFiatOutputs (#3858) A single BuyFiat with incomplete creditor data caused fiatOutputService.createInternal to throw 'Failed to create fiat output...', which escaped the unguarded loops in addFiatOutputs and aborted the entire cron run, blocking all other pending payouts. Wrap each createInternal call in try/catch and log per-entity, matching the existing pattern used by the other per-entity loops in this service (AML check, fee refresh, output setting, completion, chargeback). --- .../services/buy-fiat-preparation.service.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts index cd08f8f740..afac659d11 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts @@ -450,7 +450,11 @@ export class BuyFiatPreparationService { ); for (const buyFiat of immediateOutputs) { - await this.fiatOutputService.createInternal(FiatOutputType.BUY_FIAT, { buyFiats: [buyFiat] }, buyFiat.id); + try { + await this.fiatOutputService.createInternal(FiatOutputType.BUY_FIAT, { buyFiats: [buyFiat] }, buyFiat.id); + } catch (e) { + this.logger.error(`Error during buy-fiat ${buyFiat.id} fiat output creation:`, e); + } } // batched payouts (business days only) @@ -480,12 +484,16 @@ export class BuyFiatPreparationService { ); for (const buyFiats of sellGroups.values()) { - await this.fiatOutputService.createInternal( - FiatOutputType.BUY_FIAT, - { buyFiats }, - buyFiats[0].id, - buyFiats[0].userData.paymentLinksConfigObj.ep2ReportContainer != null, - ); + try { + await this.fiatOutputService.createInternal( + FiatOutputType.BUY_FIAT, + { buyFiats }, + buyFiats[0].id, + buyFiats[0].userData.paymentLinksConfigObj.ep2ReportContainer != null, + ); + } catch (e) { + this.logger.error(`Error during buy-fiat ${buyFiats[0].id} batched fiat output creation:`, e); + } } } From 63ff21e63fb908d7df16fc19ba324bf7031ef7e8 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:40:43 +0200 Subject: [PATCH 04/10] fix(config): restore LIGHTNING_API_CERTIFICATE env fallback for Azure (#3867) When LIGHTNING_API_CERTIFICATE_PATH is not set (Azure App Service has no cert file mount), fall back to reading the inline PEM from LIGHTNING_API_CERTIFICATE using the legacy
newline separator. --- .env.example | 6 +++++- src/config/config.ts | 11 +++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index fb9eba2506..b56ba20d05 100644 --- a/.env.example +++ b/.env.example @@ -181,8 +181,12 @@ LIGHTNING_LNBITS_API_KEY= LIGHTNING_LNBITS_LNURLP_URL= LIGHTNING_LND_API_URL= LIGHTNING_LND_ADMIN_MACAROON= -# Path to the live LND TLS cert file on disk (mounted into the container) +# Path to the live LND TLS cert file on disk (mounted into the container). +# Takes precedence over LIGHTNING_API_CERTIFICATE when set. LIGHTNING_API_CERTIFICATE_PATH= +# TLS certificate for the LND connection (inline PEM,
as line separator). +# Only used when LIGHTNING_API_CERTIFICATE_PATH is not set (e.g. Azure). +LIGHTNING_API_CERTIFICATE= MONERO_WALLET_ADDRESS= MONERO_NODE_URL= diff --git a/src/config/config.ts b/src/config/config.ts index b97bfd2d2b..0c75396d2b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1287,11 +1287,14 @@ export class Configuration { function readCert(): string | undefined { const path = process.env.LIGHTNING_API_CERTIFICATE_PATH; - if (!path) return undefined; + if (path) { + // Path is set: read the live LND cert from disk and let a missing/unreadable file throw, + // so a broken mount surfaces immediately instead of being masked by a stale fallback. + return readFileSync(path, 'utf8'); + } - // Path is set: read the live LND cert from disk and let a missing/unreadable file throw, - // so a broken mount surfaces immediately instead of being masked by a stale fallback. - return readFileSync(path, 'utf8'); + // Fallback for environments without a cert file mount (e.g. Azure App Service). + return process.env.LIGHTNING_API_CERTIFICATE?.split('
').join('\n'); } function splitWithdrawKeys(value?: string): Map { From 86591836e12e06c7fa034a72069199141e51060b Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:18 -0300 Subject: [PATCH 05/10] fix(liquidity): skip exchanges without API credentials in balance polling (#3859) * Skip exchanges with no API credentials in liquidity balance polling The liquidity-management exchange adapter polls every exchange's balance each cycle. When an exchange has no API credentials configured (e.g. XT on dev, where XT_KEY/XT_SECRET are unset), the ccxt call threw an AuthenticationError that was logged as ERROR and re-thrown every cycle (~60 error logs/hour). Add an isConfigured getter to ExchangeService (true only when both apiKey and secret are set) and, in the adapter, skip unconfigured exchanges gracefully: return empty balances instead of calling getBalances(), and warn once per exchange instead of erroring every cycle. The configured- but-failed case keeps the existing ERROR + re-throw behavior. * refactor(exchange): make isConfigured part of the type contract (David review) --- .../__tests__/exchange.service.spec.ts | 24 +++++++++++++++++++ .../exchange/services/exchange.service.ts | 5 ++++ .../exchange/services/scrypt.service.ts | 5 ++++ .../adapters/balances/exchange.adapter.ts | 16 ++++++++++++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/integration/exchange/services/__tests__/exchange.service.spec.ts b/src/integration/exchange/services/__tests__/exchange.service.spec.ts index afad4e83a0..ac0492b893 100644 --- a/src/integration/exchange/services/__tests__/exchange.service.spec.ts +++ b/src/integration/exchange/services/__tests__/exchange.service.spec.ts @@ -39,6 +39,30 @@ describe('ExchangeService', () => { expect(service).toBeDefined(); }); + it('should not be configured without credentials', () => { + expect(service.isConfigured).toBe(false); + }); + + it('should not be configured with only an apiKey', () => { + service = new ExchangeTestModule.TestExchangeService( + ExchangeTestModule.TestExchange, + { apiKey: 'key' }, + new QueueHandler(undefined, undefined), + ); + + expect(service.isConfigured).toBe(false); + }); + + it('should be configured with apiKey and secret', () => { + service = new ExchangeTestModule.TestExchangeService( + ExchangeTestModule.TestExchange, + { apiKey: 'key', secret: 'secret' }, + new QueueHandler(undefined, undefined), + ); + + expect(service.isConfigured).toBe(true); + }); + it('should return BTC/EUR and buy', async () => { Setup.Markets(); diff --git a/src/integration/exchange/services/exchange.service.ts b/src/integration/exchange/services/exchange.service.ts index de51c0570b..14d5310004 100644 --- a/src/integration/exchange/services/exchange.service.ts +++ b/src/integration/exchange/services/exchange.service.ts @@ -68,6 +68,11 @@ export abstract class ExchangeService extends PricingProvider implements OnModul return this.exchange.name; } + // true only when both ccxt credentials are present; used to skip exchanges with no API keys (e.g. XT on dev) + get isConfigured(): boolean { + return !!this.config?.apiKey && !!this.config?.secret; + } + async getRawBalances(): Promise { return this.callApi((e) => e.fetchBalance()); } diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index 26fbac2c85..637caac598 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -41,6 +41,11 @@ export class ScryptService extends PricingProvider { readonly name: string = 'Scrypt'; + get isConfigured(): boolean { + const { apiKey, apiSecret } = GetConfig().scrypt; + return !!apiKey && !!apiSecret; + } + constructor() { super(); diff --git a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts index cd99d63f98..4764b6cef8 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts @@ -15,6 +15,9 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration { private readonly ASSET_MAPPINGS = { BTC: ['XBT'] }; + // exchanges already warned about missing credentials, to warn once instead of every cycle + private readonly unconfiguredWarned = new Set(); + constructor( private readonly exchangeRegistry: ExchangeRegistryService, private readonly orderRepo: LiquidityManagementOrderRepository, @@ -57,8 +60,19 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration { // --- HELPER METHODS --- // async getForExchange(exchange: string, assets: LiquidityManagementAsset[]): Promise { + const exchangeService = this.exchangeRegistry.getExchange(exchange); + + // not configured (no API credentials) -> skip gracefully, warn once + if (!exchangeService.isConfigured) { + if (!this.unconfiguredWarned.has(exchange)) { + this.unconfiguredWarned.add(exchange); + this.logger.warn(`Exchange ${exchange} has no credentials configured — skipping liquidity balance`); + } + + return assets.map((a) => LiquidityBalance.create(a, 0, 0)); + } + try { - const exchangeService = this.exchangeRegistry.getExchange(exchange); const { total: totalBalances, available: availableBalances } = await exchangeService.getBalances(); return assets.map((a) => { From 26eee6eca1eebe61c768b51ba5379019d9f6e061 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:37:58 +0200 Subject: [PATCH 06/10] fix(liquidity): skip unconfigured exchanges instead of reporting zero balances (#3870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning LiquidityBalance.create(a, 0, 0) for exchanges without API credentials persists fake zero balances via saveBalanceResults. If a configured exchange ever loses its credentials (env regression), the rules would see a sudden zero balance and could trigger rebalancing pipelines towards an exchange we cannot even query — with only a single warn log per process lifetime as a hint. Return no balances instead: verifyRule already handles a missing balance gracefully (info log, rule skipped). --- .../adapters/balances/exchange.adapter.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts index 4764b6cef8..10a86d9a68 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts @@ -62,14 +62,15 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration { async getForExchange(exchange: string, assets: LiquidityManagementAsset[]): Promise { const exchangeService = this.exchangeRegistry.getExchange(exchange); - // not configured (no API credentials) -> skip gracefully, warn once + // not configured (no API credentials) -> skip, warn once. No balances are reported on purpose: + // a zero balance would be persisted and could trigger liquidity rules, masking a config error if (!exchangeService.isConfigured) { if (!this.unconfiguredWarned.has(exchange)) { this.unconfiguredWarned.add(exchange); this.logger.warn(`Exchange ${exchange} has no credentials configured — skipping liquidity balance`); } - return assets.map((a) => LiquidityBalance.create(a, 0, 0)); + return []; } try { From bd3c4b52e83432c936ecf18a37bd00b4fea38a80 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:50:45 +0200 Subject: [PATCH 07/10] fix(juice): align positionV2 query with renamed stablecoinAddress field (#3875) The juicedollar ponder schema generalized the position's stablecoin contract field from jusd to stablecoinAddress (multi-stablecoin support), so getPositionV2s failed with GRAPHQL_VALIDATION_FAILED on every processLogInfo cycle. The public JuicePositionDto keeps the jusd key, only the graph query and graph DTO are renamed. --- src/integration/blockchain/juice/dto/juice.dto.ts | 2 +- src/integration/blockchain/juice/juice-client.ts | 2 +- src/integration/blockchain/juice/juice.service.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/integration/blockchain/juice/dto/juice.dto.ts b/src/integration/blockchain/juice/dto/juice.dto.ts index f22e409a3e..5d1fbd583d 100644 --- a/src/integration/blockchain/juice/dto/juice.dto.ts +++ b/src/integration/blockchain/juice/dto/juice.dto.ts @@ -4,7 +4,7 @@ export interface JuicePositionGraphDto extends FrankencoinBasedCollateralDto { id: string; position: string; owner: string; - jusd: string; + stablecoinAddress: string; price: string; limitForClones: string; availableForClones: string; diff --git a/src/integration/blockchain/juice/juice-client.ts b/src/integration/blockchain/juice/juice-client.ts index a14b4e1056..6201e434bd 100644 --- a/src/integration/blockchain/juice/juice-client.ts +++ b/src/integration/blockchain/juice/juice-client.ts @@ -53,7 +53,7 @@ export class JuiceClient { id position owner - jusd + stablecoinAddress collateral price collateralSymbol diff --git a/src/integration/blockchain/juice/juice.service.ts b/src/integration/blockchain/juice/juice.service.ts index 773b408bd7..205848e7f9 100644 --- a/src/integration/blockchain/juice/juice.service.ts +++ b/src/integration/blockchain/juice/juice.service.ts @@ -119,7 +119,7 @@ export class JuiceService extends FrankencoinBasedService implements OnModuleIni positionsResult.push({ address: { position: position.position, - jusd: position.jusd, + jusd: position.stablecoinAddress, collateral: position.collateral, owner: position.owner, }, From 5d92ecca54a7594fa981817ab5dc179913ebb4ff Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:30:58 +0200 Subject: [PATCH 08/10] fix(juice): query poolShare instead of removed jUICE root field (#3877) Second schema rename in the generalized juicedollar ponder (after stablecoinAddress, #3875): the equity entity jUICE is now poolShare, same fields (id, profits, loss, reserve) and still keyed by the stablecoin address, verified against both deployed ponders. --- src/integration/blockchain/juice/juice-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/integration/blockchain/juice/juice-client.ts b/src/integration/blockchain/juice/juice-client.ts index 6201e434bd..d7c0c3df3c 100644 --- a/src/integration/blockchain/juice/juice-client.ts +++ b/src/integration/blockchain/juice/juice-client.ts @@ -102,7 +102,7 @@ export class JuiceClient { const document = gql` { - jUICE(id: "${address}") { + poolShare(id: "${address}") { id profits loss @@ -111,7 +111,7 @@ export class JuiceClient { } `; - return request<{ jUICE: JuiceEquityGraphDto }>(graphUrl, document).then((r) => r.jUICE); + return request<{ poolShare: JuiceEquityGraphDto }>(graphUrl, document).then((r) => r.poolShare); } getWalletAddress(): string { From f43b6b35da3eec7ad7b50b7aee3395cf2d7d9cf2 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:31:22 +0200 Subject: [PATCH 09/10] feat(monitoring): add stuck fiat outputs metric to payment observer (#3876) Fiat outputs can fail transmission to the bank (e.g. Olkypay model validation errors) and then sit ready but untransmitted indefinitely, visible only in the entity info column. Count outputs with isReadyDate older than one hour and no isTransmittedDate in the payment observer and surface them in the health check. --- src/shared/repositories/repository.factory.ts | 3 +++ src/subdomains/core/monitoring/health.controller.ts | 2 ++ .../core/monitoring/observers/payment.observer.ts | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/src/shared/repositories/repository.factory.ts b/src/shared/repositories/repository.factory.ts index 25e509e336..e9c3500e61 100644 --- a/src/shared/repositories/repository.factory.ts +++ b/src/shared/repositories/repository.factory.ts @@ -15,6 +15,7 @@ import { DepositRepository } from 'src/subdomains/supporting/address-pool/deposi import { DepositRouteRepository } from 'src/subdomains/supporting/address-pool/route/deposit-route.repository'; import { BankTxRepository } from 'src/subdomains/supporting/bank-tx/bank-tx/repositories/bank-tx.repository'; import { LiquidityOrderRepository } from 'src/subdomains/supporting/dex/repositories/liquidity-order.repository'; +import { FiatOutputRepository } from 'src/subdomains/supporting/fiat-output/fiat-output.repository'; import { CheckoutTxRepository } from 'src/subdomains/supporting/fiat-payin/repositories/checkout-tx.repository'; import { PayInRepository } from 'src/subdomains/supporting/payin/repositories/payin.repository'; import { TransactionSpecificationRepository } from 'src/subdomains/supporting/payment/repositories/transaction-specification.repository'; @@ -43,6 +44,7 @@ export class RepositoryFactory { public readonly lmOrder: LiquidityManagementOrderRepository; public readonly lmRule: LiquidityManagementRuleRepository; public readonly custodyOrder: CustodyOrderRepository; + public readonly fiatOutput: FiatOutputRepository; constructor(manager: EntityManager) { this.user = new UserRepository(manager); @@ -65,5 +67,6 @@ export class RepositoryFactory { this.lmOrder = new LiquidityManagementOrderRepository(manager); this.lmRule = new LiquidityManagementRuleRepository(manager); this.custodyOrder = new CustodyOrderRepository(manager); + this.fiatOutput = new FiatOutputRepository(manager); } } diff --git a/src/subdomains/core/monitoring/health.controller.ts b/src/subdomains/core/monitoring/health.controller.ts index dfd9d689cb..c1fbf7f8bd 100644 --- a/src/subdomains/core/monitoring/health.controller.ts +++ b/src/subdomains/core/monitoring/health.controller.ts @@ -139,12 +139,14 @@ export class HealthController { private checkPayment(state: SystemState | null): { status: HealthStatus; detail?: string } { const data = state?.payment?.combined?.data as { stuckPayments?: number; + stuckFiatOutputs?: number; unhandledCryptoInputs?: number; }; if (!data) return { status: HealthStatus.DEGRADED, detail: 'No payment data' }; const issues: string[] = []; if (data.stuckPayments > 0) issues.push(`${data.stuckPayments} stuck quotes`); + if (data.stuckFiatOutputs > 0) issues.push(`${data.stuckFiatOutputs} stuck fiat outputs`); if (data.unhandledCryptoInputs > 5) issues.push(`${data.unhandledCryptoInputs} unhandled inputs`); if (issues.length === 0) return { status: HealthStatus.OK }; diff --git a/src/subdomains/core/monitoring/observers/payment.observer.ts b/src/subdomains/core/monitoring/observers/payment.observer.ts index eb8a502628..8b48d32f0d 100644 --- a/src/subdomains/core/monitoring/observers/payment.observer.ts +++ b/src/subdomains/core/monitoring/observers/payment.observer.ts @@ -25,6 +25,7 @@ interface PaymentData { bankTxGsType: number; refRewardManualCheck: number; stuckPayments: number; + stuckFiatOutputs: number; pendingCustodyOrders: number; } @@ -108,6 +109,11 @@ export class PaymentObserver extends MetricObserver { ), created: LessThan(Util.hoursBefore(3)), }), + stuckFiatOutputs: await this.repos.fiatOutput.countBy({ + isReadyDate: LessThan(Util.hoursBefore(1)), + isTransmittedDate: IsNull(), + isComplete: false, + }), pendingCustodyOrders: await this.repos.custodyOrder.countBy({ status: CustodyOrderStatus.CONFIRMED, type: Not(In(CustodyIncomingTypes)), From 40afe609eb1d702d44a53261b0033a285c74b58f Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:44:54 -0300 Subject: [PATCH 10/10] fix(payin): skip Cardano pay-in check when no Tatum API key is configured (#3878) The Cardano register strategy polls Tatum every minute and always includes its payment deposit address, so on environments without a (funded) Tatum key every cycle fails with an ERROR (~60/h on dev, currently a 402 because the key's plan is out of credits). Follow the exchange-credentials pattern (#3859): expose isConfigured on CardanoClient (Tatum key present) and skip the cron with a single WARN when unset. A key that is set but rejected still errors, so a real prod misconfiguration stays visible. Solana/Tron are webhook-driven and make no unprompted Tatum calls, so no guard is needed there. --- src/integration/blockchain/cardano/cardano-client.ts | 4 ++++ .../blockchain/cardano/services/cardano.service.ts | 4 ++++ .../payin/services/payin-cardano.service.ts | 4 ++++ .../strategies/register/impl/cardano.strategy.ts | 12 ++++++++++++ 4 files changed, 24 insertions(+) diff --git a/src/integration/blockchain/cardano/cardano-client.ts b/src/integration/blockchain/cardano/cardano-client.ts index 34aeb21b63..57f72821b5 100644 --- a/src/integration/blockchain/cardano/cardano-client.ts +++ b/src/integration/blockchain/cardano/cardano-client.ts @@ -62,6 +62,10 @@ export class CardanoClient extends BlockchainClient { return this.wallet.address; } + get isConfigured(): boolean { + return !!Config.blockchain.cardano.cardanoTatumApiKey; + } + async getBlockHeight(): Promise { const info = await this.getNetworkInfo(); return info.tip; diff --git a/src/integration/blockchain/cardano/services/cardano.service.ts b/src/integration/blockchain/cardano/services/cardano.service.ts index 95696c69d6..f91be53505 100644 --- a/src/integration/blockchain/cardano/services/cardano.service.ts +++ b/src/integration/blockchain/cardano/services/cardano.service.ts @@ -27,6 +27,10 @@ export class CardanoService extends BlockchainService { return this.client.walletAddress; } + get isConfigured(): boolean { + return this.client.isConfigured; + } + verifySignature(message: string, address: string, signature: string, key?: string): boolean { try { return verifyCardanoSignature(signature, key, message, address); diff --git a/src/subdomains/supporting/payin/services/payin-cardano.service.ts b/src/subdomains/supporting/payin/services/payin-cardano.service.ts index b363f73c42..3daddd3e2a 100644 --- a/src/subdomains/supporting/payin/services/payin-cardano.service.ts +++ b/src/subdomains/supporting/payin/services/payin-cardano.service.ts @@ -12,6 +12,10 @@ export class PayInCardanoService { return this.cardanoService.getWalletAddress(); } + get isConfigured(): boolean { + return this.cardanoService.isConfigured; + } + async getNativeCoinBalanceForAddress(address: string): Promise { return this.cardanoService.getNativeCoinBalanceForAddress(address); } diff --git a/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts index 51c3cd1c25..cec4af850a 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts @@ -22,6 +22,8 @@ export class CardanoStrategy extends RegisterStrategy { private readonly cardanoPaymentDepositAddress: string; + private unconfiguredWarned = false; + constructor( private readonly payInCardanoService: PayInCardanoService, private readonly transactionRequestService: TransactionRequestService, @@ -41,6 +43,16 @@ export class CardanoStrategy extends RegisterStrategy { //*** JOBS ***// @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) async checkPayInEntries(): Promise { + // not configured (no Tatum API key) -> skip, warn once + if (!this.payInCardanoService.isConfigured) { + if (!this.unconfiguredWarned) { + this.unconfiguredWarned = true; + this.logger.warn('Cardano has no Tatum API key configured — skipping pay-in check'); + } + + return; + } + const activeDepositAddresses = await this.transactionRequestService.getActiveDepositAddresses( Util.hoursBefore(1), this.blockchain,