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
91 changes: 91 additions & 0 deletions migration/1772100000000-AddBoltzLiquidityAction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Create Boltz deposit action and wire it as onFail fallback for Clementine (Action 236).
* Configure and activate Rule 320 (Citrea cBTC) with thresholds.
*
* Strategy: Clementine (fee-free, 10 BTC fixed) remains primary deficit action.
* When Clementine fails (e.g. insufficient balance < 10 BTC), Boltz handles
* flexible amounts (with fees) as fallback.
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddBoltzLiquidityAction1772100000000 {
name = 'AddBoltzLiquidityAction1772100000000';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
// Step 1: Create Boltz deposit action
await queryRunner.query(`
INSERT INTO "dbo"."liquidity_management_action" ("system", "command", "tag")
VALUES ('Boltz', 'deposit', 'cBTC')
`);

// Step 2: Get the newly created action ID
const [boltzAction] = await queryRunner.query(`
SELECT "id" FROM "dbo"."liquidity_management_action"
WHERE "system" = 'Boltz' AND "command" = 'deposit'
`);

if (!boltzAction) {
throw new Error('Failed to create Boltz action');
}

console.log(`Created Boltz action with id=${boltzAction.id}`);

// Step 3: Set Boltz as onFail fallback for Clementine (Action 236)
await queryRunner.query(`
UPDATE "dbo"."liquidity_management_action"
SET "onFailId" = ${boltzAction.id}
WHERE "id" = 236
`);

// Step 4: Configure and activate Rule 320 (Citrea cBTC)
await queryRunner.query(`
UPDATE "dbo"."liquidity_management_rule"
SET "status" = 'Active',
"minimal" = 0,
"optimal" = 0.1,
"maximal" = 0.5,
"reactivationTime" = 10
WHERE "id" = 320
`);

console.log('Rule 320 activated: minimal=0, optimal=0.1, maximal=0.5');
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
// Revert Rule 320 to inactive
await queryRunner.query(`
UPDATE "dbo"."liquidity_management_rule"
SET "status" = 'Inactive',
"minimal" = NULL,
"optimal" = NULL,
"maximal" = NULL,
"reactivationTime" = NULL
WHERE "id" = 320
`);

// Remove onFail link from Clementine action
await queryRunner.query(`
UPDATE "dbo"."liquidity_management_action"
SET "onFailId" = NULL
WHERE "id" = 236
`);

// Delete Boltz action
await queryRunner.query(`
DELETE FROM "dbo"."liquidity_management_action"
WHERE "system" = 'Boltz' AND "command" = 'deposit'
`);
}
};
3 changes: 3 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,9 @@ export class Configuration {
},
certificate: process.env.LIGHTNING_API_CERTIFICATE?.split('<br>').join('\n'),
},
boltz: {
apiUrl: process.env.BOLTZ_API_URL ?? 'https://lightning.space/v1',
},
spark: {
sparkWalletSeed: process.env.SPARK_WALLET_SEED,
},
Expand Down
3 changes: 3 additions & 0 deletions src/integration/blockchain/blockchain.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BlockchainApiModule } from './api/blockchain-api.module';
import { ArbitrumModule } from './arbitrum/arbitrum.module';
import { ArweaveModule } from './arweave/arweave.module';
import { BaseModule } from './base/base.module';
import { BoltzModule } from './boltz/boltz.module';
import { BscModule } from './bsc/bsc.module';
import { CardanoModule } from './cardano/cardano.module';
import { CitreaTestnetModule } from './citrea-testnet/citrea-testnet.module';
Expand Down Expand Up @@ -67,6 +68,7 @@ import { ZanoModule } from './zano/zano.module';
CitreaModule,
CitreaTestnetModule,
ClementineModule,
BoltzModule,
RealUnitBlockchainModule,
Eip7702DelegationModule,
PimlicoPaymasterModule,
Expand Down Expand Up @@ -99,6 +101,7 @@ import { ZanoModule } from './zano/zano.module';
CitreaModule,
CitreaTestnetModule,
ClementineModule,
BoltzModule,
CryptoService,
BlockchainRegistryService,
TxValidationService,
Expand Down
177 changes: 177 additions & 0 deletions src/integration/blockchain/boltz/boltz-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { HttpService } from 'src/shared/services/http.service';
import { DfxLogger } from 'src/shared/services/dfx-logger';

export interface BoltzConfig {
apiUrl: string;
}

// Boltz swap lifecycle events (from boltz-backend SwapUpdateEvent enum)
export enum BoltzSwapStatus {
CREATED = 'swap.created',
EXPIRED = 'swap.expired',

INVOICE_SET = 'invoice.set',
INVOICE_PENDING = 'invoice.pending',
INVOICE_PAID = 'invoice.paid',
INVOICE_SETTLED = 'invoice.settled',
INVOICE_FAILEDTOPAY = 'invoice.failedToPay',
INVOICE_EXPIRED = 'invoice.expired',

TRANSACTION_MEMPOOL = 'transaction.mempool',
TRANSACTION_CLAIM_PENDING = 'transaction.claim.pending',
TRANSACTION_CLAIMED = 'transaction.claimed',
TRANSACTION_CONFIRMED = 'transaction.confirmed',
TRANSACTION_REFUNDED = 'transaction.refunded',
TRANSACTION_FAILED = 'transaction.failed',
TRANSACTION_LOCKUP_FAILED = 'transaction.lockupFailed',

TRANSACTION_SERVER_MEMPOOL = 'transaction.server.mempool',
TRANSACTION_SERVER_CONFIRMED = 'transaction.server.confirmed',

MINERFEE_PAID = 'minerfee.paid',
}

