Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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, <br> as line separator).
# Only used when LIGHTNING_API_CERTIFICATE_PATH is not set (e.g. Azure).
LIGHTNING_API_CERTIFICATE=

MONERO_WALLET_ADDRESS=
Expand Down
15 changes: 14 additions & 1 deletion src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('<br>').join('\n'),
certificate: readCert(),
},
boltz: {
apiUrl: process.env.BOLTZ_API_URL,
Expand Down Expand Up @@ -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('<br>').join('\n');
}

function splitWithdrawKeys(value?: string): Map<string, string> {
return (value?.split(',') ?? [])
.map((k) => k.split(':'))
Expand Down
4 changes: 4 additions & 0 deletions src/integration/blockchain/cardano/cardano-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export class CardanoClient extends BlockchainClient {
return this.wallet.address;
}

get isConfigured(): boolean {
return !!Config.blockchain.cardano.cardanoTatumApiKey;
}

async getBlockHeight(): Promise<number> {
const info = await this.getNetworkInfo();
return info.tip;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/integration/blockchain/juice/dto/juice.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/integration/blockchain/juice/juice-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class JuiceClient {
id
position
owner
jusd
stablecoinAddress
collateral
price
collateralSymbol
Expand Down Expand Up @@ -102,7 +102,7 @@ export class JuiceClient {

const document = gql`
{
jUICE(id: "${address}") {
poolShare(id: "${address}") {
id
profits
loss
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/integration/blockchain/juice/juice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
5 changes: 5 additions & 0 deletions src/integration/exchange/services/exchange.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Balances> {
return this.callApi((e) => e.fetchBalance());
}
Expand Down
5 changes: 5 additions & 0 deletions src/integration/exchange/services/scrypt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
14 changes: 6 additions & 8 deletions src/integration/lightning/lightning-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 --- //

Expand Down Expand Up @@ -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 },
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/shared/repositories/repository.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

constructor(
private readonly exchangeRegistry: ExchangeRegistryService,
private readonly orderRepo: LiquidityManagementOrderRepository,
Expand Down Expand Up @@ -57,8 +60,20 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration {
// --- HELPER METHODS --- //

async getForExchange(exchange: string, assets: LiquidityManagementAsset[]): Promise<LiquidityBalance[]> {
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) => {
Expand Down
2 changes: 2 additions & 0 deletions src/subdomains/core/monitoring/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface PaymentData {
bankTxGsType: number;
refRewardManualCheck: number;
stuckPayments: number;
stuckFiatOutputs: number;
pendingCustodyOrders: number;
}

Expand Down Expand Up @@ -108,6 +109,11 @@ export class PaymentObserver extends MetricObserver<PaymentData> {
),
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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export class PayInCardanoService {
return this.cardanoService.getWalletAddress();
}

get isConfigured(): boolean {
return this.cardanoService.isConfigured;
}

async getNativeCoinBalanceForAddress(address: string): Promise<number> {
return this.cardanoService.getNativeCoinBalanceForAddress(address);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,6 +43,16 @@ export class CardanoStrategy extends RegisterStrategy {
//*** JOBS ***//
@DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 })
async checkPayInEntries(): Promise<void> {
// 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,
Expand Down
Loading