diff --git a/src/blockchain/scanner/base-scanner.ts b/src/blockchain/scanner/base-scanner.ts new file mode 100644 index 0000000..967b5d0 --- /dev/null +++ b/src/blockchain/scanner/base-scanner.ts @@ -0,0 +1,107 @@ +/** + * Base Scanner + * + * Abstract base class and interfaces for scanning blockchain events + * (fulfillments, proofs, withdrawals) on destination/source chains. + */ + +import { ChainType } from '@/core/interfaces/intent'; + +/** + * Types of events that can be scanned + */ +export enum ScanEventType { + /** Intent fulfilled on destination chain */ + FULFILLMENT = 'fulfillment', + /** Intent proven on source chain */ + PROVEN = 'proven', + /** Reward withdrawn on source chain */ + WITHDRAWAL = 'withdrawal', +} + +/** + * Result of a scan operation + */ +export interface ScanResult { + /** Whether the event was found */ + found: boolean; + /** Type of event scanned for */ + eventType: ScanEventType; + /** Claimant address - the solver/filler who fulfilled the intent and can claim the reward */ + claimant?: string; + /** Transaction hash where the event was found */ + transactionHash?: string; + /** Error message if scan failed */ + error?: string; + /** Whether the scan timed out */ + timedOut?: boolean; + /** Time elapsed from start of scanning to event detection (in milliseconds) */ + elapsedMs?: number; +} + +/** + * Configuration for the scanner + */ +export interface ScannerConfig { + /** The intent hash to watch for */ + intentHash: string; + /** Portal contract address */ + portalAddress: string; + /** RPC URL for the chain */ + rpcUrl: string; + /** Chain ID */ + chainId: bigint; + /** Chain type (EVM, SVM, etc.) */ + chainType: ChainType; + /** Chain name for display purposes */ + chainName: string; + /** Timeout in milliseconds (default: 5 minutes) */ + timeoutMs?: number; + /** Poll interval in milliseconds (default: 5 seconds) */ + pollIntervalMs?: number; +} + +/** Default timeout: 5 minutes */ +export const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +/** Default poll interval: 5 seconds */ +export const DEFAULT_POLL_INTERVAL_MS = 5 * 1000; + +/** + * Abstract base class for blockchain event scanners. + * + * Implementations should handle chain-specific event listening/polling + * to detect when specific events occur (fulfillment, proof, withdrawal). + */ +export abstract class BaseScanner { + protected config: ScannerConfig; + protected stopped = false; + + constructor(config: ScannerConfig) { + this.config = { + ...config, + timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + pollIntervalMs: config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + }; + } + + /** + * Scan for a specific event type. + * Returns when the event is detected or timeout is reached. + */ + abstract scan(eventType: ScanEventType): Promise; + + /** + * Stop the scanner (for cleanup on user interrupt) + */ + stop(): void { + this.stopped = true; + } + + /** + * Get the scanner configuration + */ + getConfig(): ScannerConfig { + return this.config; + } +} diff --git a/src/blockchain/scanner/evm-scanner.ts b/src/blockchain/scanner/evm-scanner.ts new file mode 100644 index 0000000..aecbf92 --- /dev/null +++ b/src/blockchain/scanner/evm-scanner.ts @@ -0,0 +1,300 @@ +/** + * EVM Scanner + * + * Scans for portal events (IntentFulfilled, IntentProven, IntentWithdrawn) + * on EVM chains using viem's getLogs with polling. + */ + +import { Address, Chain, createPublicClient, getAbiItem, Hex, http, PublicClient } from 'viem'; +import * as chains from 'viem/chains'; + +import { portalAbi } from '@/commons/abis/portal.abi'; +import { logger } from '@/utils/logger'; + +import { BaseScanner, ScanEventType, ScannerConfig, ScanResult } from './base-scanner'; + +// Extract events from portal ABI +const intentFulfilledEvent = getAbiItem({ + abi: portalAbi, + name: 'IntentFulfilled', +}); + +const intentProvenEvent = getAbiItem({ + abi: portalAbi, + name: 'IntentProven', +}); + +const intentWithdrawnEvent = getAbiItem({ + abi: portalAbi, + name: 'IntentWithdrawn', +}); + +/** + * EVM implementation of the scanner. + * Uses viem to poll for portal events on EVM chains. + */ +export class EvmScanner extends BaseScanner { + private client: PublicClient; + private startBlock: bigint | null = null; + private scanStartTime: number = 0; + private currentEventType: ScanEventType = ScanEventType.FULFILLMENT; + + constructor(config: ScannerConfig) { + super(config); + const chain = this.getViemChain(config.chainId); + this.client = createPublicClient({ + chain, + transport: http(config.rpcUrl), + }); + } + + /** + * Scan for events until found or timeout + */ + async scan(eventType: ScanEventType): Promise { + this.currentEventType = eventType; + this.scanStartTime = Date.now(); + this.stopped = false; + const { timeoutMs, pollIntervalMs, chainName } = this.config; + + // Get current block as starting point + this.startBlock = await this.client.getBlockNumber(); + logger.info(`Starting ${eventType} scan from block ${this.startBlock}`); + + // First check if event already occurred + const existingEvent = await this.checkForEvent(eventType); + if (existingEvent.found) { + return existingEvent; + } + + const eventLabel = this.getEventLabel(eventType); + logger.spinner(`Watching for ${eventLabel} on ${chainName}...`); + + while (!this.stopped) { + const elapsed = Date.now() - this.scanStartTime; + + // Check timeout + if (elapsed >= timeoutMs!) { + logger.warn( + `${eventLabel} not detected within ${Math.round(timeoutMs! / 1000 / 60)} minutes` + ); + return { + found: false, + eventType, + timedOut: true, + elapsedMs: elapsed, + error: `Timeout after ${Math.round(timeoutMs! / 1000 / 60)} minutes`, + }; + } + + // Update spinner with elapsed time + const elapsedSec = Math.round(elapsed / 1000); + const remainingSec = Math.round((timeoutMs! - elapsed) / 1000); + logger.updateSpinner( + `Watching for ${eventLabel} on ${chainName}... (${elapsedSec}s elapsed, ${remainingSec}s remaining)` + ); + + // Wait for poll interval + await this.sleep(pollIntervalMs!); + + // Check for event + const result = await this.checkForEvent(eventType); + if (result.found) { + logger.succeed(`${eventLabel} detected on ${chainName}!`); + return result; + } + } + + // Scanner was stopped manually + logger.stopSpinner(); + return { + found: false, + eventType, + error: 'Scanner stopped', + }; + } + + /** + * Check for the specified event type in recent blocks + */ + private async checkForEvent(eventType: ScanEventType): Promise { + switch (eventType) { + case ScanEventType.FULFILLMENT: + return this.checkForFulfillment(); + case ScanEventType.PROVEN: + return this.checkForProven(); + case ScanEventType.WITHDRAWAL: + return this.checkForWithdrawal(); + default: + return { + found: false, + eventType, + error: `Unsupported event type: ${eventType}`, + }; + } + } + + /** + * Check for IntentFulfilled events + */ + private async checkForFulfillment(): Promise { + const eventType = ScanEventType.FULFILLMENT; + try { + const currentBlock = await this.client.getBlockNumber(); + const { intentHash, portalAddress } = this.config; + + const logs = await this.client.getLogs({ + address: portalAddress as Address, + event: intentFulfilledEvent, + args: { + intentHash: intentHash as Hex, + }, + fromBlock: this.startBlock ?? currentBlock - 1000n, + toBlock: currentBlock, + }); + + if (logs.length > 0) { + const log = logs[0]; + const elapsedMs = Date.now() - this.scanStartTime; + return { + found: true, + eventType, + claimant: log.args.claimant as string, + transactionHash: log.transactionHash, + elapsedMs, + }; + } + + return { found: false, eventType }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.warn(`Error checking for fulfillment: ${errorMessage}`); + return { found: false, eventType }; + } + } + + /** + * Check for IntentProven events + */ + private async checkForProven(): Promise { + const eventType = ScanEventType.PROVEN; + try { + const currentBlock = await this.client.getBlockNumber(); + const { intentHash, portalAddress } = this.config; + + const logs = await this.client.getLogs({ + address: portalAddress as Address, + event: intentProvenEvent, + args: { + intentHash: intentHash as Hex, + }, + fromBlock: this.startBlock ?? currentBlock - 1000n, + toBlock: currentBlock, + }); + + if (logs.length > 0) { + const log = logs[0]; + const elapsedMs = Date.now() - this.scanStartTime; + return { + found: true, + eventType, + claimant: log.args.claimant as string, + transactionHash: log.transactionHash, + elapsedMs, + }; + } + + return { found: false, eventType }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.warn(`Error checking for proof: ${errorMessage}`); + return { found: false, eventType }; + } + } + + /** + * Check for IntentWithdrawn events + * Note: intentHash is not indexed in this event, so we filter in memory + */ + private async checkForWithdrawal(): Promise { + const eventType = ScanEventType.WITHDRAWAL; + try { + const currentBlock = await this.client.getBlockNumber(); + const { intentHash, portalAddress } = this.config; + + const logs = await this.client.getLogs({ + address: portalAddress as Address, + event: intentWithdrawnEvent, + fromBlock: this.startBlock ?? currentBlock - 1000n, + toBlock: currentBlock, + }); + + // Filter by intentHash in memory since it's not indexed + const matchingLog = logs.find( + log => log.args.intentHash?.toLowerCase() === intentHash.toLowerCase() + ); + + if (matchingLog) { + const elapsedMs = Date.now() - this.scanStartTime; + return { + found: true, + eventType, + claimant: matchingLog.args.claimant as string, + transactionHash: matchingLog.transactionHash, + elapsedMs, + }; + } + + return { found: false, eventType }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.warn(`Error checking for withdrawal: ${errorMessage}`); + return { found: false, eventType }; + } + } + + /** + * Get human-readable label for event type + */ + private getEventLabel(eventType: ScanEventType): string { + switch (eventType) { + case ScanEventType.FULFILLMENT: + return 'fulfillment'; + case ScanEventType.PROVEN: + return 'proof'; + case ScanEventType.WITHDRAWAL: + return 'withdrawal'; + default: + return 'event'; + } + } + + /** + * Get viem chain configuration by chain ID + */ + private getViemChain(chainId: bigint): Chain { + const id = Number(chainId); + const viemChain = Object.values(chains).find((chain: Chain) => chain.id === id); + + if (!viemChain) { + // Return a minimal chain config for unsupported chains + return { + id, + name: `Chain ${id}`, + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: { + default: { http: [this.config.rpcUrl] }, + }, + } as Chain; + } + + return viemChain; + } + + /** + * Sleep for specified milliseconds + */ + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/src/blockchain/scanner/index.ts b/src/blockchain/scanner/index.ts new file mode 100644 index 0000000..0052684 --- /dev/null +++ b/src/blockchain/scanner/index.ts @@ -0,0 +1,39 @@ +/** + * Scanner Module + * + * Factory and exports for creating chain-specific event scanners. + * Supports scanning for fulfillments, proofs, and withdrawals. + */ + +import { ChainType } from '@/core/interfaces/intent'; + +import { BaseScanner, ScannerConfig } from './base-scanner'; +import { EvmScanner } from './evm-scanner'; + +export * from './base-scanner'; + +/** + * Creates a scanner appropriate for the given chain type. + */ +export function createScanner(config: ScannerConfig): BaseScanner { + switch (config.chainType) { + case ChainType.EVM: + return new EvmScanner(config); + + case ChainType.SVM: + throw new Error(`SVM scanning not yet implemented. Intent: ${config.intentHash}`); + + case ChainType.TVM: + throw new Error(`TVM scanning not yet implemented. Intent: ${config.intentHash}`); + + default: + throw new Error(`Unsupported chain type: ${config.chainType}`); + } +} + +/** + * Check if scanning is supported for a chain type + */ +export function isScanningSupported(chainType: ChainType): boolean { + return chainType === ChainType.EVM; +} diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 91b49db..5c20758 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -20,6 +20,7 @@ import { privateKeyToAccount } from 'viem/accounts'; import { BasePublisher } from '@/blockchain/base-publisher'; import { EvmPublisher } from '@/blockchain/evm-publisher'; +import { createScanner, isScanningSupported, ScanEventType } from '@/blockchain/scanner'; import { SvmPublisher } from '@/blockchain/svm-publisher'; import { TvmPublisher } from '@/blockchain/tvm-publisher'; import { serialize } from '@/commons/utils/serialize'; @@ -40,6 +41,7 @@ interface PublishCommandOptions { privateKey?: string; rpc?: string; dryRun?: boolean; + noWatch?: boolean; } export function createPublishCommand(): Command { @@ -53,12 +55,13 @@ export function createPublishCommand(): Command { .option('-r, --rpc ', 'RPC URL (overrides env)') .option('--recipient
', 'Recipient address on destination chain') .option('--dry-run', 'Validate without publishing') + .option('--no-watch', 'Skip watching for fulfillment on destination chain') .action(async options => { try { // Interactive mode logger.title('🎨 Interactive Intent Publishing'); - const { reward, encodedRoute, sourceChain, destChain, sourcePortal } = + const { reward, encodedRoute, sourceChain, destChain, sourcePortal, destinationPortal } = await buildIntentInteractively(options); if (process.env.DEBUG) { @@ -111,6 +114,19 @@ export function createPublishCommand(): Command { if (result.success) { logger.displayTransactionResult(result); + + // Watch for fulfillment on destination chain (unless --no-watch) + if (!options.noWatch && result.intentHash && destinationPortal) { + await watchForFulfillment({ + intentHash: result.intentHash, + destinationPortal, + destChain, + }); + } else if (!options.noWatch && !destinationPortal) { + logger.warning( + 'Cannot watch for fulfillment: destination portal address not available from quote' + ); + } } else { logger.fail('Publishing failed'); throw new Error(result.error || 'Publishing failed'); @@ -523,9 +539,70 @@ async function buildIntentInteractively(options: PublishCommandOptions) { sourceChain, destChain, sourcePortal, + destinationPortal: quote?.contracts?.destinationPortal, }; } +/** + * Watch for fulfillment on the destination chain + */ +async function watchForFulfillment(params: { + intentHash: string; + destinationPortal: string; + destChain: ChainConfig; +}): Promise { + const { intentHash, destinationPortal, destChain } = params; + + // Check if scanning is supported for this chain type + if (!isScanningSupported(destChain.type)) { + logger.warning( + `Fulfillment scanning not yet supported for ${destChain.type} chains. ` + + `Intent hash: ${intentHash}` + ); + return; + } + + logger.section('🔍 Watching for Fulfillment'); + + try { + const scanner = createScanner({ + intentHash, + portalAddress: destinationPortal, + rpcUrl: destChain.rpcUrl, + chainId: destChain.id, + chainType: destChain.type, + chainName: destChain.name, + }); + + const result = await scanner.scan(ScanEventType.FULFILLMENT); + + if (result.found) { + const elapsedDisplay = result.elapsedMs + ? `${(result.elapsedMs / 1000).toFixed(1)}s` + : 'Unknown'; + logger.displayKeyValue( + { + 'Intent Hash': intentHash, + Claimant: result.claimant || 'Unknown', + 'Fulfillment Tx': result.transactionHash || 'Unknown', + 'Time to Fulfill': elapsedDisplay, + }, + '✅ Intent Fulfilled!' + ); + } else if (result.timedOut) { + logger.warning( + 'Fulfillment not detected within timeout. The intent may still be fulfilled later.' + ); + logger.info(`You can check the intent status using: eco-routes status ${intentHash}`); + } else if (result.error) { + logger.warning(`Fulfillment scanning stopped: ${result.error}`); + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.warning(`Failed to watch for fulfillment: ${errorMessage}`); + } +} + /** * Select a token for a specific chain */