diff --git a/migration/1770290011000-ExtendLiquidityOrderCorrelationId.js b/migration/1770290011000-ExtendLiquidityOrderCorrelationId.js new file mode 100644 index 0000000000..ed0f2fea3d --- /dev/null +++ b/migration/1770290011000-ExtendLiquidityOrderCorrelationId.js @@ -0,0 +1,26 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class ExtendLiquidityOrderCorrelationId1770290011000 { + name = 'ExtendLiquidityOrderCorrelationId1770290011000' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "liquidity_management_order" ALTER COLUMN "correlationId" nvarchar(MAX)`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "liquidity_management_order" ALTER COLUMN "correlationId" nvarchar(256)`); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index 8a9924eaef..f9a4249612 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -824,6 +824,7 @@ export class Configuration { signerAddress: process.env.CLEMENTINE_SIGNER_ADDRESS, timeoutMs: parseInt(process.env.CLEMENTINE_TIMEOUT_MS ?? '60000'), signingTimeoutMs: parseInt(process.env.CLEMENTINE_SIGNING_TIMEOUT_MS ?? '300000'), + expectedVersion: process.env.CLEMENTINE_CLI_VERSION ?? '', }, bitcoinTestnet4: { btcTestnet4Output: { diff --git a/src/integration/blockchain/clementine/clementine-client.ts b/src/integration/blockchain/clementine/clementine-client.ts index 2e82a65fd6..3a099592de 100644 --- a/src/integration/blockchain/clementine/clementine-client.ts +++ b/src/integration/blockchain/clementine/clementine-client.ts @@ -12,6 +12,13 @@ export interface ClementineConfig { homeDir: string; timeoutMs: number; signingTimeoutMs: number; + expectedVersion: string; +} + +export interface ClementineVersionInfo { + version: string; + commit?: string; + rawOutput: string; } export enum DepositStatus { @@ -22,6 +29,7 @@ export enum DepositStatus { } export enum WithdrawStatus { + NOT_FOUND = 'not_found', PENDING = 'pending', SCANNING = 'scanning', SIGNING = 'signing', @@ -89,6 +97,26 @@ export class ClementineClient { return this.executeCommand(['show-config']); } + /** + * Get the CLI version information + * @returns Version info including semver version and optional commit hash + */ + async getVersion(): Promise { + const output = await this.executeCommand(['--version'], undefined, false); + + // Try to parse semver version (e.g., "1.2.3", "v1.2.3", "clementine-cli 1.2.3") + const versionMatch = output.match(/v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)/); + + // Try to parse commit hash (e.g., "commit: abc123", "git: abc123def") + const commitMatch = output.match(/(?:commit|git)[:\s]+([a-f0-9]{7,40})/i); + + return { + version: versionMatch?.[1] ?? 'unknown', + commit: commitMatch?.[1], + rawOutput: output.trim(), + }; + } + // --- WALLET OPERATIONS --- // /** @@ -206,6 +234,9 @@ export class ClementineClient { return null; } + // Unknown output format - log warning and return null + this.logger.warn(`withdrawScan: unexpected CLI output format, treating as no UTXO found. Output: ${output}`); + return null; } @@ -227,7 +258,24 @@ export class ClementineClient { this.config.signingTimeoutMs, ); - // Parse both signatures from output + // Try to parse signatures by their labels first (safer) + // CLI output format: "Optimistic withdrawal signature hex: {sig}" and "Operator-paid withdrawal signature hex: {sig}" + const optimisticMatch = output.match(/optimistic[^:]*signature[^:]*:\s*([a-f0-9]+)/i); + const operatorMatch = output.match(/operator[^:]*signature[^:]*:\s*([a-f0-9]+)/i); + + if (optimisticMatch && operatorMatch) { + return { + optimisticSignature: optimisticMatch[1], + operatorPaidSignature: operatorMatch[1], + }; + } + + // Fallback: parse by order (less safe - assumes first is optimistic, second is operator) + this.logger.warn( + 'Could not find labeled signatures in CLI output, falling back to order-based parsing. ' + + 'This may cause issues if CLI output format changes.', + ); + const sigMatches = output.match(/signature[:\s]+([a-f0-9]+)/gi); if (!sigMatches || sigMatches.length < 2) { throw new Error(`Failed to parse withdrawal signatures from CLI output: ${output}`); @@ -270,9 +318,20 @@ export class ClementineClient { * Get the status of a withdrawal operation * Command: clementine-cli withdraw status * @param withdrawalUtxo The withdrawal UTXO to check (format: txid:vout) + * @returns Status result with NOT_FOUND if no withdrawal exists for this UTXO */ async withdrawStatus(withdrawalUtxo: string): Promise { const output = await this.executeCommand(['withdraw', 'status', withdrawalUtxo]); + + // Check if no withdrawal exists for this UTXO + // CLI outputs "No withdrawals found for OutPoint ..." if never submitted + if (output.toLowerCase().includes('no withdrawals found')) { + return { + withdrawalUtxo, + status: WithdrawStatus.NOT_FOUND, + }; + } + return this.parseWithdrawStatus(output, withdrawalUtxo); } @@ -302,13 +361,12 @@ export class ClementineClient { // --- INTERNAL METHODS --- // - private async executeCommand(args: string[], timeout?: number): Promise { - const fullArgs = this.buildArgs(args); - this.logger.verbose(`Executing: ${this.config.cliPath} ${fullArgs.join(' ')}`); + private async executeCommand(args: string[], timeout?: number, addNetworkFlag = true): Promise { + const finalArgs = addNetworkFlag ? this.addNetworkFlag(args) : args; + this.logger.verbose(`Executing: ${this.config.cliPath} ${finalArgs.join(' ')}`); try { - const output = await this.spawnAsync(fullArgs, timeout ?? this.config.timeoutMs); - return output; + return await this.spawnAsync(finalArgs, timeout ?? this.config.timeoutMs); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`Clementine CLI error: ${message}`); @@ -389,7 +447,7 @@ export class ClementineClient { }); } - private buildArgs(baseArgs: string[]): string[] { + private addNetworkFlag(baseArgs: string[]): string[] { const args = [...baseArgs]; // Insert --network flag after the subcommand, before positional arguments diff --git a/src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts index c37cd20b3e..5ccaa8a246 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts @@ -13,6 +13,7 @@ import { CLEMENTINE_WITHDRAWAL_DUST_BTC, ClementineClient, ClementineNetwork, + WithdrawStatus, } from 'src/integration/blockchain/clementine/clementine-client'; import { ClementineService } from 'src/integration/blockchain/clementine/clementine.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; @@ -87,6 +88,7 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { private readonly recoveryTaprootAddress: string; private readonly signerAddress: string; private readonly network: ClementineNetwork; + private readonly expectedCliVersion: string; private networkValidated = false; constructor( @@ -105,6 +107,7 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { this.network = config.network; this.recoveryTaprootAddress = config.recoveryTaprootAddress; this.signerAddress = config.signerAddress; + this.expectedCliVersion = config.expectedVersion; this.clementineClient = clementineService.getDefaultClient(); this.bitcoinClient = this.isTestnet @@ -158,9 +161,7 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { } // Validate configuration - if (!this.recoveryTaprootAddress) { - throw new OrderNotProcessableException('Clementine recovery taproot address not configured'); - } + this.validateRecoveryAddress(); // Validate network consistency on first use await this.validateNetworkConsistency(); @@ -180,7 +181,9 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { const citreaAddress = this.citreaClient.walletAddress; const { depositAddress } = await this.clementineClient.depositStart(this.recoveryTaprootAddress, citreaAddress); - this.logger.verbose(`Deposit started: sending ${CLEMENTINE_BRIDGE_AMOUNT_BTC} BTC to ${depositAddress}`); + this.logger.info( + `Deposit address generated: ${depositAddress}, recovery: ${this.recoveryTaprootAddress}, citrea: ${citreaAddress}`, + ); // Update order with fixed amount order.inputAmount = CLEMENTINE_BRIDGE_AMOUNT_BTC; @@ -222,9 +225,7 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { } // Validate configuration - if (!this.signerAddress) { - throw new OrderNotProcessableException('Clementine signer address not configured'); - } + this.validateSignerAddress(); // Validate network consistency on first use await this.validateNetworkConsistency(); @@ -390,7 +391,10 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { data.operatorPaidSignature = signatures.operatorPaidSignature; data.step = 'signatures_generated'; - this.logger.verbose(`Withdrawal: signatures generated`); + this.logger.info( + `Withdrawal signatures generated for UTXO ${data.withdrawalUtxo}, ` + + `signer: ${data.signerAddress}, destination: ${data.destinationAddress}`, + ); order.correlationId = `${CORRELATION_PREFIX.WITHDRAW}${this.encodeWithdrawCorrelation(data)}`; return false; } @@ -399,6 +403,27 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { order: LiquidityManagementOrder, data: WithdrawCorrelationData, ): Promise { + // Idempotency check: verify if withdrawal was already sent to bridge + // This prevents double cBTC burning if the process crashes after withdrawSend() + // but before the correlationId is persisted + const existingStatus = await this.clementineClient.withdrawStatus(data.withdrawalUtxo); + + // Only proceed with withdrawSend() if NO withdrawal exists for this UTXO + // If status is anything other than NOT_FOUND, the withdrawal was already submitted + if (existingStatus.status !== WithdrawStatus.NOT_FOUND) { + this.logger.info( + `Withdrawal: already submitted to bridge (status: ${existingStatus.status}), skipping withdrawSend()`, + ); + + // Already submitted - move to next step without calling withdrawSend() again + data.step = 'sent_to_bridge'; + // Use order.updated as fallback timestamp since we don't know the exact time + data.sentToBridgeAt = data.sentToBridgeAt ?? order.updated.getTime(); + + order.correlationId = `${CORRELATION_PREFIX.WITHDRAW}${this.encodeWithdrawCorrelation(data)}`; + return false; + } + // Send to bridge contract (burns cBTC) await this.clementineClient.withdrawSend( data.signerAddress, @@ -456,11 +481,17 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { } // Check if 12 hours have passed - if so, send to operators - if (!data.sentToBridgeAt) { - this.logger.warn('Withdrawal: sentToBridgeAt missing, skipping operator escalation check'); - return false; + // Use order.updated as fallback if sentToBridgeAt is missing (e.g., due to data loss or migration) + let sentTimestamp = data.sentToBridgeAt; + if (!sentTimestamp) { + this.logger.warn('Withdrawal: sentToBridgeAt missing, using order.updated as fallback for timeout calculation'); + sentTimestamp = order.updated.getTime(); + + // Persist the fallback timestamp for future checks + data.sentToBridgeAt = sentTimestamp; + order.correlationId = `${CORRELATION_PREFIX.WITHDRAW}${this.encodeWithdrawCorrelation(data)}`; } - const elapsed = Date.now() - data.sentToBridgeAt; + const elapsed = Date.now() - sentTimestamp; if (elapsed > OPTIMISTIC_TIMEOUT_MS) { this.logger.verbose(`Withdrawal: 12 hours elapsed, sending to operators`); @@ -501,6 +532,11 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { //*** HELPER METHODS ***// private async sendBtcToAddress(address: string, amount: number): Promise { + // Validate address format before sending + if (!this.isValidBitcoinAddress(address)) { + throw new OrderFailedException(`Invalid Bitcoin address format: ${address}`); + } + const feeRate = await this.getFeeRate(); const txId = await this.bitcoinClient.sendMany([{ addressTo: address, amount }], feeRate); @@ -508,9 +544,35 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { throw new OrderFailedException(`Failed to send BTC to address ${address}`); } + this.logger.info(`Sent ${amount} BTC to ${address}, txId: ${txId}`); + return txId; } + /** + * Validates Bitcoin address format (basic validation). + * Checks length and prefix for the configured network. + */ + private isValidBitcoinAddress(address: string): boolean { + if (!address || address.length < 26 || address.length > 90) { + return false; + } + + // Must match expected network prefixes + if (!this.isAddressForNetwork(address, this.network)) { + return false; + } + + // Bech32/Bech32m addresses (bc1/tb1) should be lowercase + if (address.startsWith('bc1') || address.startsWith('tb1') || address.startsWith('bcrt1')) { + if (address !== address.toLowerCase()) { + return false; + } + } + + return true; + } + private encodeWithdrawCorrelation(data: WithdrawCorrelationData): string { return Buffer.from(JSON.stringify(data)).toString('base64'); } @@ -519,6 +581,62 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8')); } + //*** ADDRESS VALIDATION ***// + + /** + * Validates the recovery taproot address configuration. + * Must be present, have 'dep' prefix, and underlying address must match network. + */ + private validateRecoveryAddress(): void { + if (!this.recoveryTaprootAddress) { + throw new OrderNotProcessableException('Clementine recovery taproot address not configured'); + } + + // Must have 'dep' prefix + if (!this.recoveryTaprootAddress.startsWith('dep')) { + throw new OrderNotProcessableException( + `Clementine recovery address must have 'dep' prefix, got: ${this.recoveryTaprootAddress}`, + ); + } + + // Underlying address must be valid + const underlyingAddress = this.recoveryTaprootAddress.replace(/^dep/, ''); + if (!this.isValidBitcoinAddress(underlyingAddress)) { + throw new OrderNotProcessableException( + `Clementine recovery address has invalid underlying Bitcoin address: ${underlyingAddress}`, + ); + } + + this.logger.verbose(`Recovery address validated: ${this.recoveryTaprootAddress}`); + } + + /** + * Validates the signer address configuration. + * Must be present, have 'wit' prefix, and underlying address must match network. + */ + private validateSignerAddress(): void { + if (!this.signerAddress) { + throw new OrderNotProcessableException('Clementine signer address not configured'); + } + + // Must have 'wit' prefix + if (!this.signerAddress.startsWith('wit')) { + throw new OrderNotProcessableException( + `Clementine signer address must have 'wit' prefix, got: ${this.signerAddress}`, + ); + } + + // Underlying address must be valid + const underlyingAddress = this.signerAddress.replace(/^wit/, ''); + if (!this.isValidBitcoinAddress(underlyingAddress)) { + throw new OrderNotProcessableException( + `Clementine signer address has invalid underlying Bitcoin address: ${underlyingAddress}`, + ); + } + + this.logger.verbose(`Signer address validated: ${this.signerAddress}`); + } + //*** NETWORK VALIDATION ***// /** @@ -531,6 +649,27 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { const errors: string[] = []; + // Validate CLI version + if (!this.expectedCliVersion) { + errors.push('CLEMENTINE_CLI_VERSION environment variable is not configured'); + } else { + try { + const versionInfo = await this.clementineClient.getVersion(); + this.logger.info( + `Clementine CLI version: ${versionInfo.version}` + + (versionInfo.commit ? ` (commit: ${versionInfo.commit})` : ''), + ); + + if (versionInfo.version !== this.expectedCliVersion) { + errors.push( + `Clementine CLI version mismatch: expected '${this.expectedCliVersion}', got '${versionInfo.version}'`, + ); + } + } catch (e) { + errors.push(`Failed to verify Clementine CLI version: ${e.message}`); + } + } + // Validate Bitcoin node is on correct network try { const btcInfo = await this.bitcoinClient.getInfo(); diff --git a/src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity.ts b/src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity.ts index 1e11d72f3c..12e381593a 100644 --- a/src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity.ts +++ b/src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity.ts @@ -45,7 +45,7 @@ export class LiquidityManagementOrder extends IEntity { @Column({ type: 'int', nullable: true }) previousOrderId?: number; - @Column({ length: 256, nullable: true }) + @Column({ length: 'MAX', nullable: true }) correlationId?: string; @Column({ length: 'MAX', nullable: true }) diff --git a/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts b/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts index bb0ad4426f..bb347e1049 100644 --- a/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts +++ b/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts @@ -239,13 +239,11 @@ export class LiquidityManagementPipelineService { if (isComplete) { order.complete(); - await this.orderRepo.save(order); - - this.logger.verbose(`Liquidity management order ${order.id} complete`); - return true; } - return false; + await this.orderRepo.save(order); + + return isComplete; } private async handlePipelineCompletion(pipeline: LiquidityManagementPipeline): Promise {