@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
405 lines (364 loc) • 13.7 kB
text/typescript
/**
* Ordinals Multi-Provider Detector
*
* Orchestrates multiple ordinals detector providers with intelligent fallback strategies.
* This detector ONLY handles ordinals and inscriptions via multiple APIs (Hiro, Ord, etc.)
* and has nothing to do with Counterparty assets.
*/
import type { UTXO } from '../interfaces/index.ts';
import type {
IProtectionDetector,
ProtectedAssetData,
} from '../interfaces/protection.interface.ts';
import type { InscriptionData, OrdinalsDetector } from '../interfaces/ordinals.interface.ts';
import {
HiroOrdinalsDetector,
type HiroOrdinalsDetectorOptions,
} from './hiro-ordinals-detector.ts';
import { OrdServerDetector, type OrdServerDetectorOptions } from './ord-server-detector.ts';
/**
* Detection strategies for multi-provider operation
*/
export type DetectionStrategy = 'first-success' | 'any-positive' | 'consensus';
/**
* Configuration options for OrdinalsMultiProviderDetector
*/
export interface OrdinalsMultiProviderDetectorOptions {
/** Detection strategy to use (default: 'first-success') */
strategy?: DetectionStrategy;
/** Global timeout for all operations in milliseconds (default: 10000) */
timeout?: number;
/** Maximum number of providers to query concurrently (default: 2) */
maxConcurrency?: number;
/** Provider-specific options */
providerOptions?: {
hiro?: HiroOrdinalsDetectorOptions;
ordServer?: OrdServerDetectorOptions;
};
}
/**
* Ordinals Multi-Provider Detector implementation
*
* Orchestrates multiple detector providers with intelligent fallback strategies:
* - 'first-success': Query providers in order until one returns true
* - 'any-positive': Query all providers in parallel, return true if ANY returns true
* - 'consensus': Query multiple providers, return true only if majority returns true
*
* Features:
* - Fail-safe operation (never throws, returns false on errors)
* - Configurable detection strategies
* - Provider-specific timeout and retry logic
* - Graceful degradation when providers fail
* - Comprehensive error logging
*/
export class OrdinalsMultiProviderDetector implements IProtectionDetector, OrdinalsDetector {
private readonly providers: OrdinalsDetector[];
private readonly strategy: DetectionStrategy;
private readonly timeout: number;
private readonly maxConcurrency: number;
constructor(
providers?: OrdinalsDetector[],
options: OrdinalsMultiProviderDetectorOptions = {},
) {
// Initialize providers with defaults if none provided
this.providers = providers || [
new HiroOrdinalsDetector({
timeout: 3000, // Shorter timeout for multi-provider context
...options.providerOptions?.hiro,
}),
new OrdServerDetector({
timeout: 3000,
...options.providerOptions?.ordServer,
}),
];
this.strategy = options.strategy || 'first-success';
this.timeout = options.timeout || 10000;
this.maxConcurrency = options.maxConcurrency || 2;
if (this.providers.length === 0) {
console.warn(
'OrdinalsMultiProviderDetector: No providers configured, will always return false',
);
}
}
/**
* Check if a UTXO contains protected inscriptions/ordinals using multiple providers
*/
async isProtectedUtxo(utxo: UTXO): Promise<boolean> {
if (this.providers.length === 0) {
return false;
}
try {
const startTime = Date.now();
let result: boolean;
switch (this.strategy) {
case 'first-success':
result = await this.firstSuccessStrategy(utxo);
break;
case 'any-positive':
result = await this.anyPositiveStrategy(utxo);
break;
case 'consensus':
result = await this.consensusStrategy(utxo);
break;
default:
// Fallback to first-success for unknown strategies
console.warn(
`OrdinalsMultiProviderDetector: Unknown strategy '${this.strategy}', using 'first-success'`,
);
result = await this.firstSuccessStrategy(utxo);
}
const duration = Date.now() - startTime;
console.debug(
`OrdinalsMultiProviderDetector: ${this.strategy} strategy completed in ${duration}ms for ${utxo.txid}:${utxo.vout} -> ${result}`,
);
return result;
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Strategy execution failed for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false; // Fail-safe: return false on any error
}
}
/**
* Get inscription data from the first provider that has data for this UTXO
*/
async getInscriptionData(utxo: UTXO): Promise<InscriptionData | null> {
if (this.providers.length === 0) {
return null;
}
try {
// Try providers in order until we get data
for (const provider of this.providers) {
try {
const inscriptionData = await Promise.race([
provider.getInscriptionData(utxo),
this.createTimeoutPromise<InscriptionData | null>(null),
]);
if (inscriptionData) {
console.debug(
`OrdinalsMultiProviderDetector: Got inscription data from provider for ${utxo.txid}:${utxo.vout}`,
);
return inscriptionData;
}
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Provider failed to get inscription data for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
// Continue to next provider
}
}
return null; // No provider had inscription data
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Failed to get inscription data for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null; // Fail-safe: return null on any error
}
}
/**
* Get asset data from the first provider that has data for this UTXO
* Only returns 'ordinal' or 'inscription' types - never 'stamp' or 'src20'
*/
async getAssetData(utxo: UTXO): Promise<ProtectedAssetData | null> {
if (this.providers.length === 0) {
return null;
}
try {
// Try providers in order until we get data
for (const provider of this.providers) {
try {
const assetData = await Promise.race([
provider.getAssetData(utxo),
this.createTimeoutPromise<ProtectedAssetData | null>(null),
]);
if (assetData) {
// Ensure only ordinal/inscription types are returned
if (assetData.type === 'ordinal' || assetData.type === 'inscription') {
console.debug(
`OrdinalsMultiProviderDetector: Got ${assetData.type} data from provider for ${utxo.txid}:${utxo.vout}`,
);
return assetData;
}
// Skip non-ordinal assets (stamps, src20, etc.)
console.debug(
`OrdinalsMultiProviderDetector: Skipping non-ordinal asset type '${assetData.type}' for ${utxo.txid}:${utxo.vout}`,
);
}
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Provider failed to get asset data for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
// Continue to next provider
}
}
return null; // No provider had ordinal/inscription data
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Failed to get asset data for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null; // Fail-safe: return null on any error
}
}
/**
* First-success strategy: Query providers in order until one returns true
*/
private async firstSuccessStrategy(utxo: UTXO): Promise<boolean> {
for (const provider of this.providers) {
try {
const result = await Promise.race([
provider.isProtectedUtxo(utxo),
this.createTimeoutPromise<boolean>(false),
]);
if (result === true) {
console.debug(
`OrdinalsMultiProviderDetector: Provider found protection for ${utxo.txid}:${utxo.vout}`,
);
return true;
}
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Provider failed for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
// Continue to next provider
}
}
return false; // All providers failed or returned false
}
/**
* Any-positive strategy: Query all providers in parallel, return true if ANY returns true
*/
private async anyPositiveStrategy(utxo: UTXO): Promise<boolean> {
// Create batches to respect maxConcurrency
const batches: OrdinalsDetector[][] = [];
for (let i = 0; i < this.providers.length; i += this.maxConcurrency) {
batches.push(this.providers.slice(i, i + this.maxConcurrency));
}
// Process batches sequentially, but providers within each batch in parallel
for (const batch of batches) {
try {
const promises = batch.map(async (provider) => {
try {
return await Promise.race([
provider.isProtectedUtxo(utxo),
this.createTimeoutPromise<boolean>(false),
]);
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Provider failed in any-positive strategy for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false; // Provider failed, consider as false
}
});
const results = await Promise.all(promises);
// If any provider returned true, return true immediately
if (results.some((result) => result === true)) {
console.debug(
`OrdinalsMultiProviderDetector: At least one provider found protection for ${utxo.txid}:${utxo.vout}`,
);
return true;
}
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Batch failed in any-positive strategy for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
// Continue to next batch
}
}
return false; // No provider returned true
}
/**
* Consensus strategy: Query multiple providers, return true only if majority returns true
*/
private async consensusStrategy(utxo: UTXO): Promise<boolean> {
const results: boolean[] = [];
// Create batches to respect maxConcurrency
const batches: OrdinalsDetector[][] = [];
for (let i = 0; i < this.providers.length; i += this.maxConcurrency) {
batches.push(this.providers.slice(i, i + this.maxConcurrency));
}
// Process batches sequentially, but providers within each batch in parallel
for (const batch of batches) {
try {
const promises = batch.map(async (provider) => {
try {
return await Promise.race([
provider.isProtectedUtxo(utxo),
this.createTimeoutPromise<boolean>(false),
]);
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Provider failed in consensus strategy for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false; // Provider failed, consider as false
}
});
const batchResults = await Promise.all(promises);
results.push(...batchResults);
} catch (error) {
console.warn(
`OrdinalsMultiProviderDetector: Batch failed in consensus strategy for ${utxo.txid}:${utxo.vout}: ${
error instanceof Error ? error.message : String(error)
}`,
);
// Continue to next batch, but mark these providers as failed
results.push(...batch.map(() => false));
}
}
// Calculate consensus
const trueCount = results.filter((result) => result === true).length;
const totalCount = results.length;
const majorityThreshold = Math.ceil(totalCount / 2);
const hasConsensus = trueCount >= majorityThreshold;
console.debug(
`OrdinalsMultiProviderDetector: Consensus for ${utxo.txid}:${utxo.vout}: ${trueCount}/${totalCount} providers say protected -> ${hasConsensus}`,
);
return hasConsensus;
}
/**
* Create a timeout promise that resolves with the given value after the global timeout
*/
private createTimeoutPromise<T>(timeoutValue: T): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => resolve(timeoutValue), this.timeout);
});
}
/**
* Get information about the configured providers
*/
getProviderInfo(): Array<{ name: string; configured: boolean }> {
return this.providers.map((provider, index) => ({
name: provider.constructor.name || `Provider${index}`,
configured: true,
}));
}
/**
* Get the current detection strategy
*/
getStrategy(): DetectionStrategy {
return this.strategy;
}
/**
* Get the number of configured providers
*/
getProviderCount(): number {
return this.providers.length;
}
}