Skip to content
28 changes: 28 additions & 0 deletions migration/1767952500437-MaerkiRemoved.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class MaerkiRemoved1767952500437 {
name = 'MaerkiRemoved1767952500437'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "country" DROP CONSTRAINT "DF_687dc858f7aff3f03ffbb214f2c"`);
await queryRunner.query(`ALTER TABLE "country" DROP COLUMN "maerkiBaumannEnable"`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "country" ADD "maerkiBaumannEnable" bit NOT NULL`);
await queryRunner.query(`ALTER TABLE "country" ADD CONSTRAINT "DF_687dc858f7aff3f03ffbb214f2c" DEFAULT 0 FOR "maerkiBaumannEnable"`);
}
}
4 changes: 4 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export class Configuration {
priceSourceManual = 'DFX'; // source name for priceStep if price is set manually in buy-crypto
priceSourcePayment = 'Payment'; // source name for priceStep if price is defined by payment quote

isDomesticIban(iban: string): boolean {
return ['CH', 'LI'].includes(iban?.substring(0, 2));
}

defaults = {
currency: 'EUR',
language: 'EN',
Expand Down
2 changes: 1 addition & 1 deletion src/integration/exchange/services/binance.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class BinanceService extends ExchangeService {
Arbitrum: 'ARBITRUM',
BinanceSmartChain: 'BSC',
Bitcoin: 'BTC',
Lightning: undefined,
Lightning: 'LIGHTNING',
Spark: undefined,
Monero: 'XMR',
Zano: undefined,
Expand Down
4 changes: 2 additions & 2 deletions src/integration/lightning/dto/lnbits.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export interface LnBitsWalletPaymentParamsDto {
amount: number;
memo: string;
expirySec: number;
webhook: string;
extra: {
webhook?: string;
extra?: {
link: string;
signature: string;
};
Expand Down
18 changes: 18 additions & 0 deletions src/integration/lightning/dto/lnd.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ export enum LndPaymentStatus {
FAILED = 'FAILED',
}

export enum LndInvoiceState {
OPEN = 'OPEN',
SETTLED = 'SETTLED',
CANCELED = 'CANCELED',
ACCEPTED = 'ACCEPTED',
}

export interface LndInvoiceDto {
memo: string;
r_hash: string;
payment_request: string;
value_sat: string;
state: LndInvoiceState;
settled: boolean;
settle_date: string;
amt_paid_sat: string;
}

export interface LndPaymentDto {
payment_hash: string;
value_sat: number;
Expand Down
8 changes: 8 additions & 0 deletions src/integration/lightning/lightning-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
LndChannelBalanceDto,
LndChannelDto,
LndInfoDto,
LndInvoiceDto,
LndPaymentDto,
LndRouteDto,
LndSendPaymentResponseDto,
Expand Down Expand Up @@ -115,6 +116,13 @@ export class LightningClient {
.then((p) => p.payments);
}

async lookupInvoice(paymentHashHex: string): Promise<LndInvoiceDto> {
return this.http.get<LndInvoiceDto>(
`${Config.blockchain.lightning.lnd.apiUrl}/invoice/${paymentHashHex}`,
this.httpLndConfig(),
);
}

async sendPaymentByInvoice(invoice: string): Promise<LndSendPaymentResponseDto> {
return this.http.post<LndSendPaymentResponseDto>(
`${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`,
Expand Down
1 change: 0 additions & 1 deletion src/shared/models/country/__mocks__/country.entity.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const defaultCountry: Partial<Country> = {
dfxEnable: true,
lockEnable: true,
ipEnable: true,
maerkiBaumannEnable: true,
yapealEnable: true,
updated: undefined,
created: undefined,
Expand Down
3 changes: 0 additions & 3 deletions src/shared/models/country/country.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ export class Country extends IEntity {
@Column({ default: true })
ipEnable: boolean;

@Column({ default: false })
maerkiBaumannEnable: boolean;

@Column({ default: false })
yapealEnable: boolean;

Expand Down
2 changes: 1 addition & 1 deletion src/shared/services/payment-info.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class PaymentInfoService {
if (!dto.currency) throw new NotFoundException('Currency not found');
if (!dto.currency.buyable) throw new BadRequestException('Currency not buyable');

if ('iban' in dto && dto.currency?.name === 'CHF' && !dto.iban.startsWith('CH') && !dto.iban.startsWith('LI'))
if ('iban' in dto && dto.currency?.name === 'CHF' && !Config.isDomesticIban(dto.iban))
throw new BadRequestException(
'CHF transactions are only permitted to Liechtenstein or Switzerland. Use EUR for other countries.',
);
Expand Down
2 changes: 1 addition & 1 deletion src/shared/utils/test.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DeepPartial } from 'typeorm';

export class TestUtil {
static provideConfig(config: DeepPartial<Configuration> = {}): Provider {
const conf = { ...new Configuration(), ...config } as Configuration;
const conf = Object.assign(new Configuration(), config);
return { provide: ConfigService, useValue: new ConfigService(conf) };
}
}
2 changes: 1 addition & 1 deletion src/shared/utils/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class Util {
}

static roundByPrecision(amount: number, precision: number): number {
return new BigNumber(amount).precision(precision).toNumber();
return new BigNumber(amount).precision(precision, BigNumber.ROUND_HALF_UP).toNumber();
}

static floorByPrecision(amount: number, precision: number): number {
Expand Down
2 changes: 1 addition & 1 deletion src/subdomains/core/aml/services/aml-helper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ export class AmlHelperService {
if (entity.inputAmount > entity.cryptoInput.asset.liquidityCapacity)
errors.push(AmlError.LIQUIDITY_LIMIT_EXCEEDED);
if (nationality && !nationality.cryptoEnable) errors.push(AmlError.TX_COUNTRY_NOT_ALLOWED);
if (entity.sell.fiat.name === 'CHF' && !entity.sell.iban.startsWith('CH') && !entity.sell.iban.startsWith('LI'))
if (entity.sell.fiat.name === 'CHF' && !Config.isDomesticIban(entity.sell.iban))
errors.push(AmlError.ABROAD_CHF_NOT_ALLOWED);
if (
blacklist.some((b) =>
Expand Down
2 changes: 1 addition & 1 deletion src/subdomains/core/aml/services/aml.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class AmlService {
if (
!entity.userData.bankTransactionVerification &&
entity instanceof BuyFiat &&
(entity.sell.iban.startsWith('LI') || entity.sell.iban.startsWith('CH'))
Config.isDomesticIban(entity.sell.iban)
)
entity.userData = await this.userDataService.updateUserDataInternal(entity.userData, {
bankTransactionVerification: CheckStatus.GSHEET,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ export class BuyCryptoService {
{
iban: chargebackIban,
amount: chargebackAmount,
currency: buyCrypto.bankTx?.currency,
currency: dto.chargebackCurrency ?? buyCrypto.bankTx?.currency,
...creditorData,
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe('TransactionHelper', () => {
txHelper.getRefundData(
transaction.refundTargetEntity,
defaultUserData,
IbanBankName.MAERKI,
IbanBankName.YAPEAL,
'DE12500105170648489890',
!transaction.cryptoInput,
),
Expand All @@ -130,18 +130,21 @@ describe('TransactionHelper', () => {

jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(createCustomFiat({ name: 'CHF' }));
jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createInternalChargebackFeeDto());
jest
.spyOn(pricingService, 'getPrice')
.mockResolvedValue(createCustomPrice({ source: 'CHF', target: 'CHF', price: 1 }));

await expect(
txHelper.getRefundData(
transaction.refundTargetEntity,
defaultUserData,
IbanBankName.MAERKI,
IbanBankName.YAPEAL,
'DE12500105170648489890',
!transaction.cryptoInput,
),
).resolves.toMatchObject({
fee: { network: 0, bank: 1.13 },
refundAmount: 99.87,
refundAmount: 99.88,
refundTarget: 'DE12500105170648489890',
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,14 @@ export class TransactionController {
}
: undefined;

const chargebackCurrency = refundData.refundAsset.name;

if (transaction.targetEntity instanceof BankTxReturn) {
if (!dto.creditorData) throw new BadRequestException('Creditor data is required for bank refunds');

return this.bankTxReturnService.refundBankTx(transaction.targetEntity, {
refundIban: refundData.refundTarget ?? dto.refundTarget,
chargebackCurrency,
creditorData,
...refundDto,
});
Expand Down Expand Up @@ -487,6 +490,7 @@ export class TransactionController {

return this.buyCryptoService.refundBankTx(transaction.targetEntity, {
refundIban: refundData.refundTarget ?? dto.refundTarget,
chargebackCurrency,
creditorData,
...refundDto,
});
Expand Down
1 change: 1 addition & 0 deletions src/subdomains/core/history/dto/refund-internal.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class BaseRefund {

export class BankTxRefund extends BaseRefund {
refundIban?: string;
chargebackCurrency?: string;
chargebackOutput?: FiatOutput;
creditorData?: CreditorData;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter {

constructor(
system: LiquidityManagementSystem,
private readonly exchangeService: ExchangeService,
protected readonly exchangeService: ExchangeService,
private readonly exchangeRegistry: ExchangeRegistryService,
private readonly dexService: DexService,
private readonly orderRepo: LiquidityManagementOrderRepository,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import { Injectable } from '@nestjs/common';
import { BinanceService } from 'src/integration/exchange/services/binance.service';
import { ExchangeRegistryService } from 'src/integration/exchange/services/exchange-registry.service';
import { LndInvoiceState } from 'src/integration/lightning/dto/lnd.dto';
import { LightningClient } from 'src/integration/lightning/lightning-client';
import { LightningHelper } from 'src/integration/lightning/lightning-helper';
import { LightningService } from 'src/integration/lightning/services/lightning.service';
import { AssetService } from 'src/shared/models/asset/asset.service';
import { Util } from 'src/shared/utils/util';
import { DexService } from 'src/subdomains/supporting/dex/services/dex.service';
import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service';
import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity';
import { LiquidityManagementSystem } from '../../enums';
import { OrderFailedException } from '../../exceptions/order-failed.exception';
import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception';
import { CorrelationId } from '../../interfaces';
import { LiquidityManagementOrderRepository } from '../../repositories/liquidity-management-order.repository';
import { CcxtExchangeAdapter } from './base/ccxt-exchange.adapter';

export enum BinanceAdapterCommands {
LIGHTNING_WITHDRAW = 'lightning-withdraw',
}

const BINANCE_LIGHTNING_MAX_WITHDRAWAL_BTC = 0.00999;

@Injectable()
export class BinanceAdapter extends CcxtExchangeAdapter {
private readonly lightningClient: LightningClient;

constructor(
binanceService: BinanceService,
exchangeRegistry: ExchangeRegistryService,
dexService: DexService,
liquidityOrderRepo: LiquidityManagementOrderRepository,
pricingService: PricingService,
assetService: AssetService,
lightningService: LightningService,
) {
super(
LiquidityManagementSystem.BINANCE,
Expand All @@ -27,5 +45,85 @@ export class BinanceAdapter extends CcxtExchangeAdapter {
pricingService,
assetService,
);

this.lightningClient = lightningService.getDefaultClient();
this.commands.set(BinanceAdapterCommands.LIGHTNING_WITHDRAW, this.lightningWithdraw.bind(this));
}

// --- LIGHTNING WITHDRAW --- //

private async lightningWithdraw(order: LiquidityManagementOrder): Promise<CorrelationId> {
const asset = order.pipeline.rule.targetAsset.dexName;
const balance = await this.exchangeService.getAvailableBalance(asset);

const amount = Util.floor(Math.min(order.maxAmount, balance, BINANCE_LIGHTNING_MAX_WITHDRAWAL_BTC), 8);

if (amount <= 0)
throw new OrderNotProcessableException(
`${this.exchangeService.name}: not enough balance for ${asset} (balance: ${balance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`,
);
const amountSats = LightningHelper.btcToSat(amount);

// Generate invoice via LnBits
const invoice = await this.lightningClient.getLnBitsWalletPayment({
amount: amountSats,
memo: `LM Order ${order.id}`,
expirySec: 1800, // 30 min (Binance limit)
});

order.inputAmount = amount;
order.inputAsset = asset;
order.outputAsset = asset;

// Send invoice to Binance for withdrawal
const response = await this.exchangeService.withdrawFunds(asset, amount, invoice.pr, undefined, 'LIGHTNING');

return response.id;
}

// --- COMPLETION CHECK --- //

async checkCompletion(order: LiquidityManagementOrder): Promise<boolean> {
if (order.action.command === BinanceAdapterCommands.LIGHTNING_WITHDRAW) {
return this.checkLightningWithdrawCompletion(order);
}
return super.checkCompletion(order);
}

private async checkLightningWithdrawCompletion(order: LiquidityManagementOrder): Promise<boolean> {
const asset = order.pipeline.rule.targetAsset.dexName;
const withdrawal = await this.exchangeService.getWithdraw(order.correlationId, asset);
if (!withdrawal) return false;

if (withdrawal.status === 'failed') {
throw new OrderFailedException(`Lightning withdrawal ${order.correlationId} failed on Binance`);
}

// For Lightning, txid = payment_hash (hex)
const paymentHash = withdrawal.txid;
if (!paymentHash) return false;

try {
const invoice = await this.lightningClient.lookupInvoice(paymentHash);
const isComplete = invoice.state === LndInvoiceState.SETTLED;

if (isComplete) {
order.outputAmount = LightningHelper.satToBtc(+invoice.amt_paid_sat);
}

return isComplete;
} catch {
// Invoice not found = not yet received
return false;
}
}

// --- VALIDATION --- //

validateParams(command: string, params: Record<string, unknown>): boolean {
if (command === BinanceAdapterCommands.LIGHTNING_WITHDRAW) {
return true; // No params needed for lightning-withdraw
}
return super.validateParams(command, params);
}
}
Loading
Loading