Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 53 additions & 27 deletions src/hooks/use-cross-chain-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
type SolanaWalletConnection,
type WalletConnections,
} from "@/lib/browser-wallets";
import { AttestationResilienceManager } from "@/utils/attestation-resilience";

export type TransferStep =
| "idle"
Expand All @@ -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;
Expand Down Expand Up @@ -564,41 +564,67 @@ 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 (
transactionHash: string,
sourceChainId: number,
): Promise<AttestationResponse> => {
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;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1127,4 +1153,4 @@ export function useCrossChainTransfer() {
getBalance,
reset,
};
}
}
57 changes: 57 additions & 0 deletions src/utils/__tests__/attestation-resilience.test.ts
Original file line number Diff line number Diff line change
@@ -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.'
);
});
});
108 changes: 108 additions & 0 deletions src/utils/attestation-resilience.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
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.`);
}
}
5 changes: 3 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"src/utils/__tests__/**/*"
]
}
}