diff --git a/package-lock.json b/package-lock.json index e579ebb90e..1d59ad2213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,6 +90,7 @@ "nestjs-i18n": "^10.5.1", "nestjs-real-ip": "^2.2.0", "node-2fa": "^2.0.3", + "node-pty": "^1.1.0", "node-sql-parser": "^5.3.13", "nodemailer": "^6.10.1", "passport": "^0.6.0", @@ -22942,6 +22943,22 @@ "dev": true, "license": "MIT" }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", diff --git a/package.json b/package.json index 60bab8cd48..b2adfb4a0d 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "nestjs-i18n": "^10.5.1", "nestjs-real-ip": "^2.2.0", "node-2fa": "^2.0.3", + "node-pty": "^1.1.0", "node-sql-parser": "^5.3.13", "nodemailer": "^6.10.1", "passport": "^0.6.0", diff --git a/src/config/config.ts b/src/config/config.ts index f9a4249612..7095825298 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -474,7 +474,7 @@ export class Configuration { { prefixes: (userData: UserData) => [`user/${userData.id}/UserNotes`], fileTypes: [ContentType.PDF], - filter: (file: KycFileBlob) => file.name.toLowerCase().includes('-TxAudit2025'.toLowerCase()), + filter: (file: KycFileBlob) => file.name.toLowerCase().includes('-TxAudit2026'.toLowerCase()), }, ], }, @@ -825,6 +825,7 @@ export class Configuration { timeoutMs: parseInt(process.env.CLEMENTINE_TIMEOUT_MS ?? '60000'), signingTimeoutMs: parseInt(process.env.CLEMENTINE_SIGNING_TIMEOUT_MS ?? '300000'), expectedVersion: process.env.CLEMENTINE_CLI_VERSION ?? '', + passphrase: process.env.CLEMENTINE_PASSPHRASE ?? '', }, bitcoinTestnet4: { btcTestnet4Output: { diff --git a/src/integration/blockchain/clementine/clementine-client.ts b/src/integration/blockchain/clementine/clementine-client.ts index 3a099592de..7fd07bcf65 100644 --- a/src/integration/blockchain/clementine/clementine-client.ts +++ b/src/integration/blockchain/clementine/clementine-client.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process'; +import * as pty from 'node-pty'; import { DfxLogger } from 'src/shared/services/dfx-logger'; export enum ClementineNetwork { @@ -13,6 +13,7 @@ export interface ClementineConfig { timeoutMs: number; signingTimeoutMs: number; expectedVersion: string; + passphrase: string; } export interface ClementineVersionInfo { @@ -223,6 +224,11 @@ export class ClementineClient { async withdrawScan(signerAddress: string, destinationAddress: string): Promise { const output = await this.executeCommand(['withdraw', 'scan', signerAddress, destinationAddress]); + if (output.toLowerCase().includes('waiting for confirmation') || output.toLowerCase().includes('unconfirmed')) { + this.logger.verbose('withdrawScan: UTXO found but unconfirmed, waiting for confirmation'); + return null; + } + // Parse withdrawal UTXO from output (format: txid:vout) const utxoMatch = output.match(/([a-f0-9]{64}:\d+)/i); if (utxoMatch) { @@ -297,21 +303,21 @@ export class ClementineClient { * @param destinationAddress Bitcoin destination address * @param withdrawalUtxo The withdrawal UTXO (format: txid:vout) * @param optimisticSignature The optimistic withdrawal signature + * @param citreaPrivateKey Citrea private key for signing the withdrawal transaction (64 hex chars) */ async withdrawSend( signerAddress: string, destinationAddress: string, withdrawalUtxo: string, optimisticSignature: string, + citreaPrivateKey: string, ): Promise { - await this.executeCommand([ - 'withdraw', - 'send-safe-withdraw', - signerAddress, - destinationAddress, - withdrawalUtxo, - optimisticSignature, - ]); + await this.executeCommand( + ['withdraw', 'send-safe-withdraw', signerAddress, destinationAddress, withdrawalUtxo, optimisticSignature], + this.config.signingTimeoutMs, + true, + citreaPrivateKey, + ); } /** @@ -349,24 +355,31 @@ export class ClementineClient { withdrawalUtxo: string, operatorPaidSignature: string, ): Promise { - await this.executeCommand([ - 'withdraw', - 'send-withdrawal-signature-to-operators', - signerAddress, - destinationAddress, - withdrawalUtxo, - operatorPaidSignature, - ]); + await this.executeCommand( + [ + 'withdraw', + 'send-withdrawal-signature-to-operators', + signerAddress, + destinationAddress, + withdrawalUtxo, + operatorPaidSignature, + ], + this.config.signingTimeoutMs, + ); } // --- INTERNAL METHODS --- // - private async executeCommand(args: string[], timeout?: number, addNetworkFlag = true): Promise { + private async executeCommand( + args: string[], + timeout?: number, + addNetworkFlag = true, + citreaPrivateKey?: string, + ): Promise { const finalArgs = addNetworkFlag ? this.addNetworkFlag(args) : args; - this.logger.verbose(`Executing: ${this.config.cliPath} ${finalArgs.join(' ')}`); try { - return await this.spawnAsync(finalArgs, timeout ?? this.config.timeoutMs); + return await this.spawnWithPty(finalArgs, timeout ?? this.config.timeoutMs, citreaPrivateKey); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`Clementine CLI error: ${message}`); @@ -374,17 +387,27 @@ export class ClementineClient { } } - private spawnAsync(args: string[], timeout: number): Promise { + /** + * Spawn CLI with PTY to handle interactive prompts. + * @param args CLI arguments + * @param timeout Timeout in milliseconds + * @param citreaPrivateKey Optional Citrea private key for signing withdrawals (64 hex chars) + */ + private spawnWithPty(args: string[], timeout: number, citreaPrivateKey?: string): Promise { return new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; + let output = ''; let timeoutId: NodeJS.Timeout | null = null; - let killTimeoutId: NodeJS.Timeout | null = null; let isSettled = false; + let passphraseHandled = false; + let citreaKeyHandled = false; + + this.logger.verbose(`Executing (PTY): ${this.config.cliPath} ${args.join(' ')}`); - const proc = spawn(this.config.cliPath, args, { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, HOME: this.config.homeDir }, + const proc = pty.spawn(this.config.cliPath, args, { + name: 'xterm', + cols: 200, + rows: 30, + env: { ...process.env, HOME: this.config.homeDir } as Record, }); const cleanup = (): void => { @@ -392,16 +415,13 @@ export class ClementineClient { clearTimeout(timeoutId); timeoutId = null; } - if (killTimeoutId) { - clearTimeout(killTimeoutId); - killTimeoutId = null; - } }; const settle = (error?: Error, result?: string): void => { if (isSettled) return; isSettled = true; cleanup(); + proc.kill(); if (error) { reject(error); @@ -410,38 +430,34 @@ export class ClementineClient { } }; - // Timeout handling timeoutId = setTimeout(() => { - proc.kill('SIGTERM'); - - // Force kill after 5 seconds if process doesn't respond to SIGTERM - killTimeoutId = setTimeout(() => { - if (proc.exitCode === null) { - // Process still running, force kill - proc.kill('SIGKILL'); - } - }, 5000); - settle(new Error(`Command timed out after ${timeout}ms`)); }, timeout); - proc.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - }); + proc.onData((data: string) => { + output += data; + const lowerData = data.toLowerCase(); - proc.stderr.on('data', (data: Buffer) => { - stderr += data.toString(); - }); + if (!passphraseHandled && lowerData.includes('passphrase')) { + passphraseHandled = true; + proc.write(this.config.passphrase + '\r'); + } - proc.on('error', (error: Error) => { - settle(new Error(`Spawn error: ${error.message}`)); + if (!citreaKeyHandled && lowerData.includes('secret key')) { + citreaKeyHandled = true; + if (citreaPrivateKey) { + proc.write(citreaPrivateKey + '\r'); + } else { + settle(new Error('CLI prompted for Citrea private key but none was provided')); + } + } }); - proc.on('close', (code: number | null) => { - if (code === 0) { - settle(undefined, stdout); + proc.onExit(({ exitCode }) => { + if (exitCode === 0) { + settle(undefined, output); } else { - settle(new Error(`Exit code ${code}: ${stderr || stdout}`)); + settle(new Error(`Exit code ${exitCode}: ${output}`)); } }); }); diff --git a/src/subdomains/core/aml/services/aml.service.ts b/src/subdomains/core/aml/services/aml.service.ts index 3ad0cb2240..58fec6773a 100644 --- a/src/subdomains/core/aml/services/aml.service.ts +++ b/src/subdomains/core/aml/services/aml.service.ts @@ -5,6 +5,7 @@ import { CountryService } from 'src/shared/models/country/country.service'; import { IpLogService } from 'src/shared/models/ip-log/ip-log.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; +import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { NameCheckService } from 'src/subdomains/generic/kyc/services/name-check.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; import { BankData, BankDataType } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; @@ -41,6 +42,7 @@ export class AmlService { private readonly userService: UserService, private readonly transactionService: TransactionService, private readonly ipLogService: IpLogService, + private readonly kycService: KycService, ) {} async postProcessing(entity: BuyFiat | BuyCrypto, last30dVolume: number | undefined): Promise { @@ -98,6 +100,7 @@ export class AmlService { const blacklist = await this.specialExternalBankAccountService.getBlacklist(); const multiAccountBankNames = await this.specialExternalBankAccountService.getMultiAccountNames(); + entity.userData.kycSteps = await this.kycService.getStepsByUserData(entity.userData.id); entity.userData.users = await this.userService.getAllUserDataUsers(entity.userData.id); let bankData = await this.getBankData(entity); const refUser = diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 8b1a4aa7ab..006c9ea7f4 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -234,7 +234,7 @@ export class BuyCryptoService { cryptoInput: true, bankTx: true, checkoutTx: true, - transaction: { user: { wallet: true }, userData: { users: true } }, + transaction: { user: { wallet: true }, userData: { users: true, kycSteps: true } }, chargebackOutput: true, bankData: true, }, 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 a5caec0461..249e7198ce 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 @@ -66,10 +66,17 @@ const CITREA_CHAIN_IDS: Record = { [ClementineNetwork.TESTNET4]: 5115, // Citrea testnet }; +// Bitcoin confirmations required before block is relayed to Citrea's BitcoinLightClient +const BITCOIN_RELAY_CONFIRMATIONS: Record = { + [ClementineNetwork.BITCOIN]: 6, + [ClementineNetwork.TESTNET4]: 100, +}; + interface WithdrawCorrelationData { step: string; signerAddress: string; destinationAddress: string; + dustTxId?: string; withdrawalUtxo?: string; optimisticSignature?: string; operatorPaidSignature?: string; @@ -264,6 +271,7 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { step: 'dust_sent', signerAddress: this.signerAddress, destinationAddress, + dustTxId, }; return `${CORRELATION_PREFIX.WITHDRAW}${this.encodeWithdrawCorrelation(correlationData)}`; @@ -287,13 +295,12 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { const correlationData = order.correlationId.replace(CORRELATION_PREFIX.DEPOSIT, ''); const [depositAddress, btcTxId] = correlationData.split(':'); - // Step 1: Verify the Bitcoin transaction is confirmed - if (btcTxId) { - const isConfirmed = await this.bitcoinClient.isTxComplete(btcTxId, 6); // Clementine requires 6+ confirmations - if (!isConfirmed) { - this.logger.verbose(`Deposit ${depositAddress}: BTC transaction not yet confirmed (need 6+)`); - return false; - } + // Step 1: Verify the Bitcoin transaction has enough confirmations for block relay + if (btcTxId && !(await this.isBtcTxRelayConfirmed(btcTxId))) { + this.logger.verbose( + `Deposit ${depositAddress}: BTC TX not yet confirmed (need ${BITCOIN_RELAY_CONFIRMATIONS[this.network]}+)`, + ); + return false; } // Step 2: Check Clementine deposit status @@ -403,6 +410,14 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { order: LiquidityManagementOrder, data: WithdrawCorrelationData, ): Promise { + // Check if the dust TX has enough confirmations for the block to be relayed to Citrea + if (data.dustTxId && !(await this.isBtcTxRelayConfirmed(data.dustTxId))) { + this.logger.verbose( + `Withdrawal: waiting for dust TX to reach ${BITCOIN_RELAY_CONFIRMATIONS[this.network]} confirmations`, + ); + return false; + } + // 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 @@ -430,6 +445,7 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { data.destinationAddress, data.withdrawalUtxo, data.optimisticSignature, + this.citreaPrivateKey, ); data.step = 'sent_to_bridge'; @@ -739,6 +755,12 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { return this.network === ClementineNetwork.TESTNET4; } + private get citreaPrivateKey(): string { + return this.isTestnet + ? GetConfig().blockchain.citreaTestnet.citreaTestnetWalletPrivateKey + : GetConfig().blockchain.citrea.citreaWalletPrivateKey; + } + private get citreaBlockchain(): Blockchain { return this.isTestnet ? Blockchain.CITREA_TESTNET : Blockchain.CITREA; } @@ -752,4 +774,12 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { ? this.bitcoinTestnet4FeeService.getRecommendedFeeRate() : this.bitcoinFeeService.getRecommendedFeeRate(); } + + /** + * Check if a Bitcoin TX has enough confirmations for its block to be relayed to Citrea. + */ + private isBtcTxRelayConfirmed(txId: string): Promise { + const requiredConfirmations = BITCOIN_RELAY_CONFIRMATIONS[this.network]; + return this.bitcoinClient.isTxComplete(txId, requiredConfirmations); + } } diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index dbd75f4cd6..de02dfd4de 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -136,7 +136,7 @@ export class BuyFiatService { fiatOutput: true, bankTx: true, cryptoInput: { route: { user: true }, transaction: true }, - transaction: { user: { wallet: true }, userData: true }, + transaction: { user: { wallet: true }, userData: { kycSteps: true } }, bankData: true, }, }); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 0461eb6a98..23327e13b6 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -324,6 +324,19 @@ export class RealUnitController { // --- Registration Endpoints --- + @Get('register/status') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Check if wallet is registered for RealUnit', + description: 'Returns true if the connected wallet is registered for RealUnit, false otherwise', + }) + @ApiOkResponse({ type: Boolean }) + async isRegistered(@GetJwt() jwt: JwtPayload): Promise { + const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true } }); + return this.realunitService.hasRegistrationForWallet(user.userData, jwt.address); + } + @Post('register/email') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 21a29bd545..babdcd6a5b 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -579,7 +579,7 @@ export class RealUnitService { if (!success) throw new BadRequestException('Failed to forward registration to Aktionariat'); } - private hasRegistrationForWallet(userData: UserData, walletAddress: string): boolean { + hasRegistrationForWallet(userData: UserData, walletAddress: string): boolean { return userData .getStepsWith(KycStepName.REALUNIT_REGISTRATION) .filter((s) => !(s.isFailed || s.isCanceled))