// Chain Swap final events (BTC onchain -> cBTC onchain)
// Success: transaction.claimed
// Fail: swap.expired, transaction.failed, transaction.lockupFailed, transaction.refunded
export const ChainSwapSuccessStatuses = [BoltzSwapStatus.TRANSACTION_CLAIMED];
export const ChainSwapFailedStatuses = [
BoltzSwapStatus.EXPIRED,
BoltzSwapStatus.TRANSACTION_FAILED,
BoltzSwapStatus.TRANSACTION_LOCKUP_FAILED,
BoltzSwapStatus.TRANSACTION_REFUNDED,
];

export interface ChainSwapDetails {
swapTree: {
claimLeaf: { output: string; version: number };
refundLeaf: { output: string; version: number };
};
lockupAddress: string;
serverPublicKey: string;
timeoutBlockHeight: number;
amount: number;
blindingKey?: string;
refundAddress?: string;
claimAddress?: string;
bip21?: string;
}

export interface BoltzChainSwapResponse {
id: string;
claimDetails: ChainSwapDetails;
lockupDetails: ChainSwapDetails;
}

export interface BoltzSwapStatusResponse {
status: BoltzSwapStatus;
failureReason?: string;
failureDetails?: string;
zeroConfRejected?: boolean;
transaction?: {
id: string;
hex?: string;
};
}

export interface ChainPairInfo {
hash: string;
rate: number;
limits: {
maximal: number;
minimal: number;
maximalZeroConf: number;
};
fees: {
percentage: number;
minerFees: {
server: number;
user: {
claim: number;
lockup: number;
};
};
};
}

// Response: Record<fromAsset, Record<toAsset, ChainPairInfo>>
export type ChainPairsResponse = Record<string, Record<string, ChainPairInfo>>;

export interface HelpMeClaimRequest {
preimage: string;
preimageHash: string;
}

export interface HelpMeClaimResponse {
txHash: string;
}

export class BoltzClient {
private readonly logger = new DfxLogger(BoltzClient);

constructor(
private readonly http: HttpService,
private readonly config: BoltzConfig,
) {}

/**
* Fetch available chain swap pairs (includes pairHash needed for createChainSwap).
*/
async getChainPairs(): Promise<ChainPairsResponse> {
const url = `${this.config.apiUrl}/swap/v2/swap/chain/`;

return this.http.get<ChainPairsResponse>(url, { tryCount: 3, retryDelay: 2000 });
}

/**
* Create a Chain Swap: BTC (onchain) -> cBTC (Citrea onchain)
* For EVM destination chains, only claimAddress is needed (no claimPublicKey).
* refundPublicKey is required for BTC sender side to enable refunds on swap failure.
* preimageHash and pairHash are required by the Boltz API.
*/
async createChainSwap(
preimageHash: string,
claimAddress: string,
userLockAmount: number,
pairHash: string,
referralId: string,
refundPublicKey: string,
): Promise<BoltzChainSwapResponse> {
const url = `${this.config.apiUrl}/swap/v2/swap/chain/`;

const body = {
from: 'BTC',
to: 'cBTC',
preimageHash,
claimAddress,
userLockAmount,
pairHash,
referralId,
refundPublicKey,
};

this.logger.verbose(`Creating chain swap: ${userLockAmount} sats, BTC -> cBTC, claim=${claimAddress}`);

return this.http.post<BoltzChainSwapResponse>(url, body, { tryCount: 3, retryDelay: 2000 });
}

async getSwapStatus(swapId: string): Promise<BoltzSwapStatusResponse> {
const url = `${this.config.apiUrl}/swap/v2/swap/${swapId}`;

return this.http.get<BoltzSwapStatusResponse>(url, { tryCount: 3, retryDelay: 2000 });
}

/**
* Request Boltz to claim cBTC on behalf of the user (server-side claiming).
* The preimage proves payment; Boltz uses it to release cBTC to the claim address.
*/
async helpMeClaim(preimage: string, preimageHash: string): Promise<HelpMeClaimResponse> {
const url = `${this.config.apiUrl}/claim/help-me-claim`;

const body: HelpMeClaimRequest = { preimage, preimageHash };

this.logger.verbose(`Requesting help-me-claim for preimageHash=${preimageHash}`);

return this.http.post<HelpMeClaimResponse>(url, body, { tryCount: 3, retryDelay: 2000 });
}
}
10 changes: 10 additions & 0 deletions src/integration/blockchain/boltz/boltz.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SharedModule } from 'src/shared/shared.module';
import { BoltzService } from './boltz.service';

@Module({
imports: [SharedModule],
providers: [BoltzService],
exports: [BoltzService],
})
export class BoltzModule {}
18 changes: 18 additions & 0 deletions src/integration/blockchain/boltz/boltz.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { GetConfig } from 'src/config/config';
import { HttpService } from 'src/shared/services/http.service';
import { BoltzClient } from './boltz-client';

@Injectable()
export class BoltzService {
private readonly client: BoltzClient;

constructor(http: HttpService) {
const config = GetConfig().blockchain.boltz;
this.client = new BoltzClient(http, config);
}

getDefaultClient(): BoltzClient {
return this.client;
}
}
Loading
Loading