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
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>
254 lines
7.3 KiB
TypeScript
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);
|
|
}
|
|
}
|