Files
smom-dbis-138/services/btc-intake/src/native-bitcoin-watcher.ts
zaragoza444 5a9889cace
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m3s
CI/CD Pipeline / Security Scanning (push) Successful in 2m58s
CI/CD Pipeline / Lint and Format (push) Failing after 46s
CI/CD Pipeline / Terraform Validation (push) Failing after 26s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 29s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 41s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 22s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 24s
Validation / validate-genesis (push) Successful in 32s
Validation / validate-terraform (push) Failing after 28s
Validation / validate-kubernetes (push) Failing after 13s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m31s
Validation / validate-documentation (push) Failing after 23s
Verify Deployment / Verify Deployment (push) Failing after 1m4s
feat(settlement): production BTC L1 rails, hot cBTC liquidity, Dfns/Cobo smoke
Add BTC L1 settle/reconcile/ledger APIs, bitcoind intake, cBTC PMM hot-LP scripts, and custody credential smoke tests (secrets stay gitignored). Enables full-prod local green health and server pull-deploy for secure.omdnl.org /btc/*.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 09:28:24 -07:00

254 lines
7.3 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,
txId: instruction.observedTxId,
depositAddress: instruction.depositAddress,
confirmations: instruction.currentConfirmations,
};
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);
}
}