@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
462 lines (407 loc) • 14.3 kB
text/typescript
/**
* Multi-Asset Protection Detector
*
* Orchestrates multiple protection detectors for different asset types with
* intelligent aggregation strategies. This detector combines results from
* multiple IProtectionDetector implementations to provide comprehensive
* asset protection detection across ordinals, stamps, SRC-20 tokens, and more.
*/
import type { UTXO } from '../interfaces/index.ts';
import type {
IProtectionDetector,
ProtectedAssetData,
ProtectionDetectorConfig,
} from '../interfaces/protection.interface.ts';
/**
* Aggregation strategies for multi-detector operation
*/
export type AggregationStrategy = 'any-positive' | 'all-positive' | 'majority';
/**
* Configuration options for MultiAssetProtectionDetector
*/
export interface MultiAssetProtectionDetectorOptions {
/** Aggregation strategy to use (default: 'any-positive') */
strategy?: AggregationStrategy;
/** Global timeout for all operations in milliseconds (default: 10000) */
timeout?: number;
/** Maximum number of detectors to query concurrently (default: 3) */
maxConcurrency?: number;
/** Whether to enable detailed logging (default: false) */
enableLogging?: boolean;
/** Whether to continue on detector failures (default: true) */
continueOnFailure?: boolean;
/** Global detector configuration override */
detectorConfig?: Partial<ProtectionDetectorConfig>;
}
/**
* Result from a single detector operation
*/
interface DetectorResult {
detector: string;
isProtected: boolean;
assetData: ProtectedAssetData | null;
error: Error | null;
duration: number;
}
/**
* Multi-Asset Protection Detector implementation
*
* Orchestrates multiple protection detectors with intelligent aggregation strategies:
* - 'any-positive': Return true if ANY detector finds protection
* - 'all-positive': Return true only if ALL detectors find protection
* - 'majority': Return true if majority of detectors find protection
*
* Features:
* - Parallel execution for optimal performance
* - Graceful error handling and recovery
* - Detailed asset data aggregation
* - Configurable concurrency limits
* - Comprehensive logging and debugging
* - Fail-safe operation (never throws on detector errors)
*/
export class MultiAssetProtectionDetector implements IProtectionDetector {
private readonly detectors: IProtectionDetector[];
private readonly strategy: AggregationStrategy;
private readonly timeout: number;
private readonly maxConcurrency: number;
private readonly enableLogging: boolean;
private readonly continueOnFailure: boolean;
private readonly detectorConfig: Partial<ProtectionDetectorConfig>;
constructor(
detectors: IProtectionDetector[] = [],
options: MultiAssetProtectionDetectorOptions = {},
) {
this.detectors = [...detectors]; // Create defensive copy
this.strategy = options.strategy || 'any-positive';
this.timeout = options.timeout || 10000;
this.maxConcurrency = Math.max(1, options.maxConcurrency || 3);
this.enableLogging = options.enableLogging || false;
this.continueOnFailure = options.continueOnFailure !== false; // Default true
this.detectorConfig = options.detectorConfig || {};
if (this.detectors.length === 0) {
console.warn(
'MultiAssetProtectionDetector: No detectors configured, will always return false',
);
}
if (this.enableLogging) {
console.debug(
`MultiAssetProtectionDetector: Initialized with ${this.detectors.length} detectors using '${this.strategy}' strategy`,
);
}
}
/**
* Check if a UTXO contains protected assets using multiple detectors
*/
async isProtectedUtxo(utxo: UTXO): Promise<boolean> {
if (this.detectors.length === 0) {
return false;
}
try {
const startTime = Date.now();
const results = await this.executeDetectorOperations(utxo, 'isProtectedUtxo');
const decision = this.aggregateProtectionResults(results);
const duration = Date.now() - startTime;
if (this.enableLogging) {
console.debug(
`MultiAssetProtectionDetector: Protection check for ${utxo.txid}:${utxo.vout} completed in ${duration}ms`,
{
strategy: this.strategy,
results: results.map((r) => ({
detector: r.detector,
isProtected: r.isProtected,
error: r.error?.message,
})),
decision,
},
);
}
return decision;
} catch (error) {
console.warn(
`MultiAssetProtectionDetector: Protection check failed for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false; // Fail-safe: return false on any unhandled error
}
}
/**
* Get detailed asset data for a protected UTXO from all detectors
*/
async getAssetData(utxo: UTXO): Promise<ProtectedAssetData | null> {
if (this.detectors.length === 0) {
return null;
}
try {
const startTime = Date.now();
const results = await this.executeDetectorOperations(utxo, 'getAssetData');
const aggregatedData = this.aggregateAssetData(results);
const duration = Date.now() - startTime;
if (this.enableLogging) {
console.debug(
`MultiAssetProtectionDetector: Asset data retrieval for ${utxo.txid}:${utxo.vout} completed in ${duration}ms`,
{
detectorCount: this.detectors.length,
successfulDetectors: results.filter((r) =>
r.assetData !== null && r.error === null
).length,
aggregatedData: aggregatedData
? { type: aggregatedData.type, identifier: aggregatedData.identifier }
: null,
},
);
}
return aggregatedData;
} catch (error) {
console.warn(
`MultiAssetProtectionDetector: Asset data retrieval failed for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null; // Fail-safe: return null on any unhandled error
}
}
/**
* Execute detector operations in parallel with concurrency limits
*/
private async executeDetectorOperations(
utxo: UTXO,
operation: 'isProtectedUtxo' | 'getAssetData',
): Promise<DetectorResult[]> {
const results: DetectorResult[] = [];
// Create batches to respect maxConcurrency
const batches: IProtectionDetector[][] = [];
for (let i = 0; i < this.detectors.length; i += this.maxConcurrency) {
batches.push(this.detectors.slice(i, i + this.maxConcurrency));
}
// Process batches sequentially, but detectors within each batch in parallel
for (const batch of batches) {
const batchPromises = batch.map(async (detector, index) => {
const detectorName = detector.constructor.name || `Detector${results.length + index}`;
const startTime = Date.now();
try {
const timeoutPromise = this.createTimeoutPromise(this.timeout);
let operationPromise: Promise<boolean | ProtectedAssetData | null>;
if (operation === 'isProtectedUtxo') {
operationPromise = detector.isProtectedUtxo(utxo);
} else {
operationPromise = detector.getAssetData(utxo);
}
const result = await Promise.race([
operationPromise,
timeoutPromise,
]);
const duration = Date.now() - startTime;
// Handle timeout
if (result === null && operation === 'isProtectedUtxo') {
throw new Error(`Detector ${detectorName} timed out after ${this.timeout}ms`);
}
if (operation === 'isProtectedUtxo') {
return {
detector: detectorName,
isProtected: result as boolean,
assetData: null,
error: null,
duration,
};
} else {
return {
detector: detectorName,
isProtected: result !== null,
assetData: result as ProtectedAssetData | null,
error: null,
duration,
};
}
} catch (error) {
const duration = Date.now() - startTime;
const detectorError = error instanceof Error ? error : new Error(String(error));
if (this.enableLogging) {
console.warn(
`MultiAssetProtectionDetector: ${detectorName} failed for ${utxo.txid}:${utxo.vout}: ${detectorError.message}`,
);
}
if (!this.continueOnFailure) {
throw detectorError;
}
return {
detector: detectorName,
isProtected: false,
assetData: null,
error: detectorError,
duration,
};
}
});
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
}
return results;
}
/**
* Aggregate protection results based on the configured strategy
*/
private aggregateProtectionResults(results: DetectorResult[]): boolean {
const successfulResults = results.filter((r) => r.error === null);
if (successfulResults.length === 0) {
if (this.enableLogging) {
console.warn('MultiAssetProtectionDetector: All detectors failed, returning false');
}
return false;
}
const protectedCount = successfulResults.filter((r) => r.isProtected).length;
const totalCount = successfulResults.length;
switch (this.strategy) {
case 'any-positive':
return protectedCount > 0;
case 'all-positive':
return protectedCount === totalCount && totalCount > 0;
case 'majority':
return protectedCount > Math.floor(totalCount / 2);
default:
console.warn(
`MultiAssetProtectionDetector: Unknown strategy '${this.strategy}', falling back to 'any-positive'`,
);
return protectedCount > 0;
}
}
/**
* Aggregate asset data from multiple detectors
*/
private aggregateAssetData(results: DetectorResult[]): ProtectedAssetData | null {
const validResults = results.filter((r) => r.error === null && r.assetData !== null);
if (validResults.length === 0) {
return null;
}
// Prefer results with higher estimated values, then by asset type priority
const assetTypePriority = {
'ordinal': 5,
'inscription': 4,
'stamp': 3,
'src20': 2,
'counterparty': 1,
'unknown': 0,
};
const sortedResults = validResults
.sort((a, b) => {
const aData = a.assetData!;
const bData = b.assetData!;
// Sort by value first (descending)
const valueA = aData.value || 0;
const valueB = bData.value || 0;
if (valueA !== valueB) {
return valueB - valueA;
}
// Then by asset type priority (descending)
const priorityA = assetTypePriority[aData.type] || 0;
const priorityB = assetTypePriority[bData.type] || 0;
return priorityB - priorityA;
});
const primaryEntry = sortedResults[0];
if (!primaryEntry || !primaryEntry.assetData) {
return null;
}
const primaryResult = primaryEntry.assetData;
// Merge properties from all results
const mergedProperties: Record<string, any> = {};
const mergedMetadata: any = {};
for (const result of validResults) {
const data = result.assetData!;
if (data.properties) {
Object.assign(mergedProperties, data.properties);
}
if (data.metadata) {
Object.assign(mergedMetadata, data.metadata);
}
}
return {
type: primaryResult.type,
metadata: Object.keys(mergedMetadata).length > 0 ? mergedMetadata : primaryResult.metadata,
value: primaryResult.value,
identifier: primaryResult.identifier,
properties: {
...primaryResult.properties,
...mergedProperties,
detectors: validResults.map((r) => r.detector), // Track which detectors found this asset
detectorCount: validResults.length,
},
};
}
/**
* Create a timeout promise that rejects after the specified duration
*/
private createTimeoutPromise(timeoutMs: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(
() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)),
timeoutMs,
);
});
}
/**
* Add a detector to the collection
*/
addDetector(detector: IProtectionDetector): void {
this.detectors.push(detector);
if (this.enableLogging) {
console.debug(
`MultiAssetProtectionDetector: Added detector ${detector.constructor.name}, now have ${this.detectors.length} detectors`,
);
}
}
/**
* Remove a detector from the collection
*/
removeDetector(detector: IProtectionDetector): boolean {
const index = this.detectors.indexOf(detector);
if (index > -1) {
this.detectors.splice(index, 1);
if (this.enableLogging) {
console.debug(
`MultiAssetProtectionDetector: Removed detector ${detector.constructor.name}, now have ${this.detectors.length} detectors`,
);
}
return true;
}
return false;
}
/**
* Get information about configured detectors
*/
getDetectorInfo(): Array<{ name: string; configured: boolean }> {
return this.detectors.map((detector, index) => ({
name: detector.constructor.name || `Detector${index}`,
configured: true,
}));
}
/**
* Get the current aggregation strategy
*/
getStrategy(): AggregationStrategy {
return this.strategy;
}
/**
* Get the number of configured detectors
*/
getDetectorCount(): number {
return this.detectors.length;
}
/**
* Get configuration summary
*/
getConfig(): {
strategy: AggregationStrategy;
timeout: number;
maxConcurrency: number;
detectorCount: number;
enableLogging: boolean;
} {
return {
strategy: this.strategy,
timeout: this.timeout,
maxConcurrency: this.maxConcurrency,
detectorCount: this.detectors.length,
enableLogging: this.enableLogging,
};
}
}