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,