import { randomUUID } from 'crypto'; import type { AuditPublisher, CustodyAdapter, MintJobSink, OutstandingPolicy, } from './custody-adapter'; import type { AuditRecord, BasketMandateSnapshot, BitcoinDepositEvent, DepositInstruction, MintJob, } from './types'; const DEFAULT_CONFIRMATIONS_REQUIRED = 6; function nowIso(): string { return new Date().toISOString(); } function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } export class NativeBitcoinWatcher { private readonly instructions = new Map(); private readonly mintJobs = new Map(); private readonly auditRecords: AuditRecord[] = []; private cursor?: string; constructor( private readonly adapter: CustodyAdapter, private readonly mintSink: MintJobSink, private readonly auditPublisher: AuditPublisher, private readonly outstandingPolicy?: OutstandingPolicy, ) {} async createDepositInstruction(input: { basketMandate: BasketMandateSnapshot; expectedAmountSats?: number; }): Promise { const instructionId = randomUUID(); const depositAddress = await this.adapter.allocateDepositAddress({ instructionId, clientId: input.basketMandate.clientId, }); const timestamp = nowIso(); const instruction: DepositInstruction = { id: instructionId, basketMandate: clone(input.basketMandate), depositAddress, expectedAmountSats: input.expectedAmountSats, currentConfirmations: 0, confirmationsRequired: DEFAULT_CONFIRMATIONS_REQUIRED, status: 'instruction_created', createdAt: timestamp, updatedAt: timestamp, }; this.instructions.set(instruction.id, instruction); await this.publishAudit({ id: randomUUID(), instructionId, category: 'deposit_instruction', message: 'Allocated BTC deposit address for Chain 138 cBTC mint flow', metadata: { depositAddress, basketMandateId: input.basketMandate.id, }, createdAt: timestamp, }); return clone(instruction); } getInstruction(id: string): DepositInstruction | null { const instruction = this.instructions.get(id); return instruction ? clone(instruction) : null; } getAuditRecords(): AuditRecord[] { return clone(this.auditRecords); } getMintJobs(): MintJob[] { return Array.from(this.mintJobs.values()).map((job) => clone(job)); } async sync(): Promise { const { events, cursor } = await this.adapter.listDepositEvents(this.cursor); for (const event of events) { await this.applyEvent(event); } this.cursor = cursor; return Array.from(this.instructions.values()).map((instruction) => clone(instruction)); } private async applyEvent(event: BitcoinDepositEvent): Promise { const instruction = this.instructions.get(event.instructionId); if (!instruction) { return; } if ( instruction.observedTxId === event.txId && instruction.currentConfirmations >= event.confirmations && instruction.status !== 'frozen' ) { return; } instruction.observedTxId = event.txId; instruction.currentConfirmations = Math.max(0, event.confirmations); instruction.expectedAmountSats = event.amountSats; instruction.updatedAt = event.observedAt || nowIso(); if (event.type === 'reorg' || event.confirmations < instruction.confirmationsRequired) { instruction.status = 'pending_confirmations'; await this.publishAudit({ id: randomUUID(), instructionId: instruction.id, category: 'confirmation', message: 'Deposit observed but waiting for final BTC confirmations', metadata: { txId: event.txId, confirmations: event.confirmations, type: event.type, }, createdAt: instruction.updatedAt, }); return; } instruction.status = 'confirmed'; await this.publishAudit({ id: randomUUID(), instructionId: instruction.id, category: 'confirmation', message: 'Deposit reached 6 confirmations and is eligible for minting', metadata: { txId: event.txId, confirmations: event.confirmations, amountSats: event.amountSats, }, createdAt: instruction.updatedAt, }); await this.maybeQueueMint(instruction, event.amountSats); } private async maybeQueueMint(instruction: DepositInstruction, amountSats: number): Promise { if (this.mintJobs.has(instruction.id)) { instruction.status = 'minted'; return; } const reserveBalanceSats = await this.adapter.getConfirmedReserveBalanceSats(); const alreadyMintedSats = Array.from(this.mintJobs.values()).reduce( (total, job) => total + job.amountSats, 0, ); if (reserveBalanceSats < alreadyMintedSats + amountSats) { await this.freezeInstruction( instruction, 'Reserve deficit detected during cBTC reconciliation', { reserveBalanceSats, alreadyMintedSats, requestedMintSats: amountSats, }, ); return; } if (this.outstandingPolicy) { const currentOutstandingSats = await this.outstandingPolicy.getCurrentOutstandingSats(); const maxOutstandingSats = await this.outstandingPolicy.getMaxOutstandingSats(); if (maxOutstandingSats > 0 && currentOutstandingSats + amountSats > maxOutstandingSats) { await this.freezeInstruction( instruction, 'Bridge outstanding limit would be exceeded by mint request', { currentOutstandingSats, maxOutstandingSats, requestedMintSats: amountSats, }, ); return; } } const timestamp = nowIso(); const mintJob: MintJob = { id: randomUUID(), instructionId: instruction.id, basketMandateId: instruction.basketMandate.id, chain138VaultAddress: instruction.basketMandate.chain138VaultAddress, canonicalSymbol: 'cBTC', amountSats, status: 'queued', createdAt: timestamp, }; await this.mintSink.enqueue(mintJob); this.mintJobs.set(instruction.id, mintJob); instruction.status = 'minted'; instruction.updatedAt = timestamp; await this.publishAudit({ id: randomUUID(), instructionId: instruction.id, category: 'mint_job', message: 'Queued auto-mint job for confirmed BTC deposit', metadata: { mintJobId: mintJob.id, chain138VaultAddress: mintJob.chain138VaultAddress, amountSats, }, createdAt: timestamp, }); } private async freezeInstruction( instruction: DepositInstruction, reason: string, metadata?: Record, ): Promise { instruction.status = 'frozen'; instruction.freezeReason = reason; instruction.updatedAt = nowIso(); await this.publishAudit({ id: randomUUID(), instructionId: instruction.id, category: 'freeze', message: reason, metadata, createdAt: instruction.updatedAt, }); } private async publishAudit(record: AuditRecord): Promise { this.auditRecords.push(record); await this.auditPublisher.publish(record); } }