diff --git a/.env.example b/.env.example index 56203f87cd..b56ba20d05 100644 --- a/.env.example +++ b/.env.example @@ -181,6 +181,11 @@ 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). +# 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= diff --git a/src/config/config.ts b/src/config/config.ts index 290d3faef5..0c75396d2b 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,18 @@ export class Configuration { : ((process.env.DISABLED_PROCESSES?.split(',') ?? []) as Process[]); } +function readCert(): string | undefined { + const path = process.env.LIGHTNING_API_CERTIFICATE_PATH; + 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'); + } + + // 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 { return (value?.split(',') ?? []) .map((k) => k.split(':')) 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/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..d7c0c3df3c 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 @@ -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 { 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, }, 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/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 }, }; } 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/liquidity-management/adapters/balances/exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts index cd99d63f98..10a86d9a68 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,20 @@ 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, 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 []; + } + try { - const exchangeService = this.exchangeRegistry.getExchange(exchange); const { total: totalBalances, available: availableBalances } = await exchangeService.getBalances(); return assets.map((a) => { 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)), 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); + } } } 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,