Files
smom-dbis-138/services/btc-intake/src/native-bitcoin-watcher.ts
defiQUG 76aa419320 feat: bridges, PMM, flash workflow, token-aggregation, and deployment docs
- CCIP/trustless bridge contracts, GRU tokens, DEX/PMM tests, reserve vault.
- Token-aggregation service routes, planner, chain config, relay env templates.
- Config snapshots and multi-chain deployment markdown updates.
- gitignore services/btc-intake/dist/ (tsc output); do not track dist.

Run forge build && forge test before deploy (large solc graph).

Made-with: Cursor
2026-04-07 23:40:52 -07:00

251 lines
7.2 KiB
TypeScript

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<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export class NativeBitcoinWatcher {
private readonly instructions = new Map<string, DepositInstruction>();
private readonly mintJobs = new Map<string, MintJob>();
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<DepositInstruction> {
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<DepositInstruction[]> {
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<void> {
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<void> {
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<string, unknown>,
): Promise<void> {
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<void> {
this.auditRecords.push(record);
await this.auditPublisher.publish(record);
}
}