From be7eb9c6dbed556c74c4535f9b17eaa2aac3d893 Mon Sep 17 00:00:00 2001 From: forumevi Date: Fri, 11 Sep 2026 12:05:46 +0300 Subject: [PATCH] feat(attestation): integrate AttestationResilienceManager into cross-chain transfer flow with live telemetry --- src/hooks/use-cross-chain-transfer.ts | 80 ++++++++----- .../__tests__/attestation-resilience.test.ts | 57 +++++++++ src/utils/attestation-resilience.ts | 108 ++++++++++++++++++ tsconfig.json | 5 +- 4 files changed, 221 insertions(+), 29 deletions(-) create mode 100644 src/utils/__tests__/attestation-resilience.test.ts create mode 100644 src/utils/attestation-resilience.ts diff --git a/src/hooks/use-cross-chain-transfer.ts b/src/hooks/use-cross-chain-transfer.ts index 4bf420f..34811eb 100644 --- a/src/hooks/use-cross-chain-transfer.ts +++ b/src/hooks/use-cross-chain-transfer.ts @@ -55,6 +55,7 @@ import { type SolanaWalletConnection, type WalletConnections, } from "@/lib/browser-wallets"; +import { AttestationResilienceManager } from "@/utils/attestation-resilience"; export type TransferStep = | "idle" @@ -78,7 +79,6 @@ interface FastTransferFeeResponse { const DEFAULT_DECIMALS = 6; const FAST_FINALITY_THRESHOLD = 1000; const STANDARD_FINALITY_THRESHOLD = 2000; -const ATTESTATION_POLL_INTERVAL_MS = 5000; const MINT_MAX_RETRIES = 3; const MINT_RETRY_BASE_DELAY_MS = 2000; const GAS_BUFFER_PERCENT = 120n; @@ -564,7 +564,7 @@ export function useCrossChainTransfer() { }; // --------------------------------------------------------------------------- - // Step 3: Attest — Poll Circle's IRIS API until attestation is complete + // Step 3: Attest — Resilient IRIS API polling with Exponential Backoff & Telemetry // --------------------------------------------------------------------------- const retrieveAttestation = async ( @@ -572,33 +572,59 @@ export function useCrossChainTransfer() { sourceChainId: number, ): Promise => { setCurrentStep("waiting-attestation"); - addLog("Retrieving attestation..."); + addLog("Initializing resilient attestation fetch..."); - const url = `${IRIS_API_URL}/v2/messages/${CHAIN_CONFIGS[sourceChainId as SupportedChainId].destinationDomain}?transactionHash=${transactionHash}`; + const url = `${IRIS_API_URL}/v2/messages/${ + CHAIN_CONFIGS[sourceChainId as SupportedChainId].destinationDomain + }?transactionHash=${transactionHash}`; - while (true) { - const response = await fetch(url); - if (response.status === 404) { - await new Promise((resolve) => - setTimeout(resolve, ATTESTATION_POLL_INTERVAL_MS), - ); - continue; - } - if (!response.ok) { - throw new Error( - `Attestation request failed with status ${response.status}`, - ); - } - const data = await response.json(); - if (data?.messages?.[0]?.status === "complete") { - addLog("Attestation retrieved"); - return data.messages[0] as AttestationResponse; - } - addLog("Waiting for attestation..."); - await new Promise((resolve) => - setTimeout(resolve, ATTESTATION_POLL_INTERVAL_MS), + const resilienceManager = new AttestationResilienceManager({ + maxAttempts: 30, + initialDelayMs: 3000, + maxDelayMs: 25000, + backoffFactor: 1.4, + }); + + const { attestation: rawMessage, telemetry } = + await resilienceManager.executeResilientFetch( + async () => { + const response = await fetch(url); + if (response.status === 404) { + return { status: "pending" }; + } + if (!response.ok) { + throw new Error(`IRIS API HTTP Error: ${response.status}`); + } + const data = await response.json(); + const msg = data?.messages?.[0]; + + if (msg?.status === "complete") { + return { + status: "complete", + attestation: JSON.stringify(msg), + }; + } + return { status: "pending" }; + }, + (attempt, currentMetrics) => { + const elapsedSec = ( + (Date.now() - currentMetrics.startTime) / + 1000 + ).toFixed(1); + addLog( + `Waiting for attestation... Attempt #${attempt} [Elapsed: ${elapsedSec}s]`, + ); + }, ); - } + + const parsedAttestation = JSON.parse(rawMessage) as AttestationResponse; + addLog( + `Attestation retrieved in ${ + ((telemetry.durationMs || 0) / 1000).toFixed(2) + }s (${telemetry.attempts} attempts)`, + ); + + return parsedAttestation; }; // --------------------------------------------------------------------------- @@ -1127,4 +1153,4 @@ export function useCrossChainTransfer() { getBalance, reset, }; -} +} \ No newline at end of file diff --git a/src/utils/__tests__/attestation-resilience.test.ts b/src/utils/__tests__/attestation-resilience.test.ts new file mode 100644 index 0000000..8a5549c --- /dev/null +++ b/src/utils/__tests__/attestation-resilience.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { AttestationResilienceManager } from '../attestation-resilience'; + +import { AttestationResilienceManager } from '../attestation-resilience'; + +describe('AttestationResilienceManager', () => { + let manager: AttestationResilienceManager; + + beforeEach(() => { + manager = new AttestationResilienceManager({ + maxAttempts: 3, + initialDelayMs: 10, + maxDelayMs: 50, + backoffFactor: 2, + }); + }); + + it('should calculate jitter delay within expected bounds', () => { + const delay = manager.calculateJitterDelay(1); + expect(delay).toBeGreaterThanOrEqual(0); + expect(delay).toBeLessThanOrEqual(50); + }); + + it('should successfully retrieve attestation when status is complete', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + status: 'complete', + attestation: '0x123456789abcdef', + }); + + const result = await manager.executeResilientFetch(mockFetch); + + expect(result.attestation).toEqual('0x123456789abcdef'); + expect(result.telemetry.status).toEqual('COMPLETE'); + expect(result.telemetry.attempts).toEqual(1); + }); + + it('should retry until status becomes complete', async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'complete', attestation: '0x87654321' }); + + const result = await manager.executeResilientFetch(mockFetch); + + expect(result.attestation).toEqual('0x87654321'); + expect(result.telemetry.attempts).toEqual(2); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should throw an error when max attempts are exceeded', async () => { + const mockFetch = jest.fn().mockResolvedValue({ status: 'pending' }); + + await expect(manager.executeResilientFetch(mockFetch)).rejects.toThrow( + 'Attestation polling timed out after 3 attempts.' + ); + }); +}); diff --git a/src/utils/attestation-resilience.ts b/src/utils/attestation-resilience.ts new file mode 100644 index 0000000..fdecc31 --- /dev/null +++ b/src/utils/attestation-resilience.ts @@ -0,0 +1,108 @@ +/** + * AttestationResilienceManager + * + * Production-grade resilience wrapper for Circle IRIS Attestation API. + * Implements Exponential Backoff with Jitter, retry thresholds, and telemetry tracking. + */ + +export interface TelemetryMetrics { + startTime: number; + endTime?: number; + durationMs?: number; + attempts: number; + status: 'PENDING' | 'COMPLETE' | 'FAILED'; +} + +export interface ResilienceConfig { + maxAttempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; + backoffFactor?: number; +} + +export class AttestationResilienceManager { + private maxAttempts: number; + private initialDelayMs: number; + private maxDelayMs: number; + private backoffFactor: number; + + constructor(config: ResilienceConfig = {}) { + this.maxAttempts = config.maxAttempts ?? 15; + this.initialDelayMs = config.initialDelayMs ?? 2000; + this.maxDelayMs = config.maxDelayMs ?? 30000; + this.backoffFactor = config.backoffFactor ?? 1.5; + } + + /** + * Calculates backoff delay using Full Jitter algorithm to prevent Thundering Herd problem. + */ + public calculateJitterDelay(attempt: number): number { + const exponentialDelay = Math.min( + this.maxDelayMs, + this.initialDelayMs * Math.pow(this.backoffFactor, attempt) + ); + // Full Jitter: random value between 0 and calculated exponential delay + return Math.floor(Math.random() * exponentialDelay); + } + + /** + * Wraps attestation fetch with resilient retry logic & metrics collection. + */ + public async executeResilientFetch( + fetchFn: () => Promise<{ status: string; attestation?: string; message?: string }>, + onProgress?: (attempt: number, metrics: TelemetryMetrics) => void + ): Promise<{ attestation: string; telemetry: TelemetryMetrics }> { + const metrics: TelemetryMetrics = { + startTime: Date.now(), + attempts: 0, + status: 'PENDING', + }; + + let attempt = 0; + + while (attempt < this.maxAttempts) { + attempt++; + metrics.attempts = attempt; + + try { + const result = await fetchFn(); + + if (result.status === 'complete' && result.attestation) { + metrics.endTime = Date.now(); + metrics.durationMs = metrics.endTime - metrics.startTime; + metrics.status = 'COMPLETE'; + + if (onProgress) onProgress(attempt, metrics); + + return { + attestation: result.attestation, + telemetry: metrics, + }; + } + + if (onProgress) onProgress(attempt, metrics); + } catch (error) { + // Log network error / rate limit but don't break immediately until max attempts + if (attempt >= this.maxAttempts) { + metrics.endTime = Date.now(); + metrics.durationMs = metrics.endTime - metrics.startTime; + metrics.status = 'FAILED'; + throw new Error( + `Attestation polling failed after ${this.maxAttempts} attempts: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } + } + + const delay = this.calculateJitterDelay(attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + metrics.endTime = Date.now(); + metrics.durationMs = metrics.endTime - metrics.startTime; + metrics.status = 'FAILED'; + + throw new Error(`Attestation polling timed out after ${this.maxAttempts} attempts.`); + } +} diff --git a/tsconfig.json b/tsconfig.json index e1b116d..fae4ee3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "src/utils/__tests__/**/*" ] -} +} \ No newline at end of file