From 4d448a4a21e37a34e6792c5ac3e14990e11998c8 Mon Sep 17 00:00:00 2001 From: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:56:46 +0200 Subject: [PATCH] test: wait for event utils (#121) ## Summary This PR introduces comprehensive event waiting utilities for both DataHaven (Substrate) and Ethereum chains, providing a unified interface for handling blockchain events in E2E tests. ## What's New - **Event Utilities** (`test/utils/events.ts`): New utilities for waiting on blockchain events - `waitForDataHavenEvent`: Type-safe event waiting for Substrate chain events - `waitForEthereumEvent`: Event waiting for Ethereum contract events - Graceful timeout handling (returns null instead of throwing) - Support for event filtering, callbacks, and custom timeouts - **Documentation** (`test/docs/event-utilities-guide.md`): Comprehensive guide covering usage examples for both DataHaven and Ethereum. ## Test Plan - [ ] New event utilities work as expected - [ ] Event filtering works correctly for both chains - [ ] Timeout handling behaves as documented - [ ] Parallel event waiting with `Promise.all()` works --------- Co-authored-by: Claude --- test/docs/event-utilities-guide.md | 298 +++++++++++++++++++++++++++++ test/utils/events.ts | 191 ++++++++++++++++++ test/utils/index.ts | 1 + 3 files changed, 490 insertions(+) create mode 100644 test/docs/event-utilities-guide.md create mode 100644 test/utils/events.ts diff --git a/test/docs/event-utilities-guide.md b/test/docs/event-utilities-guide.md new file mode 100644 index 00000000..2939c0ab --- /dev/null +++ b/test/docs/event-utilities-guide.md @@ -0,0 +1,298 @@ +# Event Utilities Usage Guide + +This guide demonstrates how to use event utilities for waiting and handling blockchain events in DataHaven (Substrate) and Ethereum chains. + +## Overview + +The event utilities provide a unified, type-safe interface for handling blockchain events with: + +- **Consistent API**: Similar patterns for both DataHaven and Ethereum +- **Composable design**: Use `Promise.all()` for parallel event waiting +- **Graceful timeouts**: Functions return `null` on timeout (no errors thrown) +- **Type safety**: Full TypeScript support with proper event typing + +## Quick Start + +### DataHaven Events +```typescript +import { waitForDataHavenEvent } from '@test/e2e-suite/utils/datahaven'; + +const result = await waitForDataHavenEvent({ + api: dhApi, + pallet: "Balances", + event: "Transfer", + timeout: 10000 +}); + +if (result.data) { + console.log(`Transfer: ${result.data.amount}`); +} +``` + +### Ethereum Events +```typescript +import { waitForEthereumEvent } from '@test/e2e-suite/utils/ethereum'; + +const result = await waitForEthereumEvent({ + client: publicClient, + address: tokenAddress, + abi: erc20Abi, + eventName: "Transfer", + timeout: 10000 +}); + +if (result.log) { + console.log(`Transfer: ${result.log.args.value}`); +} +``` + +## DataHaven Event Handling + +### Transaction Submission (Direct Events) + +When you submit your own transaction, you can get immediate access to all events: + +```typescript +const result = await dhApi.tx.Balances + .transfer({ dest: recipient, value: amount }) + .signAndSubmit(signer); + +// result type: TxFinalized +if (result.ok) { + // Access all events from the transaction + const transfer = result.events.find( + e => e.pallet === "Balances" && e.name === "Transfer" + ); + + if (transfer) { + console.log(`Transferred: ${transfer.value.amount}`); + } +} else { + console.error(`Failed:`, result.dispatchError); +} +``` + +**Use this approach when:** +- ✅ You're submitting the transaction yourself +- ✅ You need events from that specific transaction +- ✅ You want synchronous access to results + +### Waiting for External Events + +Use `waitForDataHavenEvent` when monitoring for events from other sources: + +```typescript +const result = await waitForDataHavenEvent({ + api: dhApi, + pallet: "Balances", + event: "Transfer", + filter: (e) => e.to === myAddress, + timeout: 10000 +}); + +if (result.data) { + console.log(`Received transfer: ${result.data.amount}`); +} +``` + +**Use this approach when:** +- ✅ Waiting for events from other transactions +- ✅ Monitoring cross-chain events +- ✅ Watching for external activity +- ✅ Implementing time-based conditions + +#### With Filtering +```typescript +const result = await waitForDataHavenEvent({ + api: dhApi, + pallet: "Balances", + event: "Transfer", + timeout: 10000, + // Only match transfers from specific sender with amount > 1000 + filter: (event) => event.from === senderAddress && event.amount > 1000n +}); +``` + +#### With Callbacks +```typescript +const result = await waitForDataHavenEvent({ + api: dhApi, + pallet: "Staking", + event: "Rewarded", + timeout: 30000, + // Real-time processing as events are found + onEvent: (event) => { + console.log(`✅ Reward received: ${event.amount}`); + updateRewardsDisplay(event.amount); + // Callback doesn't affect the return value + } +}); +``` + +### Multiple Events + +Wait for multiple events in parallel: + +```typescript +const [transfer, reward, slash] = await Promise.all([ + waitForDataHavenEvent({ + api: dhApi, + pallet: "Balances", + event: "Transfer", + timeout: 10000, + filter: (e) => e.to === myAddress + }), + waitForDataHavenEvent({ + api: dhApi, + pallet: "Staking", + event: "Rewarded", + timeout: 5000 // Shorter timeout for optional event + }), + waitForDataHavenEvent({ + api: dhApi, + pallet: "Staking", + event: "Slashed", + timeout: 5000 + }) +]); + +// Handle results - some may be null (timeout) +if (!transfer.data) { + throw new Error("Expected transfer not received"); +} + +if (reward.data) { + console.log(`Received reward: ${reward.data.amount}`); +} else { + console.log("No rewards in this period (normal)"); +} + +if (slash.data) { + console.warn(`Got slashed: ${slash.data.amount}`); +} +``` + +## Ethereum Event Handling + +### Basic Usage + +```typescript +const result = await waitForEthereumEvent({ + client: publicClient, + address: contractAddress, + abi: contractAbi, + eventName: "StateChanged", + timeout: 30000 +}); + +if (result.log) { + console.log(`New state: ${result.log.args.newState}`); + console.log(`Block: ${result.log.blockNumber}`); + console.log(`Tx: ${result.log.transactionHash}`); +} +``` + +### With Argument Filtering + +Filter events by their arguments: + +```typescript +const result = await waitForEthereumEvent({ + client: publicClient, + address: tokenAddress, + abi: erc20Abi, + eventName: "Transfer", + // Only match specific transfers + args: { + from: myAddress, // Must be FROM myAddress + to: recipientAddress // Must be TO recipientAddress + // Omit 'value' to match any amount + }, + timeout: 30000 +}); +``` + +### With Callbacks + +Process events in real-time: + +```typescript +const result = await waitForEthereumEvent({ + client: publicClient, + address: dexAddress, + abi: dexAbi, + eventName: "Swap", + args: { + tokenIn: wethAddress, + tokenOut: usdcAddress + }, + onEvent: (log) => { + const { amountIn, amountOut } = log.args; + const rate = Number(amountOut) / Number(amountIn); + + console.log(`Swap at rate: ${rate}`); + console.log(`Block: ${log.blockNumber}`); + + // Update UI, send notifications, etc. + updatePriceDisplay(rate); + }, + timeout: 60000 +}); +``` + +## Error Handling + +### Timeout Handling + +Events that timeout return `null`, not an error: + +```typescript +const result = await waitForDataHavenEvent({ + api: dhApi, + pallet: "Staking", + event: "Rewarded", + timeout: 5000 +}); + +if (!result.data) { + // Timeout - decide how to handle + console.log("No rewards within 5 seconds"); + + // Option 1: Continue (if event is optional) + // Option 2: Retry with longer timeout + // Option 3: Fail the test + throw new Error("Expected rewards not received"); +} + +// Safe to use result.data here +console.log(`Rewards: ${result.data.amount}`); +``` + +### Error vs Timeout + +```typescript +try { + const result = await waitForEthereumEvent({ + client: publicClient, + address: tokenAddress, + abi: erc20Abi, + eventName: "Transfer", + timeout: 10000 + }); + + if (!result.log) { + // Timeout - not an error + console.log("No transfer within 10 seconds"); + // Handle based on your needs + } else { + // Event found + processTransfer(result.log); + } +} catch (error) { + // Only actual errors reach here: + // - Network issues + // - Invalid parameters + // - Contract/API errors + console.error("Unexpected error:", error); +} +``` \ No newline at end of file diff --git a/test/utils/events.ts b/test/utils/events.ts new file mode 100644 index 00000000..600f8d10 --- /dev/null +++ b/test/utils/events.ts @@ -0,0 +1,191 @@ +import { firstValueFrom, of } from "rxjs"; +import { catchError, take, tap, timeout } from "rxjs/operators"; +import type { Abi, Address, Log, PublicClient } from "viem"; +import { logger } from "./logger"; +import type { DataHavenApi } from "./papi"; + +/** + * Event utilities for DataHaven and Ethereum chains + * + * This module provides utilities for waiting for events on different chains: + * - DataHaven events (substrate-based chain events) + * - Ethereum events (using viem event filters) + */ + +/** + * Result from waiting for a DataHaven event + */ +export interface DataHavenEventResult { + /** Pallet name */ + pallet: string; + /** Event name */ + event: string; + /** Event data payload (null if timeout or error) */ + data: T | null; +} + +/** + * Options for waiting for a single DataHaven event + */ +export interface WaitForDataHavenEventOptions { + /** DataHaven API instance */ + api: DataHavenApi; + /** Pallet name (e.g., "System", "Balances") */ + pallet: string; + /** Event name (e.g., "ExtrinsicSuccess", "Transfer") */ + event: string; + /** Optional filter function to match specific events */ + filter?: (event: T) => boolean; + /** Timeout in milliseconds (default: 30000) */ + timeout?: number; + /** Callback for matched event */ + onEvent?: (event: T) => void; +} + +/** + * Wait for a specific event on the DataHaven chain + * @param options - Options for event waiting + * @returns Event result with pallet, event name, and data + */ +export async function waitForDataHavenEvent( + options: WaitForDataHavenEventOptions +): Promise> { + const { api, pallet, event, filter, timeout: timeoutMs = 30000, onEvent } = options; + + const eventWatcher = (api.event as any)?.[pallet]?.[event]; + if (!eventWatcher?.watch) { + logger.warn(`Event ${pallet}.${event} not found`); + return { pallet, event, data: null }; + } + + let data: T | null; + try { + data = await firstValueFrom( + eventWatcher.watch(filter).pipe( + tap((eventData: T) => { + logger.debug(`Event ${pallet}.${event} received`); + onEvent?.(eventData); + }), + take(1), // Always stop on first event + timeout({ + first: timeoutMs, + with: () => { + logger.debug(`Timeout waiting for event ${pallet}.${event} after ${timeoutMs}ms`); + return of(null); + } + }), + catchError((error: unknown) => { + logger.error(`Error in event subscription ${pallet}.${event}: ${error}`); + return of(null); + }) + ) + ); + } catch { + data = null; + } + + return { pallet, event, data }; +} + +// ================== Ethereum Event Utilities ================== + +/** + * Result from waiting for an Ethereum event + */ +export interface EthereumEventResult { + /** Contract address */ + address: Address; + /** Event name */ + eventName: string; + /** Event log (null if timeout or error) */ + log: Log | null; +} + +/** + * Options for waiting for a single Ethereum event + */ +export interface WaitForEthereumEventOptions { + /** Viem public client instance */ + client: PublicClient; + /** Contract address */ + address: Address; + /** Contract ABI */ + abi: TAbi; + /** Event name to watch for */ + eventName: any; + /** Optional event arguments to filter */ + args?: any; + /** Timeout in milliseconds (default: 30000) */ + timeout?: number; + /** Include events from past blocks */ + fromBlock?: bigint; + /** Callback for each matched event */ + onEvent?: (log: Log) => void; +} + +/** + * Wait for a specific event on the Ethereum chain + * @param options - Options for event waiting + * @returns Event result with address, event name, and log + */ +export async function waitForEthereumEvent( + options: WaitForEthereumEventOptions +): Promise { + const { client, address, abi, eventName, args, timeout = 30000, fromBlock, onEvent } = options; + + const log = await new Promise((resolve) => { + let unwatch: (() => void) | null = null; + let timeoutId: NodeJS.Timeout | null = null; + let matchedLog: Log | null = null; + + const cleanup = () => { + if (unwatch) { + unwatch(); + } + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + + // Set up timeout + timeoutId = setTimeout(() => { + logger.debug(`Timeout waiting for Ethereum event ${eventName} after ${timeout}ms`); + cleanup(); + resolve(matchedLog); + }, timeout); + + // Watch for events + try { + unwatch = client.watchContractEvent({ + address, + abi, + eventName, + args, + fromBlock, + onLogs: (logs) => { + logger.debug(`Ethereum event ${eventName} received: ${logs.length} logs`); + + if (logs.length > 0) { + matchedLog = logs[0]; + if (onEvent) { + onEvent(matchedLog); + } + cleanup(); + resolve(matchedLog); + } + }, + onError: (error: unknown) => { + logger.error(`Error watching Ethereum event ${eventName}: ${error}`); + cleanup(); + resolve(null); + } + }); + } catch (error) { + logger.error(`Failed to watch Ethereum event ${eventName}: ${error}`); + cleanup(); + resolve(null); + } + }); + + return { address, eventName, log }; +} diff --git a/test/utils/index.ts b/test/utils/index.ts index 02ac4ccf..6251563d 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -2,6 +2,7 @@ export * from "./blockscout"; export * from "./constants"; export * from "./contracts"; export * from "./docker"; +export * from "./events"; export * from "./input"; export * from "./kurtosis"; export * from "./logger";