@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
382 lines (316 loc) • 9.47 kB
text/typescript
/**
* UTXO Lock Manager
* Manages exclusive locks on UTXOs to prevent double-spending during RBF/CPFP operations
*/
import type { IUTXOLockManager, UTXOLock } from '../interfaces/rbf-cpfp.interface.ts';
import { UTXOLockError } from '../interfaces/rbf-cpfp.interface.ts';
/**
* UTXO Lock Manager Implementation
*/
export class UTXOLockManager implements IUTXOLockManager {
private locks = new Map<string, UTXOLock>(); // outpoint -> lock
private lockIdToOutpoint = new Map<string, string>(); // lockId -> outpoint
private defaultLockDuration = 30 * 60 * 1000; // 30 minutes in milliseconds
/**
* Lock UTXO for exclusive use
*/
lockUTXO(
outpoint: string,
purpose: 'rbf' | 'cpfp' | 'pending',
durationMs?: number,
): Promise<string> {
// Check if already locked
const existingLock = this.locks.get(outpoint);
if (existingLock && existingLock.expiresAt > Date.now()) {
throw new UTXOLockError(
`UTXO ${outpoint} is already locked (purpose: ${existingLock.purpose}, expires: ${
new Date(existingLock.expiresAt).toISOString()
})`,
'ALREADY_LOCKED',
outpoint,
);
}
// Generate unique lock ID
const lockId = this.generateLockId();
const duration = durationMs || this.defaultLockDuration;
const expiresAt = Date.now() + duration;
const lock: UTXOLock = {
outpoint,
expiresAt,
purpose,
lockId,
};
this.locks.set(outpoint, lock);
this.lockIdToOutpoint.set(lockId, outpoint);
return Promise.resolve(lockId);
}
/**
* Unlock UTXO
*/
unlockUTXO(lockId: string): Promise<boolean> {
const outpoint = this.lockIdToOutpoint.get(lockId);
if (!outpoint) {
return Promise.resolve(false); // Lock ID not found
}
const lock = this.locks.get(outpoint);
if (!lock || lock.lockId !== lockId) {
return Promise.resolve(false); // Lock mismatch
}
this.locks.delete(outpoint);
this.lockIdToOutpoint.delete(lockId);
return Promise.resolve(true);
}
/**
* Check if UTXO is locked
*/
isLocked(outpoint: string): Promise<boolean> {
const lock = this.locks.get(outpoint);
if (!lock) {
return Promise.resolve(false);
}
// Check if lock has expired
if (lock.expiresAt <= Date.now()) {
// Clean up expired lock
this.locks.delete(outpoint);
this.lockIdToOutpoint.delete(lock.lockId);
return Promise.resolve(false);
}
return Promise.resolve(true);
}
/**
* Get locked UTXOs
*/
getLockedUTXOs(): Promise<UTXOLock[]> {
// Clean up expired locks first
this.clearExpiredLocks();
return Promise.resolve(Array.from(this.locks.values()));
}
/**
* Clear expired locks
*/
clearExpiredLocks(): Promise<number> {
const now = Date.now();
let clearedCount = 0;
const expiredOutpoints: string[] = [];
for (const [outpoint, lock] of this.locks.entries()) {
if (lock.expiresAt <= now) {
expiredOutpoints.push(outpoint);
}
}
for (const outpoint of expiredOutpoints) {
const lock = this.locks.get(outpoint);
if (lock) {
this.locks.delete(outpoint);
this.lockIdToOutpoint.delete(lock.lockId);
clearedCount++;
}
}
return Promise.resolve(clearedCount);
}
/**
* Lock multiple UTXOs atomically
*/
async lockMultiple(
outpoints: string[],
purpose: 'rbf' | 'cpfp' | 'pending',
durationMs?: number,
): Promise<string[]> {
// First, check if all UTXOs can be locked
const conflictingLocks: string[] = [];
for (const outpoint of outpoints) {
if (await this.isLocked(outpoint)) {
conflictingLocks.push(outpoint);
}
}
if (conflictingLocks.length > 0) {
throw new UTXOLockError(
`Cannot lock UTXOs - the following are already locked: ${conflictingLocks.join(', ')}`,
'MULTIPLE_CONFLICTS',
);
}
// Lock all UTXOs
const lockIds: string[] = [];
const lockedOutpoints: string[] = [];
try {
for (const outpoint of outpoints) {
const lockId = await this.lockUTXO(outpoint, purpose, durationMs);
lockIds.push(lockId);
lockedOutpoints.push(outpoint);
}
return lockIds;
} catch (error) {
// If any lock fails, unlock all previously locked UTXOs
for (const lockId of lockIds) {
await this.unlockUTXO(lockId);
}
throw error;
}
}
/**
* Extend lock duration
*/
extendLock(lockId: string, additionalDurationMs: number): boolean {
const outpoint = this.lockIdToOutpoint.get(lockId);
if (!outpoint) {
return false;
}
const lock = this.locks.get(outpoint);
if (!lock || lock.lockId !== lockId) {
return false;
}
// Extend the expiration time
lock.expiresAt += additionalDurationMs;
return true;
}
/**
* Get lock information
*/
getLockInfo(outpoint: string): UTXOLock | null {
const lock = this.locks.get(outpoint);
if (!lock) {
return null;
}
// Check if expired
if (lock.expiresAt <= Date.now()) {
this.locks.delete(outpoint);
this.lockIdToOutpoint.delete(lock.lockId);
return null;
}
return { ...lock }; // Return copy to prevent mutation
}
/**
* Get locks by purpose
*/
getLocksByPurpose(purpose: 'rbf' | 'cpfp' | 'pending'): Promise<UTXOLock[]> {
this.clearExpiredLocks();
return Promise.resolve(
Array.from(this.locks.values()).filter((lock) => lock.purpose === purpose),
);
}
/**
* Force unlock UTXO (admin function)
*/
forceUnlock(outpoint: string): boolean {
const lock = this.locks.get(outpoint);
if (!lock) {
return false;
}
this.locks.delete(outpoint);
this.lockIdToOutpoint.delete(lock.lockId);
return true;
}
/**
* Check lock health and cleanup
*/
async performMaintenance(): Promise<{
totalLocks: number;
expiredLocks: number;
activeLocks: number;
locksByPurpose: Record<string, number>;
}> {
const totalLocks = this.locks.size;
const expiredLocks = await this.clearExpiredLocks();
const activeLocks = this.locks.size;
// Count locks by purpose
const locksByPurpose: Record<string, number> = {};
for (const lock of this.locks.values()) {
locksByPurpose[lock.purpose] = (locksByPurpose[lock.purpose] || 0) + 1;
}
return Promise.resolve({
totalLocks,
expiredLocks,
activeLocks,
locksByPurpose,
});
}
/**
* Get lock statistics
*/
async getStatistics(): Promise<{
totalLocks: number;
locksByPurpose: Record<string, number>;
averageRemainingTime: number;
upcomingExpirations: Array<
{ outpoint: string; expiresAt: number; purpose: string }
>;
}> {
await this.clearExpiredLocks();
const totalLocks = this.locks.size;
const locksByPurpose: Record<string, number> = {};
const now = Date.now();
let totalRemainingTime = 0;
const upcomingExpirations: Array<
{ outpoint: string; expiresAt: number; purpose: string }
> = [];
for (const lock of this.locks.values()) {
locksByPurpose[lock.purpose] = (locksByPurpose[lock.purpose] || 0) + 1;
const remainingTime = lock.expiresAt - now;
totalRemainingTime += remainingTime;
// Collect locks expiring in the next 5 minutes
if (remainingTime < 5 * 60 * 1000) {
upcomingExpirations.push({
outpoint: lock.outpoint,
expiresAt: lock.expiresAt,
purpose: lock.purpose,
});
}
}
const averageRemainingTime = totalLocks > 0 ? totalRemainingTime / totalLocks : 0;
// Sort upcoming expirations by expiration time
upcomingExpirations.sort((a, b) => a.expiresAt - b.expiresAt);
return Promise.resolve({
totalLocks,
locksByPurpose,
averageRemainingTime,
upcomingExpirations,
});
}
// Private helper methods
private generateLockId(): string {
// Generate a unique lock ID
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `lock_${timestamp}_${random}`;
}
/**
* Check if UTXOs are available for locking (not locked by others)
*/
async checkAvailability(outpoints: string[]): Promise<{
available: string[];
locked: Array<{ outpoint: string; lock: UTXOLock }>;
}> {
const available: string[] = [];
const locked: Array<{ outpoint: string; lock: UTXOLock }> = [];
for (const outpoint of outpoints) {
const isCurrentlyLocked = await this.isLocked(outpoint);
if (isCurrentlyLocked) {
const lockInfo = this.getLockInfo(outpoint);
if (lockInfo) {
locked.push({ outpoint, lock: lockInfo });
}
} else {
available.push(outpoint);
}
}
return Promise.resolve({ available, locked });
}
/**
* Batch unlock multiple locks
*/
async unlockMultiple(lockIds: string[]): Promise<{
successful: string[];
failed: string[];
}> {
const successful: string[] = [];
const failed: string[] = [];
for (const lockId of lockIds) {
const success = await this.unlockUTXO(lockId);
if (success) {
successful.push(lockId);
} else {
failed.push(lockId);
}
}
return Promise.resolve({ successful, failed });
}
}