- 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
251 lines
7.2 KiB
TypeScript
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);
|
|
}
|
|
}
|