From c064a8cb88c2b80fd87fef15010f8402059c18fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Mon, 8 Jun 2026 17:05:56 -0400 Subject: [PATCH 1/8] feat(sdk): adaptive rate-limit-aware fetch wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move fetch rate-limiting out of utils into a dedicated fetch.ts and make it adaptive: runs at full speed by default, throttling only when an endpoint actually rate-limits it. - per-endpoint AIMD concurrency cap (default 5; halves to 1 on a 429, ramps back on sustained success) — collapses a 429 storm into one patient retry - per-(endpoint,method) rate pacer: paces only when the server gives an explicit reset window (Retry-After/RateLimit-Reset); header-less 429s (e.g. Solana) use jittered exponential backoff instead - standard + de-facto rate-limit header parsing (Retry-After, RateLimit-*, X-RateLimit-*, Solana x-ratelimit-method-*) - web fetch-contract semantics (returns non-ok responses, throws only on network/abort); per-endpoint state shared across instances Co-Authored-By: Claude Opus 4.8 (1M context) --- ccip-sdk/src/api/index.ts | 3 +- ccip-sdk/src/fetch.test.ts | 743 +++++++++++++++++++++++++++++++++++++ ccip-sdk/src/fetch.ts | 742 ++++++++++++++++++++++++++++++++++++ ccip-sdk/src/offchain.ts | 3 +- ccip-sdk/src/utils.test.ts | 125 +------ ccip-sdk/src/utils.ts | 242 +----------- 6 files changed, 1492 insertions(+), 366 deletions(-) create mode 100644 ccip-sdk/src/fetch.test.ts create mode 100644 ccip-sdk/src/fetch.ts diff --git a/ccip-sdk/src/api/index.ts b/ccip-sdk/src/api/index.ts index bfc0da98..5e89444b 100644 --- a/ccip-sdk/src/api/index.ts +++ b/ccip-sdk/src/api/index.ts @@ -10,6 +10,7 @@ import { CCIPMessageNotVerifiedYetError, CCIPUnexpectedPaginationError, } from '../errors/index.ts' +import { fetchWithTimeout } from '../fetch.ts' import { HttpStatus } from '../http-status.ts' import { decodeMessageV1 } from '../messages.ts' import { decodeMessage } from '../requests.ts' @@ -26,7 +27,7 @@ import { CCIPVersion, MessageStatus, } from '../types.ts' -import { bigIntReviver, decodeAddress, fetchWithTimeout, parseJson } from '../utils.ts' +import { bigIntReviver, decodeAddress, parseJson } from '../utils.ts' import type { APIErrorResponse, LaneLatencyResponse, diff --git a/ccip-sdk/src/fetch.test.ts b/ccip-sdk/src/fetch.test.ts new file mode 100644 index 00000000..1a258ecf --- /dev/null +++ b/ccip-sdk/src/fetch.test.ts @@ -0,0 +1,743 @@ +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import { + createAxiosFetchAdapter, + createRateLimitedFetch, + endpointKey, + fetchProfileForUrl, + getEndpointLogRange, + parseLogRangeError, + parseRateLimitHeaders, + parseRetryAfter, + setEndpointLogRange, +} from './fetch.ts' + +// --------------------------------------------------------------------------- +// parseRetryAfter +// --------------------------------------------------------------------------- + +describe('parseRetryAfter', () => { + it('returns null for null input', () => { + assert.equal(parseRetryAfter(null), null) + }) + + it('handles delta-seconds integer', () => { + const before = Date.now() + const result = parseRetryAfter('30') + const after = Date.now() + assert.ok(result !== null) + assert.ok(result >= before + 30_000) + assert.ok(result <= after + 30_000) + }) + + it('handles delta-seconds zero', () => { + const before = Date.now() + const result = parseRetryAfter('0') + const after = Date.now() + assert.ok(result !== null) + assert.ok(result >= before) + assert.ok(result <= after) + }) + + it('handles HTTP-date format', () => { + // A future date + const future = new Date(Date.now() + 60_000) + const result = parseRetryAfter(future.toUTCString()) + assert.ok(result !== null) + // Within 1s tolerance + assert.ok(Math.abs(result - future.getTime()) < 1000) + }) + + it('returns null for garbage input', () => { + assert.equal(parseRetryAfter('not-a-date'), null) + }) +}) + +// --------------------------------------------------------------------------- +// parseRateLimitHeaders +// --------------------------------------------------------------------------- + +function makeHeaders(pairs: Record): Headers { + const h = new Headers() + for (const [k, v] of Object.entries(pairs)) h.set(k, v) + return h +} + +describe('parseRateLimitHeaders', () => { + it('returns empty object for no headers', () => { + const result = parseRateLimitHeaders(new Headers()) + assert.deepEqual(result, {}) + }) + + it('parses Retry-After delta-seconds', () => { + const before = Date.now() + const result = parseRateLimitHeaders(makeHeaders({ 'Retry-After': '10' })) + assert.ok(result.retryAfterAt !== undefined) + assert.ok(result.retryAfterAt >= before + 10_000) + }) + + it('parses IETF draft individual headers (reset = delta-seconds)', () => { + const before = Date.now() + const result = parseRateLimitHeaders( + makeHeaders({ + 'RateLimit-Limit': '100', + 'RateLimit-Remaining': '50', + 'RateLimit-Reset': '60', + }), + ) + assert.equal(result.limit, 100) + assert.equal(result.remaining, 50) + assert.ok(result.resetAt !== undefined) + assert.ok(result.resetAt >= before + 60_000) + assert.ok(result.resetAt <= Date.now() + 60_001) + }) + + it('parses combined RateLimit header', () => { + const before = Date.now() + const result = parseRateLimitHeaders( + makeHeaders({ RateLimit: 'limit=100, remaining=20, reset=30' }), + ) + assert.equal(result.limit, 100) + assert.equal(result.remaining, 20) + assert.ok(result.resetAt !== undefined) + assert.ok(result.resetAt >= before + 30_000) + }) + + it('parses X-RateLimit-* de-facto headers (reset as delta-seconds)', () => { + const before = Date.now() + const result = parseRateLimitHeaders( + makeHeaders({ + 'X-RateLimit-Limit': '200', + 'X-RateLimit-Remaining': '100', + 'X-RateLimit-Reset': '45', + }), + ) + assert.equal(result.limit, 200) + assert.equal(result.remaining, 100) + assert.ok(result.resetAt !== undefined) + assert.ok(result.resetAt >= before + 45_000) + }) + + it('parses X-RateLimit-Reset as epoch-seconds when > 1e9', () => { + // Use a Unix epoch value well in the future + const epochSeconds = Math.floor(Date.now() / 1000) + 100 + const result = parseRateLimitHeaders(makeHeaders({ 'X-RateLimit-Reset': String(epochSeconds) })) + assert.ok(result.resetAt !== undefined) + // Should be within 1s of the epoch value + assert.ok(Math.abs(result.resetAt - epochSeconds * 1000) < 1000) + }) + + it('X-RateLimit overrides IETF headers when both present', () => { + const result = parseRateLimitHeaders( + makeHeaders({ + 'RateLimit-Limit': '100', + 'RateLimit-Remaining': '50', + 'X-RateLimit-Limit': '200', + 'X-RateLimit-Remaining': '80', + }), + ) + assert.equal(result.limit, 200) + assert.equal(result.remaining, 80) + }) +}) + +// --------------------------------------------------------------------------- +// endpointKey +// --------------------------------------------------------------------------- + +describe('endpointKey', () => { + it('strips query params from string URL', () => { + const key = endpointKey('https://api.example.com/v1/rpc?key=secret&foo=bar') + assert.equal(key, 'https://api.example.com/v1/rpc') + }) + + it('strips hash from string URL', () => { + const key = endpointKey('https://api.example.com/v1/rpc#fragment') + assert.equal(key, 'https://api.example.com/v1/rpc') + }) + + it('preserves path', () => { + const key = endpointKey('https://api.example.com/v1/rpc') + assert.equal(key, 'https://api.example.com/v1/rpc') + }) + + it('handles URL object', () => { + const key = endpointKey(new URL('https://api.example.com/rpc?foo=bar')) + assert.equal(key, 'https://api.example.com/rpc') + }) + + it('handles Request object', () => { + const key = endpointKey(new Request('https://api.example.com/rpc?foo=bar')) + assert.equal(key, 'https://api.example.com/rpc') + }) + + it('two URLs with different queries share the same key', () => { + const k1 = endpointKey('https://api.example.com/rpc?a=1') + const k2 = endpointKey('https://api.example.com/rpc?b=2') + assert.equal(k1, k2) + }) +}) + +// --------------------------------------------------------------------------- +// getEndpointLogRange / setEndpointLogRange +// --------------------------------------------------------------------------- + +describe('getEndpointLogRange / setEndpointLogRange', () => { + it('returns undefined when not set', () => { + assert.equal(getEndpointLogRange('https://unregistered.example.com/rpc'), undefined) + }) + + it('round-trips an error-learned range', () => { + const url = 'https://alchemy.example.com/v2/test' + setEndpointLogRange(url, 10_000, 'error') + assert.equal(getEndpointLogRange(url), 10_000) + }) + + it('round-trips a success-learned range', () => { + const url = 'https://infura.example.com/v3/test' + setEndpointLogRange(url, 5_000, 'success') + assert.equal(getEndpointLogRange(url), 5_000) + }) + + it('query params are stripped (same key)', () => { + const base = 'https://quicknode.example.com/rpc' + setEndpointLogRange(base + '?key=abc', 2_000, 'error') + assert.equal(getEndpointLogRange(base + '?key=xyz'), 2_000) + }) +}) + +// --------------------------------------------------------------------------- +// fetchProfileForUrl +// --------------------------------------------------------------------------- + +describe('fetchProfileForUrl', () => { + it('leaves public Solana with no proactive seed (header-driven adaptation)', () => { + const profile = fetchProfileForUrl('https://api.mainnet-beta.solana.com') + assert.equal(profile.seed, undefined) + }) + + it('seeds TON public (toncenter.com) paced, still adaptive', () => { + const profile = fetchProfileForUrl('https://toncenter.com/api/v2/jsonRPC') + assert.deepEqual(profile.seed, { limit: 1, windowMs: 1500 }) + }) + + it('seeds TON public (tonapi.io) paced', () => { + const profile = fetchProfileForUrl('https://tonapi.io/v2/something') + assert.deepEqual(profile.seed, { limit: 1, windowMs: 1500 }) + }) + + it('no seed for unknown hosts (start at full speed)', () => { + const profile = fetchProfileForUrl('https://api.example.com/rpc') + assert.equal(profile.seed, undefined) + }) + + it('no seed on invalid URL', () => { + const profile = fetchProfileForUrl('not-a-url') + assert.equal(profile.seed, undefined) + }) +}) + +// --------------------------------------------------------------------------- +// parseLogRangeError +// --------------------------------------------------------------------------- + +describe('parseLogRangeError', () => { + it('returns null for null/undefined', () => { + assert.equal(parseLogRangeError(null), null) + assert.equal(parseLogRangeError(undefined), null) + }) + + it('returns null for unrelated error', () => { + assert.equal(parseLogRangeError(new Error('something went wrong')), null) + }) + + it('parses Alchemy "up to a 10000 block range"', () => { + const err = new Error( + 'Log response size exceeded. You can make eth_getLogs requests with up to a 10000 block range and no greater than 10000 logs in the response.', + ) + const result = parseLogRangeError(err) + assert.ok(result !== null) + assert.equal(result.maxRange, 10000) + }) + + it('parses Alchemy suggested range [0x..., 0x...]', () => { + const err = new Error('Try with this block range [0x12AB1C, 0x12B9FC].') + const result = parseLogRangeError(err) + assert.ok(result !== null) + assert.ok(result.suggestedRange !== undefined) + assert.equal(result.suggestedRange[0], 0x12ab1c) + assert.equal(result.suggestedRange[1], 0x12b9fc) + }) + + it('parses Infura "query returned more than 10000 results"', () => { + const err = new Error('query returned more than 10000 results') + const result = parseLogRangeError(err) + assert.ok(result !== null) + assert.equal(result.maxRange, 10000) + }) + + it('parses QuickNode "eth_getLogs is limited to a 10000 range"', () => { + const err = new Error('eth_getLogs is limited to a 10000 range') + const result = parseLogRangeError(err) + assert.ok(result !== null) + assert.equal(result.maxRange, 10000) + }) + + it('parses QuickNode "exceeds the range"', () => { + const err = new Error('Your request exceeds the range limit allowed by the provider') + const result = parseLogRangeError(err) + assert.ok(result !== null) + assert.equal(result.maxRange, undefined) // no number captured + }) + + it('parses generic "block range is too wide"', () => { + const err = new Error('block range is too wide') + const result = parseLogRangeError(err) + assert.ok(result !== null) + }) + + it('parses generic "range too large"', () => { + const err = new Error('The range too large for this RPC') + const result = parseLogRangeError(err) + assert.ok(result !== null) + }) + + it('handles JSON-RPC error code -32005', () => { + const rpcErr = { code: -32005, message: 'block range is too large' } + const result = parseLogRangeError(rpcErr) + assert.ok(result !== null) + }) + + it('handles nested error.error.message', () => { + const nested = { error: { message: 'query returned more than 10000 results', code: -32000 } } + const result = parseLogRangeError(nested) + assert.ok(result !== null) + assert.equal(result.maxRange, 10000) + }) + + it('returns {} (non-null) for range error with no number', () => { + const err = new Error('block range is too wide') + const result = parseLogRangeError(err) + assert.ok(result !== null) + assert.equal(result.maxRange, undefined) + assert.equal(result.suggestedRange, undefined) + }) + + it('extracts the limit from any number in a block-range message (Astar/erpc -32603)', () => { + const err = { error: { code: -32603, message: 'block range is too wide (maximum 1024)' } } + assert.deepEqual(parseLogRangeError(err), { maxRange: 1024 }) + }) + + it('extracts the limit from a deeply-nested erpc range error (-32012)', () => { + const err = { + error: { + code: -32012, + message: + 'getLogs request exceeded max allowed range: block range is too wide (maximum 1024)', + data: { code: 'ErrUpstreamsExhausted', details: { durationMs: 96, method: 'eth_getLogs' } }, + }, + } + assert.deepEqual(parseLogRangeError(err), { maxRange: 1024 }) + }) +}) + +// --------------------------------------------------------------------------- +// createRateLimitedFetch (migrated from utils.test.ts) +// --------------------------------------------------------------------------- + +describe('createRateLimitedFetch', () => { + let originalFetch: typeof fetch + + let mockedFetch: any + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + mockedFetch = undefined + }) + + it('should create a rate-limited fetch function', () => { + const rateLimitedFetch = createRateLimitedFetch({}) + assert.equal(typeof rateLimitedFetch, 'function') + }) + + it('should allow requests within rate limit', async () => { + globalThis.fetch = mockedFetch = mock.fn(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + } as Response), + ) + + const rateLimitedFetch = createRateLimitedFetch({}) + + const promise1 = rateLimitedFetch('https://rl-test-allow.example.com') + const promise2 = rateLimitedFetch('https://rl-test-allow.example.com') + + await Promise.all([promise1, promise2]) + + assert.equal(mockedFetch.mock.calls.length, 2) + }) + + it('should retry on 429 rate limit errors', async () => { + let callCount = 0 + globalThis.fetch = mockedFetch = mock.fn(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: new Headers(), + } as Response) + } + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + } as Response) + }) + + const rateLimitedFetch = createRateLimitedFetch({}) + + const result = await rateLimitedFetch('https://rl-test-retry.example.com') + assert.equal(result.ok, true) + assert.equal(mockedFetch.mock.calls.length, 2) + }) + + it('should return non-retryable responses (e.g. 404) without throwing', async () => { + globalThis.fetch = mockedFetch = mock.fn(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + } as Response), + ) + + const rateLimitedFetch = createRateLimitedFetch({}) + + const result = await rateLimitedFetch('https://rl-test-404.example.com') + assert.equal(result.ok, false) + assert.equal(result.status, 404) + assert.equal(mockedFetch.mock.calls.length, 1) // no retries for non-transient + }) + + it('should respect maxRetries parameter and return transient response after exhaustion', async () => { + globalThis.fetch = mockedFetch = mock.fn(() => + Promise.resolve({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: new Headers(), + } as Response), + ) + + const rateLimitedFetch = createRateLimitedFetch({ maxRetries: 2 }) + + const result = await rateLimitedFetch('https://rl-test-retries.example.com') + assert.equal(result.ok, false) + assert.equal(result.status, 429) + assert.equal(mockedFetch.mock.calls.length, 3) // Initial + 2 retries + }) + + it('should use default parameters when none provided', () => { + const rateLimitedFetch = createRateLimitedFetch() + assert.equal(typeof rateLimitedFetch, 'function') + }) + + it('should handle network errors with retry logic', async () => { + let callCount = 0 + globalThis.fetch = mockedFetch = mock.fn(() => { + callCount++ + if (callCount === 1) { + return Promise.reject(new Error('429 rate limit exceeded')) + } + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + } as Response) + }) + + const rateLimitedFetch = createRateLimitedFetch({}) + + const result = await rateLimitedFetch('https://rl-test-network.example.com') + assert.equal(result.ok, true) + }) +}) + +// --------------------------------------------------------------------------- +// Retry on transient status +// --------------------------------------------------------------------------- + +describe('transient retry', () => { + let originalFetch: typeof fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('retries a 429 then returns the eventual success', async () => { + // Use a unique URL so endpoint state is fresh + const url = 'https://transient-retry.example.com/rpc' + let callCount = 0 + + globalThis.fetch = mock.fn(() => { + callCount++ + if (callCount <= 1) { + return Promise.resolve({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: new Headers(), + } as Response) + } + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + } as Response) + }) + + const rateLimitedFetch = createRateLimitedFetch({ maxRetries: 3 }) + // Should succeed after retry + const result = await rateLimitedFetch(url) + assert.equal(result.ok, true) + }) +}) + +// --------------------------------------------------------------------------- +// Proactive throttle (knownLimit.remaining === 0) +// --------------------------------------------------------------------------- + +describe('proactive throttle', () => { + let originalFetch: typeof fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('waits when remaining=0 before request completes', async () => { + const url = 'https://proactive-throttle.example.com/rpc' + let callCount = 0 + const requestTimes: number[] = [] + + // First call returns remaining=0 with a reset 200ms away + globalThis.fetch = mock.fn(() => { + callCount++ + requestTimes.push(Date.now()) + const headers = new Headers() + if (callCount === 1) { + // Return remaining=0, reset=0 (already reset — short wait) + headers.set('X-RateLimit-Remaining', '0') + headers.set('X-RateLimit-Reset', '0') // delta=0s, resetAt = now + return Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers } as Response) + } + return Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers } as Response) + }) + + const rateLimitedFetch = createRateLimitedFetch({}) + await rateLimitedFetch(url) + // Second call: remaining=0 but resetAt is in the past (already expired), so no hang + await rateLimitedFetch(url) + assert.equal(callCount, 2) + }) +}) + +// --------------------------------------------------------------------------- +// Adaptive limiting (full speed by default; learn + pace on contact) +// --------------------------------------------------------------------------- + +describe('adaptive limiting', () => { + let originalFetch: typeof fetch + beforeEach(() => { + originalFetch = globalThis.fetch + }) + afterEach(() => { + globalThis.fetch = originalFetch + }) + + const ok = (headers = new Headers()) => + ({ ok: true, status: 200, statusText: 'OK', headers }) as Response + const methodHeaders = (limit: number, remaining: number) => { + const h = new Headers() + h.set('x-ratelimit-method-limit', String(limit)) + h.set('x-ratelimit-method-remaining', String(remaining)) + return h + } + const rpc = (method: string, id = 0) => ({ + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id, method }), + }) + + it('runs at full speed when nothing rate-limits it (no pacing overhead)', async () => { + globalThis.fetch = mock.fn(() => Promise.resolve(ok())) + const f = createRateLimitedFetch({}) + const url = 'https://adapt-fullspeed.example.com/rpc' + const t0 = Date.now() + for (let i = 0; i < 10; i++) await f(url, rpc('getThing', i)) + assert.ok(Date.now() - t0 < 300, `expected no pacing, took ${Date.now() - t0}ms`) + }) + + it('an occasional 429 is just retried at full speed (no pacing)', async () => { + let calls = 0 + globalThis.fetch = mock.fn(() => { + calls++ + // A single, isolated 429 (no reset window) — bursts are tolerated, so we + // must NOT start pacing; just retry quickly. + if (calls === 2) { + return Promise.resolve({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: methodHeaders(1, 0), + } as Response) + } + return Promise.resolve(ok(methodHeaders(1, 1))) + }) + const f = createRateLimitedFetch({}) + const url = 'https://adapt-occasional.example.com/rpc' + const t0 = Date.now() + for (let i = 0; i < 4; i++) await f(url, rpc('getSignaturesForAddress', i)) + // One backoff (~250ms) at most, no per-second pacing. + assert.ok(Date.now() - t0 < 1000, `expected full-speed retry, took ${Date.now() - t0}ms`) + }) + + it('paces precisely when the server gives an explicit reset window', async () => { + let calls = 0 + const h = () => + makeHeaders({ 'RateLimit-Limit': '1', 'RateLimit-Remaining': '0', 'RateLimit-Reset': '1' }) + globalThis.fetch = mock.fn(() => { + calls++ + if (calls === 2) { + return Promise.resolve({ ok: false, status: 429, statusText: '', headers: h() } as Response) + } + return Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers: h() } as Response) + }) + const f = createRateLimitedFetch({}) + const url = 'https://adapt-window.example.com/rpc' + const t0 = Date.now() + for (let i = 0; i < 3; i++) await f(url, rpc('m', i)) + // Explicit 1s reset window → activate and pace ~1s. + assert.ok(Date.now() - t0 >= 800, `expected ~1s pacing, took ${Date.now() - t0}ms`) + }) + + it('does not pace burst-tolerant 429s (count header, no reset window) — retries fast', async () => { + let calls = 0 + globalThis.fetch = mock.fn(() => { + calls++ + // Every 3rd call 429s with a method-limit count but NO reset window + // (Solana-style). Such 429s must be retried, not paced. + if (calls % 3 === 0) { + return Promise.resolve({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: methodHeaders(1, 0), + } as Response) + } + return Promise.resolve(ok(methodHeaders(1, 1))) + }) + const f = createRateLimitedFetch({}) + const url = 'https://adapt-burst.example.com/rpc' + const t0 = Date.now() + for (let i = 0; i < 12; i++) await f(url, rpc('m', i)) + // Header-less 429s never activate pacing → only cheap retry backoffs. + assert.ok(Date.now() - t0 < 4000, `expected burst+retry (no pacing), took ${Date.now() - t0}ms`) + }) + + it('caps concurrent in-flight requests per endpoint, flushing as each completes', async () => { + let inFlight = 0 + let maxObserved = 0 + globalThis.fetch = mock.fn(async () => { + inFlight++ + maxObserved = Math.max(maxObserved, inFlight) + await new Promise((r) => setTimeout(r, 40)) + inFlight-- + return ok() + }) + const f = createRateLimitedFetch({ maxInFlight: 2 }) + const url = 'https://concurrency-cap.example.com/rpc' + await Promise.all(Array.from({ length: 6 }, (_, i) => f(url, rpc('m', i)))) + assert.ok(maxObserved <= 2, `max in-flight was ${maxObserved}, expected <= 2`) + assert.equal( + (globalThis.fetch as unknown as { mock: { calls: unknown[] } }).mock.calls.length, + 6, + ) + }) + + it('collapses concurrency toward 1 under sustained 429s, then completes', async () => { + let inFlight = 0 + let served = 0 + const inflightAtServe: number[] = [] + globalThis.fetch = mock.fn(async () => { + inFlight++ + const idx = ++served + inflightAtServe.push(inFlight) + await new Promise((r) => setTimeout(r, 15)) + inFlight-- + // First 12 served responses 429 (no reset window); then succeed. + if (idx <= 12) { + return { + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: methodHeaders(1, 0), + } as Response + } + return ok() + }) + const f = createRateLimitedFetch({ maxInFlight: 5, maxRetries: 12 }) + const url = 'https://aimd-sem.example.com/rpc' + const results = await Promise.all(Array.from({ length: 5 }, (_, i) => f(url, rpc('m', i)))) + // All complete successfully (the "sometimes doesn't complete" bug). + assert.equal(results.filter((r) => r.ok).length, 5) + // After the initial burst of 5, the 429s halve the cap → later sends run at + // ≤2 in flight (collapsing toward 1). + const afterBurst = inflightAtServe.slice(5) + const peak = Math.max(...afterBurst) + assert.ok(peak <= 2, `expected collapse to ≤2 in-flight after burst, saw ${peak}`) + }) +}) + +// --------------------------------------------------------------------------- +// createAxiosFetchAdapter +// --------------------------------------------------------------------------- + +describe('createAxiosFetchAdapter', () => { + it('returns a function (the adapter)', () => { + const adapter = createAxiosFetchAdapter(globalThis.fetch) + assert.equal(typeof adapter, 'function') + }) + + it('returns a function when abort is provided', () => { + const abort = new AbortController().signal + const adapter = createAxiosFetchAdapter(globalThis.fetch, abort) + assert.equal(typeof adapter, 'function') + }) + + it('without abort, same adapter reference behavior (returns function)', () => { + const adapter1 = createAxiosFetchAdapter(globalThis.fetch) + const adapter2 = createAxiosFetchAdapter(globalThis.fetch) + assert.equal(typeof adapter1, 'function') + assert.equal(typeof adapter2, 'function') + }) +}) diff --git a/ccip-sdk/src/fetch.ts b/ccip-sdk/src/fetch.ts new file mode 100644 index 00000000..52865216 --- /dev/null +++ b/ccip-sdk/src/fetch.ts @@ -0,0 +1,742 @@ +import { type AxiosAdapter, getAdapter } from 'axios' + +import { + CCIPAbortError, + CCIPError, + CCIPTimeoutError, + isTransientHttpStatus, +} from './errors/index.ts' +import type { WithLogger } from './types.ts' +import { sleep } from './utils.ts' + +/* eslint-disable jsdoc/require-jsdoc */ +/** + * Tuning for the rate-limited fetch wrapper. + * - `maxRetries`: attempts on transient (429/5xx) responses. + * - `seed`: optional proactive starting cap for hosts known to always throttle + * (e.g. TON public). When set, the default scope starts ACTIVE at this rate + * instead of full speed. It still adapts (relaxes up / tightens down). + */ +export type RateLimitOpts = { + maxRetries: number + /** Max concurrent in-flight requests per endpoint (default 5). */ + maxInFlight?: number + seed?: { limit: number; windowMs: number } +} + +/** Default (ceiling) max concurrent in-flight requests per endpoint. */ +const DEFAULT_MAX_IN_FLIGHT = 5 + +/** + * Adaptive concurrency limiter (AIMD on the in-flight cap). + * + * Starts at `ceiling` concurrent slots. A freed slot is handed straight to the + * next waiter (FIFO), so the queue drains as soon as ANY in-flight request + * finishes. On a rate-limit signal the effective cap is HALVED (down to 1) — so + * under sustained limiting only a single request is in flight, retried with + * exponential backoff until it succeeds; each success then bumps the cap back up + * by one toward the ceiling. This collapses a "5-in, 5-out 429 storm" into one + * patient retry, then re-expands once the endpoint recovers. + */ +class AdaptiveSemaphore { + private inUse = 0 + private max: number + private consecutiveOk = 0 + private readonly ceiling: number + private readonly waiters: Array<() => void> = [] + /** Clean successes required before the cap grows by 1 (kept sticky at low cap). */ + private static readonly GROW_AFTER = 3 + constructor(ceiling: number) { + this.ceiling = Math.max(1, ceiling) + this.max = this.ceiling + } + /** Current effective concurrency cap (for tests/inspection). */ + get cap(): number { + return this.max + } + async acquire(): Promise { + if (this.inUse < this.max) { + this.inUse++ + return + } + await new Promise((resolve) => this.waiters.push(resolve)) // granted via grant() + } + release(): void { + this.inUse = Math.max(0, this.inUse - 1) + this.grant() + } + private grant(): void { + while (this.inUse < this.max && this.waiters.length) { + this.inUse++ + this.waiters.shift()!() + } + } + /** Multiplicative decrease on a rate-limit signal (floored at 1). Resets the + * success streak so the cap stays collapsed while 429s keep arriving. */ + decrease(): void { + this.max = Math.max(1, Math.floor(this.max / 2)) + this.consecutiveOk = 0 + } + /** Additive increase, but only after a clean run of successes with no 429 in + * between — so under sustained limiting the cap sticks at 1 (one serial + * request retried until it succeeds) and only re-expands once recovered. */ + increase(): void { + if (this.max >= this.ceiling) return + if (++this.consecutiveOk >= AdaptiveSemaphore.GROW_AFTER) { + this.consecutiveOk = 0 + this.max++ + this.grant() + } + } +} + +/** No-header window guess; slides between these bounds as limits are/aren't hit. */ +const DEFAULT_WINDOW_MS = 1_000 +const MIN_WINDOW_MS = 250 +const MAX_WINDOW_MS = 60_000 + +function clampWindow(ms: number): number { + return Math.min(MAX_WINDOW_MS, Math.max(MIN_WINDOW_MS, Math.round(ms))) +} + +/** + * Adaptive per-(endpoint, scope) rate pacer. + * + * Stays INACTIVE — full speed, zero pacing — until pacing is warranted: either a + * seed (known always-throttled host) or a 429 that carries an explicit reset + * window (`Retry-After`/`RateLimit-Reset`). When active it paces evenly to + * `limit` per `windowMs` (leaky bucket via `nextSendAt`). A 429 with NO window + * (e.g. Solana's `x-ratelimit-method-limit`: count, no window) deliberately does + * NOT activate pacing — those endpoints tolerate bursts and refill sub-second, so + * burst + retry beats pacing to a guessed window. Sustained success speeds the + * pace up and eventually deactivates, re-probing full speed. + */ +class AdaptiveLimiter { + active: boolean + limit: number + windowMs: number + private nextSendAt = 0 + private lastLimitTs = 0 + private successStreak = 0 + + constructor(seed?: { limit: number; windowMs: number }) { + this.active = seed != null + this.limit = Math.max(1, seed?.limit ?? 1) + this.windowMs = clampWindow(seed?.windowMs ?? DEFAULT_WINDOW_MS) + } + + /** Wait (only when active) for this scope's evenly-paced slot. */ + async acquire(): Promise { + if (!this.active) return + const now = Date.now() + const at = Math.max(now, this.nextSendAt) + this.nextSendAt = at + this.windowMs / this.limit // reserve next slot synchronously + if (at > now) await sleep(at - now) + } + + /** On a 429: activate + pace ONLY when an explicit reset window is known. */ + onLimited(hint: { limit?: number; windowMs?: number }): void { + if (hint.windowMs == null) return // no window → caller just retries + backs off + this.limit = Math.max(1, hint.limit ?? this.limit) + this.windowMs = clampWindow(hint.windowMs) + this.lastLimitTs = Date.now() + this.nextSendAt = this.lastLimitTs + this.successStreak = 0 + this.active = true + } + + /** Record header limit/window without activating (used if a 429 later hits). */ + learn(limit?: number, windowMs?: number): void { + if (limit != null) this.limit = Math.max(1, limit) + if (windowMs != null) this.windowMs = clampWindow(windowMs) + } + + /** On success: probe faster after a clean run, deactivate after a long one. */ + onSuccess(): void { + if (!this.active) return + const now = Date.now() + if (now - this.lastLimitTs > this.windowMs && ++this.successStreak >= this.limit) { + this.windowMs = clampWindow(this.windowMs * 0.7) + this.limit += Math.max(1, Math.floor(this.limit / 4)) + this.successStreak = 0 + } + if (now - this.lastLimitTs > Math.max(5_000, this.windowMs * 8)) this.active = false + } +} + +/** Per-endpoint shared state: concurrency gate + per-scope limiters + learned getLogs range. */ +interface EndpointState { + sem: AdaptiveSemaphore + limiters: Map + /** Seed applied to newly-created limiters for this endpoint (known hosts). */ + seed?: { limit: number; windowMs: number } + /** True once we've seen method-scoped rate headers; routes by JSON-RPC method. */ + methodScoped: boolean + logRange?: { maxRange: number; source: 'error' | 'success' } +} + +/** Module-global registry keyed by origin + pathname (query/hash stripped). */ +const endpointRegistry = new Map() + +/** Derive a stable key from a fetch input (string | URL | Request). */ +export function endpointKey(input: Parameters[0]): string { + let url: URL + if (typeof input === 'string') { + url = new URL(input) + } else if (input instanceof Request) { + url = new URL(input.url) + } else { + url = input + } + return url.origin + url.pathname +} + +function getOrCreateEndpoint( + input: Parameters[0], + seed?: { limit: number; windowMs: number }, + maxInFlight: number = DEFAULT_MAX_IN_FLIGHT, +): EndpointState { + const key = endpointKey(input) + let state = endpointRegistry.get(key) + if (!state) { + state = { + sem: new AdaptiveSemaphore(maxInFlight), + limiters: new Map(), + seed, + methodScoped: false, + } + endpointRegistry.set(key, state) + } + return state +} + +function getLimiter(ep: EndpointState, scope: string): AdaptiveLimiter { + let lim = ep.limiters.get(scope) + if (!lim) { + lim = new AdaptiveLimiter(ep.seed) + ep.limiters.set(scope, lim) + } + return lim +} +/* eslint-enable jsdoc/require-jsdoc */ + +/** + * Parses a Retry-After header value into an epoch-ms wait-until time. + * Handles both delta-seconds (integer) and HTTP-date formats. + * @param value - The raw header value. + * @returns Epoch-ms when retry is allowed, or null if unparseable. + */ +export function parseRetryAfter(value: string | null): number | null { + if (!value) return null + const trimmed = value.trim() + // Try delta-seconds first + const deltaSeconds = Number(trimmed) + if (!isNaN(deltaSeconds) && isFinite(deltaSeconds)) { + return Date.now() + deltaSeconds * 1000 + } + // Try HTTP-date + const parsed = Date.parse(trimmed) + if (!isNaN(parsed)) return parsed + return null +} + +/** Parsed rate-limit header information. */ +export interface ParsedRateLimitHeaders { + /** Remaining allowed requests in the current window. */ + remaining?: number + /** Total limit for the window. */ + limit?: number + /** Epoch-ms when the window resets. */ + resetAt?: number + /** Epoch-ms when retry is allowed (from Retry-After). */ + retryAfterAt?: number +} + +/** + * Parses standard and de-facto rate-limit response headers. + * + * Supports: + * - `Retry-After`: delta-seconds or HTTP-date + * - IETF draft: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (delta-seconds) + * - Combined `RateLimit:` header (e.g. `limit=100, remaining=50, reset=10`) + * - De-facto: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` + * (reset heuristic: if value \> (now/1000 - 1e9) treat as epoch-seconds, else delta-seconds) + * @param headers - Response headers. + * @returns Parsed rate-limit info. + */ +export function parseRateLimitHeaders(headers: Headers): ParsedRateLimitHeaders { + const result: ParsedRateLimitHeaders = {} + const now = Date.now() + const num = (name: string): number | undefined => { + const raw = headers.get(name) + const v = raw == null ? NaN : Number(raw) + return isNaN(v) ? undefined : v + } + + const retryAfter = parseRetryAfter(headers.get('Retry-After')) + if (retryAfter !== null) result.retryAfterAt = retryAfter + + // Combined IETF header, e.g. "RateLimit: limit=100, remaining=50, reset=10". + const combined = headers.get('RateLimit') + if (combined) { + for (const part of combined.split(',')) { + const [k, v] = part.split('=').map((s) => s.trim()) + const val = Number(v) + if (!k || isNaN(val)) continue + if (k.toLowerCase() === 'limit') result.limit = val + else if (k.toLowerCase() === 'remaining') result.remaining = val + else if (k.toLowerCase() === 'reset') result.resetAt = now + val * 1000 // delta-seconds + } + } + + // Individual headers override the combined one: IETF (`RateLimit-*`, reset is + // delta-seconds) then de-facto `X-RateLimit-*` (reset > 1e9 = epoch-seconds, + // else delta-seconds — a >31yr delta window is implausible). + for (const [prefix, resetMayBeEpoch] of [ + ['RateLimit', false], + ['X-RateLimit', true], + ] as const) { + const limit = num(`${prefix}-Limit`) + if (limit !== undefined) result.limit = limit + const remaining = num(`${prefix}-Remaining`) + if (remaining !== undefined) result.remaining = remaining + const reset = num(`${prefix}-Reset`) + if (reset !== undefined) + result.resetAt = resetMayBeEpoch && reset > 1e9 ? reset * 1000 : now + reset * 1000 + } + + return result +} + +/** A learned rate hint for one response: limit/window/remaining + whether method-scoped. */ +interface RateHint { + limit?: number + windowMs?: number + remaining?: number + methodScoped: boolean +} + +/** + * Extracts a rate hint from a response. Prefers method-scoped headers + * (`x-ratelimit-method-*`, e.g. Solana — limit is per JSON-RPC method, window + * unknown so left to the limiter's estimate) over standard + * `RateLimit-*`/`X-RateLimit-*`/`Retry-After` (which carry a reset window). + * @param response - The fetch Response. + * @param method - The JSON-RPC method, if known. + * @returns The parsed hint. + */ +function extractRateHint(response: Response, method?: string): RateHint { + const mLimitRaw = response.headers.get('x-ratelimit-method-limit') + const mRemainingRaw = response.headers.get('x-ratelimit-method-remaining') + // Header must be PRESENT — Number(null) is 0, not NaN, so a missing header + // would otherwise read as a (bogus) method limit of 0 and throttle everything. + if (method && mLimitRaw != null && mRemainingRaw != null) { + const limit = Number(mLimitRaw) + const remaining = Number(mRemainingRaw) + if (!isNaN(limit) && !isNaN(remaining)) { + return { limit, remaining, methodScoped: true } + } + } + const std = parseRateLimitHeaders(response.headers) + const resetAt = std.resetAt ?? std.retryAfterAt + const windowMs = resetAt != null ? resetAt - Date.now() : undefined + return { + limit: std.limit, + remaining: std.remaining, + windowMs: windowMs != null && windowMs > 0 ? windowMs : undefined, + methodScoped: false, + } +} + +/** + * Returns starting rate-limit opts for a host. Most hosts get NO proactive seed + * (they start at full speed and only adapt if they actually return 429s). Known + * always-throttled public hosts get an informed `seed` so they start paced — but + * the seed is just a starting point; the adaptive limiter still relaxes up or + * tightens down from there based on observed responses. + * Chain files call `createRateLimitedFetch(fetchProfileForUrl(url), ctx)`. + * @param url - The endpoint URL string. + * @returns Partial RateLimitOpts (optionally with a `seed`) for the host. + */ +export function fetchProfileForUrl(url: string): Partial { + try { + const { hostname } = new URL(url) + // TON public gateways genuinely cap at ~1 req/sec and 429 constantly from a + // cold start, so seed them paced. Still adapts from there. + if ( + hostname === 'toncenter.com' || + hostname.endsWith('.toncenter.com') || + hostname === 'tonapi.io' || + hostname.endsWith('.tonapi.io') + ) { + return { seed: { limit: 1, windowMs: 1500 }, maxRetries: 5 } + } + // Public Solana: no proactive seed. Its responses carry precise per-method + // limit headers (`x-ratelimit-method-*`), so the adaptive limiter learns the + // exact per-method rate (e.g. getSignaturesForAddress: 2/s) from the first + // responses and paces only that method — faster and more accurate than a + // static seed. Left to start at full speed. + } catch { + // Invalid URL — fall through to the default (no seed) + } + // Default: start at full speed, adapt reactively on 429. + return {} +} + +/** + * Returns the learned getLogs max range for an endpoint, if set. + * @param input - Fetch input (string, URL, or Request). + * @returns Max block range, or undefined if not learned. + */ +export function getEndpointLogRange(input: Parameters[0]): number | undefined { + return endpointRegistry.get(endpointKey(input))?.logRange?.maxRange +} + +/** + * Sets the learned getLogs max range for an endpoint. + * @param input - Fetch input (string, URL, or Request). + * @param maxRange - The learned max block range. + * @param source - Whether learned from an error or a success. + */ +export function setEndpointLogRange( + input: Parameters[0], + maxRange: number, + source: 'error' | 'success', +): void { + getOrCreateEndpoint(input).logRange = { maxRange, source } +} + +/** Buffer in ms added after a rate-limit reset before sending next request. */ +const RESET_BUFFER_MS = 200 + +/** Best-effort printable form of a request body (JSON string) for debug logs. */ +function bodyStr(body: RequestInit['body']): string | undefined { + if (body == null) return undefined + if (typeof body === 'string') return body + if (body instanceof Uint8Array) return new TextDecoder().decode(body) + return undefined +} + +/** Extracts the JSON-RPC method name from a request body, if present. */ +function extractMethod(init?: RequestInit): string | undefined { + if (!init?.body || (typeof init.body !== 'string' && typeof init.body !== 'object')) return + try { + const parsed = (typeof init.body === 'string' ? JSON.parse(init.body) : init.body) as + | { method?: string } + | undefined + if (parsed && typeof parsed.method === 'string') return parsed.method + } catch { + // Not JSON or no method field + } +} + +/** + * Creates a fetch wrapper that runs at full speed by default and adaptively + * paces only when an endpoint actually rate-limits it. Per (endpoint, method) + * limiters learn the real limit/window from response headers or observed timing, + * pace to it, tighten on repeat 429s, and relax back to full speed when limits + * stop. Shares learned state per endpoint across all instances. + * @returns The wrapped fetch function. + */ +export function createRateLimitedFetch( + opts: Partial = {}, + { logger = console, abort }: { abort?: AbortSignal } & WithLogger = {}, +): typeof fetch { + opts.maxRetries ??= 15 + const opts_ = opts as RateLimitOpts + + const isRetryableError = (error: unknown): boolean => { + if (error instanceof Error) return !!error.message.match(/\b(429\b|rate.?limit)/i) + return false + } + + // Backoff used when the limiter is NOT pacing (occasional/bursty 429s). Uses + // FULL JITTER over a 250ms→2s ramp: critical because callers often fire a + // burst of requests concurrently, so a fixed delay would retry them all in + // lock-step and re-trip the limit (thundering herd). Jitter spreads the + // retries out, letting most land in a freed slot. + const backoffMs = (attempt: number): number => + Math.floor(Math.random() * Math.min(15_000, 250 * 2 ** attempt)) + + return async (input, init?) => { + let lastError: Error | null = null + const method = extractMethod(init) + const ep = getOrCreateEndpoint(input, opts_.seed, opts_.maxInFlight) + + for (let attempt = 0; attempt <= opts_.maxRetries; attempt++) { + // Resolve the limiter for this request's scope (re-resolved each attempt: + // methodScoped may flip after the first response). + const scope = ep.methodScoped && method ? method : '*' + const lim = getLimiter(ep, scope) + let response: Response + let retryDelay = 0 + try { + // Concurrency gate: at most `maxInFlight` requests per endpoint are in + // flight at once. The slot is held ONLY across the fetch + header read, + // then released so the next queued request starts immediately (it sees + // any limit learned from this response). Backoff/retry happen outside + // the slot so a backing-off request doesn't occupy a slot. + await ep.sem.acquire() + try { + // Pace only if this scope is currently rate-limited; full speed otherwise. + await lim.acquire() + + if (init?.signal && abort) init.signal = AbortSignal.any([init.signal, abort]) + else if (abort) { + if (!init) init = {} + init.signal = abort + } + abort?.throwIfAborted() + response = await globalThis.fetch(input instanceof Request ? input.clone() : input, init) + + // Learn from rate-limit headers BEFORE releasing the slot, so the next + // queued request paces against the freshest known limit. The "target" + // limiter owns this scope (a method limiter once the host is known + // method-scoped, else the per-endpoint '*' limiter). + const hint = extractRateHint(response, method) + if (hint.methodScoped) ep.methodScoped = true + const target = ep.methodScoped && method ? getLimiter(ep, method) : lim + target.learn(hint.limit, hint.windowMs) + if (response.ok) { + target.onSuccess() + ep.sem.increase() // AIMD: a success widens the concurrency cap by one + } else if (isTransientHttpStatus(response.status)) { + target.onLimited({ limit: hint.limit, windowMs: hint.windowMs }) + ep.sem.decrease() // AIMD: a 429/5xx halves the cap (toward 1) + if (attempt < opts_.maxRetries) { + // Decide the retry wait now (executed after the slot is released): + // explicit reset → honor it; active pacing → acquire() handles it; + // else jittered backoff. + if (hint.windowMs != null && hint.remaining === 0) + retryDelay = hint.windowMs + RESET_BUFFER_MS + else if (!target.active) retryDelay = backoffMs(attempt) + } + } + } finally { + ep.sem.release() + } + } catch (error) { + logger.debug('fetch errored', attempt, error, input, bodyStr(init?.body)) + lastError = error instanceof Error ? error : CCIPError.from(error, 'HTTP_ERROR') + + // Only retry on retryable network errors (rate-limit pattern); rethrow everything else + if (!isRetryableError(lastError)) throw lastError + if (attempt >= opts_.maxRetries) break + // Treat a rate-limit-flavored network error as a limit signal: narrow the + // concurrency cap and back off before retrying (no header → no pacing). + ep.sem.decrease() + if (!lim.active) await sleep(backoffMs(attempt)) + continue + } + + // Slot released — now handle the response (and back off off-slot if retrying). + if (response.ok) { + logger.debug('fetched', response.status, bodyStr(init?.body)) + return response + } + if (isTransientHttpStatus(response.status)) { + if (attempt < opts_.maxRetries) { + logger.debug('fetch transient error, retrying', response.status, attempt, retryDelay) + if (retryDelay > 0) await sleep(retryDelay) + continue + } + logger.debug('fetch transient error, retries exhausted', response.status) + return response + } + // Non-transient non-ok (4xx etc): return immediately, no retry. + logger.debug('fetch non-retryable status', input, response.status, bodyStr(init?.body)) + return response + } + + throw lastError || CCIPError.from('Request failed after all retries', 'HTTP_ERROR') + } +} + +/** + * Creates an axios adapter that routes requests through a custom `fetch` function, + * with optional `AbortSignal` propagation. + * + * Wraps axios's built-in `'fetch'` adapter so that all HTTP traffic goes through + * the provided `fetchFn` (e.g. a rate-limited fetch). When `abort` is supplied, + * it is merged (via `AbortSignal.any`) with any per-request signal already set on + * the axios config, so callers don't need to thread the abort signal manually. + * + * @param fetchFn - The `fetch` implementation to bind (e.g. from `createRateLimitedFetch`). + * @param abort - Optional `AbortSignal` to merge into every request's signal. + * @returns An axios adapter ready to pass as `httpAdapter` in an axios/TonClient config. + * + * @example + * ```typescript + * const fetchFn = createRateLimitedFetch(fetchProfileForUrl(url), ctx) + * const httpAdapter = createAxiosFetchAdapter(fetchFn, ctx?.abort) + * const client = new TonClient({ endpoint: url, httpAdapter }) + * ``` + */ +export function createAxiosFetchAdapter(fetchFn: typeof fetch, abort?: AbortSignal): AxiosAdapter { + const base = (getAdapter as (name: string, config: object) => AxiosAdapter)('fetch', { + env: { fetch: fetchFn }, + }) + if (!abort) return base + return (config) => + base({ + ...config, + signal: config.signal ? AbortSignal.any([config.signal as AbortSignal, abort]) : abort, + }) +} + +/** + * Performs a fetch request with timeout and abort signal support. + * + * @param url - URL to fetch + * @param operation - Operation name for error context + * @param opts - Optional configuration: + * - `timeoutMs` — request timeout in milliseconds (default: 30000). + * - `signal` — an external `AbortSignal` to cancel the request. + * - `fetch` — custom fetch function (defaults to `globalThis.fetch`). + * - `init` — additional `RequestInit` fields merged into the fetch call. + * @returns Promise resolving to Response + * @throws CCIPTimeoutError if request times out + * @throws CCIPAbortError if request is aborted via signal + */ +export async function fetchWithTimeout( + url: string, + operation: string, + opts?: { + timeoutMs?: number + signal?: AbortSignal + fetch?: typeof globalThis.fetch + init?: RequestInit + }, +): Promise { + const timeoutMs = opts?.timeoutMs ?? 30_000 + const fetchFn = opts?.fetch ?? globalThis.fetch.bind(globalThis) + const timeoutSignal = AbortSignal.timeout(timeoutMs) + const combinedSignal = opts?.signal + ? AbortSignal.any([timeoutSignal, opts.signal]) + : timeoutSignal + + try { + return await fetchFn(url, { ...opts?.init, signal: combinedSignal }) + } catch (error) { + if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { + if (opts?.signal?.aborted) { + throw new CCIPAbortError(operation) + } + throw new CCIPTimeoutError(operation, timeoutMs) + } + throw error + } +} + +/** Range error info from a getLogs "range too large" error. */ +export interface LogRangeErrorInfo { + /** Maximum allowed block range, if extractable from the error message. */ + maxRange?: number + /** Suggested [from, to] block range in decimal, if provided by the RPC. */ + suggestedRange?: [number, number] +} + +/** + * Parses RPC errors for "getLogs block range too large" messages. + * + * Covers Alchemy, Infura, QuickNode, and generic EVM provider patterns. + * Also checks JSON-RPC error code -32005. + * + * @param err - The caught error (any shape). + * @returns Non-null LogRangeErrorInfo if the error is a range error, null otherwise. + */ +export function parseLogRangeError(err: unknown): LogRangeErrorInfo | null { + if (err == null) return null + + // Gather candidate message strings from common error shapes + const messages: string[] = [] + + const extractMessages = (val: unknown): void => { + if (typeof val === 'string') { + messages.push(val) + } else if (val && typeof val === 'object') { + const obj = val as Record + // JSON-RPC code -32005 + if ('code' in obj && (obj.code === -32005 || obj.code === '-32005')) { + // Treat as a range error even if message doesn't match; will return {} + messages.push('__code32005__') + } + for (const key of ['message', 'error', 'data', 'body', 'details'] as const) { + if (key in obj) extractMessages(obj[key]) + } + } + } + extractMessages(err) + + const isCode32005 = messages.includes('__code32005__') + const textMessages = messages.filter((m) => m !== '__code32005__') + + // Range-error patterns (case-insensitive) + const RANGE_ERROR_PATTERNS = [ + // Alchemy: "up to a 10000 block range" + /up to a (\d+) block range/i, + // Infura: "query returned more than 10000 results" + /query returned more than (\d+) results/i, + // QuickNode + /eth_getLogs is limited to a (\d+) range/i, + /exceeds the range/i, + // Generic + /range too large/i, + /limit exceeded/i, + /too many (?:results|logs|blocks)/i, + /response size exceeded/i, + ] + + // Generic block-range detector: any message mentioning a block "range" is + // treated as a range error, and any number in that same message is taken as + // the max range (e.g. Astar/erpc "block range is too wide (maximum 1024)"). + const BLOCK_RANGE_RE = /\bblock\b.*\brange\b/i + const FIRST_NUMBER_RE = /\b(\d+)\b/ + + // Alchemy suggested range: [0x..., 0x...] + const ALCHEMY_SUGGESTED_RANGE = /\[(0x[0-9a-f]+),\s*(0x[0-9a-f]+)\]/i + + let isRangeError = isCode32005 + let maxRange: number | undefined + let suggestedRange: [number, number] | undefined + + for (const msg of textMessages) { + for (const pattern of RANGE_ERROR_PATTERNS) { + const match = pattern.exec(msg) + if (match) { + isRangeError = true + // First capture group = the limit number + if (match[1] !== undefined) { + const n = Number(match[1]) + if (!isNaN(n) && (maxRange === undefined || n < maxRange)) maxRange = n + } + } + } + // Generic: a message about a block range is a range error; any number in + // that same message is the max range (covers "(maximum N)", "limited to N", …). + if (BLOCK_RANGE_RE.test(msg)) { + isRangeError = true + const numMatch = FIRST_NUMBER_RE.exec(msg) + if (numMatch) { + const n = Number(numMatch[1]) + if (!isNaN(n) && n > 0 && (maxRange === undefined || n < maxRange)) maxRange = n + } + } + + // Alchemy-style suggested range + const rangeMatch = ALCHEMY_SUGGESTED_RANGE.exec(msg) + if (rangeMatch) { + isRangeError = true + const from = parseInt(rangeMatch[1]!, 16) + const to = parseInt(rangeMatch[2]!, 16) + if (!isNaN(from) && !isNaN(to)) suggestedRange = [from, to] + } + } + + if (!isRangeError) return null + + const info: LogRangeErrorInfo = {} + if (maxRange !== undefined) info.maxRange = maxRange + if (suggestedRange !== undefined) info.suggestedRange = suggestedRange + return info +} diff --git a/ccip-sdk/src/offchain.ts b/ccip-sdk/src/offchain.ts index 0f23fb78..908ee15c 100644 --- a/ccip-sdk/src/offchain.ts +++ b/ccip-sdk/src/offchain.ts @@ -8,9 +8,10 @@ import { CCIPUsdcBurnFeesError, } from './errors/index.ts' import { parseSourceTokenData } from './evm/messages.ts' +import { fetchWithTimeout } from './fetch.ts' import { NetworkType, networkInfo } from './networks.ts' import type { CCIPRequest, OffchainTokenData, WithLogger } from './types.ts' -import { fetchWithTimeout, getDataBytes } from './utils.ts' +import { getDataBytes } from './utils.ts' const CIRCLE_API_URL = { mainnet: 'https://iris-api.circle.com', diff --git a/ccip-sdk/src/utils.test.ts b/ccip-sdk/src/utils.test.ts index 008204af..6fe56866 100644 --- a/ccip-sdk/src/utils.test.ts +++ b/ccip-sdk/src/utils.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { afterEach, beforeEach, describe, it, mock } from 'node:test' +import { describe, it, mock } from 'node:test' import { NATIVE_MINT } from '@solana/spl-token' import { dataLength } from 'ethers' @@ -13,7 +13,6 @@ import { bigIntReviver, blockRangeGenerator, convertKeysToCamelCase, - createRateLimitedFetch, decodeAddress, decodeOnRampAddress, getAddressBytes, @@ -1054,128 +1053,6 @@ describe('parseTypeAndVersion', () => { }) }) -describe('createRateLimitedFetch', () => { - let originalFetch: typeof fetch - let mockedFetch - - beforeEach(() => { - originalFetch = globalThis.fetch - }) - - afterEach(() => { - globalThis.fetch = originalFetch - mockedFetch = undefined - }) - - it('should create a rate-limited fetch function', () => { - const rateLimitedFetch = createRateLimitedFetch({ maxRequests: 2, windowMs: 1000 }) - assert.equal(typeof rateLimitedFetch, 'function') - }) - - it('should allow requests within rate limit', async () => { - globalThis.fetch = mockedFetch = mock.fn(() => - Promise.resolve({ - ok: true, - status: 200, - statusText: 'OK', - } as Response), - ) - - const rateLimitedFetch = createRateLimitedFetch({ maxRequests: 2, windowMs: 1000 }) - - const promise1 = rateLimitedFetch('https://example.com') - const promise2 = rateLimitedFetch('https://example.com') - - await Promise.all([promise1, promise2]) - - assert.equal(mockedFetch.mock.calls.length, 2) - }) - - it('should retry on 429 rate limit errors', async () => { - let callCount = 0 - globalThis.fetch = mockedFetch = mock.fn(() => { - callCount++ - if (callCount === 1) { - return Promise.resolve({ - ok: false, - status: 429, - statusText: 'Too Many Requests', - } as Response) - } - return Promise.resolve({ - ok: true, - status: 200, - statusText: 'OK', - } as Response) - }) - - const rateLimitedFetch = createRateLimitedFetch({ maxRequests: 5, windowMs: 1000 }) - - const result = await rateLimitedFetch('https://example.com') - assert.equal(result.ok, true) - assert.equal(mockedFetch.mock.calls.length, 2) - }) - - it('should throw non-retryable errors immediately', async () => { - globalThis.fetch = mockedFetch = mock.fn(() => - Promise.resolve({ - ok: false, - status: 404, - statusText: 'Not Found', - } as Response), - ) - - const rateLimitedFetch = createRateLimitedFetch({ maxRequests: 5, windowMs: 1000 }) - - await assert.rejects(rateLimitedFetch('https://example.com'), /HTTP 404/) - assert.equal(mockedFetch.mock.calls.length, 1) - }) - - it('should respect maxRetries parameter', async () => { - globalThis.fetch = mockedFetch = mock.fn(() => - Promise.resolve({ - ok: false, - status: 429, - statusText: 'Too Many Requests', - } as Response), - ) - - const rateLimitedFetch = createRateLimitedFetch({ - maxRequests: 10, - windowMs: 1000, - maxRetries: 2, - }) - - await assert.rejects(rateLimitedFetch('https://example.com'), /Too Many Requests/) - assert.equal(mockedFetch.mock.calls.length, 3) // Initial + 2 retries - }) - - it('should use default parameters when none provided', () => { - const rateLimitedFetch = createRateLimitedFetch() - assert.equal(typeof rateLimitedFetch, 'function') - }) - - it('should handle network errors with retry logic', async () => { - let callCount = 0 - globalThis.fetch = mockedFetch = mock.fn(() => { - callCount++ - if (callCount === 1) { - return Promise.reject(new Error('429 rate limit exceeded')) - } - return Promise.resolve({ - ok: true, - status: 200, - statusText: 'OK', - } as Response) - }) - - const rateLimitedFetch = createRateLimitedFetch({ maxRequests: 5, windowMs: 1000 }) - - const result = await rateLimitedFetch('https://example.com') - assert.equal(result.ok, true) - }) -}) - describe('withRetry', () => { it('should return result on first attempt success', async () => { const operation = mock.fn(() => Promise.resolve('success')) diff --git a/ccip-sdk/src/utils.ts b/ccip-sdk/src/utils.ts index 4c7664f6..1b1ea341 100644 --- a/ccip-sdk/src/utils.ts +++ b/ccip-sdk/src/utils.ts @@ -15,15 +15,11 @@ import yaml from 'yaml' import type { Chain, ChainStatic } from './chain.ts' import { - CCIPAbortError, CCIPBlockBeforeTimestampNotFoundError, CCIPChainFamilyUnsupportedError, CCIPDataFormatUnsupportedError, CCIPError, - CCIPHttpError, - CCIPTimeoutError, CCIPTypeVersionInvalidError, - isTransientHttpStatus, } from './errors/index.ts' import { getRetryDelay, shouldRetry } from './errors/utils.ts' import { ChainFamily } from './networks.ts' @@ -571,242 +567,8 @@ export function parseTypeAndVersion( else return [type, version, typeAndVersion, match[3]] } -/* eslint-disable jsdoc/require-jsdoc */ -type RateLimitOpts = { maxRequests: number; windowMs: number; maxRetries: number } - -class RateLimit { - readonly requestQueue: Array<{ timestamp: number }> - readonly methodRateLimits: Record< - string, - { limit: number; remaining: number; queue: Array<{ timestamp: number }> } - > - constructor() { - this.requestQueue = [] - this.methodRateLimits = {} - } - - isRateLimited({ windowMs, maxRequests }: RateLimitOpts): boolean { - const now = Date.now() - // Remove old requests outside the window - while (this.requestQueue.length > 0 && now - this.requestQueue[0]!.timestamp > windowMs) { - this.requestQueue.shift() - } - return this.requestQueue.length >= maxRequests - } - - isMethodRateLimited({ windowMs }: RateLimitOpts, method: string): boolean { - const methodLimit = this.methodRateLimits[method] - if (!methodLimit) return false - - const now = Date.now() - // Remove old requests outside the window - while (methodLimit.queue.length > 0 && now - methodLimit.queue[0]!.timestamp > windowMs) { - methodLimit.queue.shift() - } - return methodLimit.queue.length >= methodLimit.limit - } - - async waitForRateLimit(opts: RateLimitOpts, method?: string): Promise { - // Wait for method-specific rate limit if applicable - if (method && this.methodRateLimits[method]) { - while (this.isMethodRateLimited(opts, method)) { - const oldestRequest = this.methodRateLimits[method].queue[0] - if (!oldestRequest) break // Queue was cleaned, no longer rate limited - const waitTime = opts.windowMs - (Date.now() - oldestRequest.timestamp) - if (waitTime > 0) { - await sleep(waitTime + 100) // Add small buffer - } - } - } - - // Wait for global rate limit - while (this.isRateLimited(opts)) { - const oldestRequest = this.requestQueue[0] - if (!oldestRequest) break // Queue was cleaned, no longer rate limited - const waitTime = opts.windowMs - (Date.now() - oldestRequest.timestamp) - if (waitTime > 0) { - await sleep(waitTime + 100) // Add small buffer - } - } - } - - recordRequest(method?: string): void { - const timestamp = Date.now() - this.requestQueue.push({ timestamp }) - if (method && this.methodRateLimits[method]) { - this.methodRateLimits[method].queue.push({ timestamp }) - } - } - - updateMethodRateLimits(response: Response, method?: string): void { - if (!method) return - - const limit = Number(response.headers.get('x-ratelimit-method-limit')) - const remaining = Number(response.headers.get('x-ratelimit-method-remaining')) - - if (isNaN(limit) || isNaN(remaining)) return - if (!this.methodRateLimits[method]) { - this.methodRateLimits[method] = { limit, remaining, queue: [] } - } else { - this.methodRateLimits[method].limit = limit - this.methodRateLimits[method].remaining = remaining - } - } -} -/* eslint-enable jsdoc/require-jsdoc */ - -// global map per hostname -const perHostnameRateLimits: Record = {} - -/** - * Creates a rate-limited fetch function with retry logic. - * Configurable via maxRequests, windowMs, and maxRetries options. - * @returns Rate-limited fetch function. - */ -export function createRateLimitedFetch( - opts: Partial = {}, - { logger = console, abort }: { abort?: AbortSignal } & WithLogger = {}, -): typeof fetch { - opts.maxRequests ??= 40 - opts.maxRetries ??= 5 - opts.windowMs ??= 12e3 - const opts_ = opts as RateLimitOpts - - const extractMethod = (init?: RequestInit): string | undefined => { - if (!init?.body || (typeof init.body !== 'string' && typeof init.body !== 'object')) return - try { - const parsed = (typeof init.body === 'string' ? JSON.parse(init.body) : init.body) as - | { method?: string } - | undefined - if (parsed && typeof parsed.method === 'string') return parsed.method - } catch { - // Not JSON or no method field - } - } - - const extractHostname = (input: Parameters[0]): string => { - if (typeof input === 'string') { - input = new URL(input) - } else if (input instanceof Request) { - input = new URL(input.url) - } - return input.hostname - } - - const isRetryableError = (error: unknown): boolean => { - if (error instanceof CCIPHttpError) return error.isTransient === true - if (error instanceof Error) return !!error.message.match(/\b(429\b|rate.?limit)/i) - return false - } - - return async (input, init?) => { - let lastError: Error | null = null - const method = extractMethod(init) - const hostname = extractHostname(input) - const rl = (perHostnameRateLimits[hostname] ??= new RateLimit()) - - const body = init?.body ?? (input instanceof Request ? await input.clone().json() : undefined) - for (let attempt = 0; attempt <= opts_.maxRetries; attempt++) { - try { - // Wait for rate limit before making request - await rl.waitForRateLimit(opts_, method) - rl.recordRequest(method) - - if (init?.signal && abort) init.signal = AbortSignal.any([init.signal, abort]) - else if (abort) { - if (!init) init = {} - init.signal = abort - } - abort?.throwIfAborted() - const response = await globalThis.fetch( - input instanceof Request ? input.clone() : input, - init, - ) - - // Update method rate limits from response headers - rl.updateMethodRateLimits(response, method) - - // If response is successful, return it - if (response.ok) { - logger.debug( - 'fetched', - response.status, - // response.headers, - body, - // ((await response.clone().json()) as { result: unknown })?.result, - ) - return response - } - - // For transient responses (429, 5xx), throw to trigger retry - if (isTransientHttpStatus(response.status)) { - throw new CCIPHttpError(response.status, response.statusText) - } - - // For other non-2xx responses, don't retry - logger.debug('fetch non-retryable error', input, response.status, init?.body) - throw new CCIPHttpError(response.status, response.statusText) - } catch (error) { - logger.debug('fetch errored', attempt, error, input, init?.body) - lastError = error instanceof Error ? error : CCIPError.from(error, 'HTTP_ERROR') - - // Only retry on transient errors (429, 5xx, network errors matching rate-limit pattern) - if (!isRetryableError(lastError)) { - throw lastError - } - - // Don't retry on the last attempt - if (attempt >= opts_.maxRetries) break - } - } - - throw lastError || CCIPError.from('Request failed after all retries', 'HTTP_ERROR') - } -} - -/** - * Performs a fetch request with timeout and abort signal support. - * - * @param url - URL to fetch - * @param operation - Operation name for error context - * @param opts - Optional configuration: - * - `timeoutMs` — request timeout in milliseconds (default: 30000). - * - `signal` — an external `AbortSignal` to cancel the request. - * - `fetch` — custom fetch function (defaults to `globalThis.fetch`). - * - `init` — additional `RequestInit` fields merged into the fetch call. - * @returns Promise resolving to Response - * @throws CCIPTimeoutError if request times out - * @throws CCIPAbortError if request is aborted via signal - */ -export async function fetchWithTimeout( - url: string, - operation: string, - opts?: { - timeoutMs?: number - signal?: AbortSignal - fetch?: typeof globalThis.fetch - init?: RequestInit - }, -): Promise { - const timeoutMs = opts?.timeoutMs ?? 30_000 - const fetchFn = opts?.fetch ?? globalThis.fetch.bind(globalThis) - const timeoutSignal = AbortSignal.timeout(timeoutMs) - const combinedSignal = opts?.signal - ? AbortSignal.any([timeoutSignal, opts.signal]) - : timeoutSignal - - try { - return await fetchFn(url, { ...opts?.init, signal: combinedSignal }) - } catch (error) { - if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { - if (opts?.signal?.aborted) { - throw new CCIPAbortError(operation) - } - throw new CCIPTimeoutError(operation, timeoutMs) - } - throw error - } -} +// Re-export for backward compatibility (symbols moved to fetch.ts) +export { createRateLimitedFetch, fetchWithTimeout } from './fetch.ts' // barebones `node:util` backfill, if needed const util = From 85f48aa3cbc757eab354a694e43c38e0f9e16c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Mon, 8 Jun 2026 17:06:07 -0400 Subject: [PATCH 2/8] feat(sdk): apply rate-limited fetch by default across all chains Wire the default rate-limited fetch into every fromUrl constructor, with a ctx.fetch opt-out: a caller-supplied fetch is used verbatim (unwrapped). - ChainContext.fetch opt-out - EVM via ethers FetchRequest adapter; Solana via ConnectionConfig.fetch; Sui via JsonRpcHTTPTransport - TON + Canton via shared createAxiosFetchAdapter (Canton only when ctx.fetch is given, preserving its native HTTP/2 default) - Aptos custom Client shim, applied in fromUrl/fromAptosConfig only (never fromProvider) Co-Authored-By: Claude Opus 4.8 (1M context) --- ccip-sdk/src/aptos/fetch-client.test.ts | 350 ++++++++++++++++++++++ ccip-sdk/src/aptos/index.ts | 118 +++++++- ccip-sdk/src/canton/client/client.test.ts | 82 +++++ ccip-sdk/src/canton/client/client.ts | 20 +- ccip-sdk/src/canton/index.ts | 7 +- ccip-sdk/src/chain.ts | 8 + ccip-sdk/src/evm/fetch-injection.test.ts | 170 +++++++++++ ccip-sdk/src/evm/index.ts | 31 +- ccip-sdk/src/solana/index.ts | 12 +- ccip-sdk/src/sui/index.ts | 13 +- ccip-sdk/src/ton/adapter.test.ts | 63 ++++ ccip-sdk/src/ton/index.ts | 53 ++-- 12 files changed, 870 insertions(+), 57 deletions(-) create mode 100644 ccip-sdk/src/aptos/fetch-client.test.ts create mode 100644 ccip-sdk/src/canton/client/client.test.ts create mode 100644 ccip-sdk/src/evm/fetch-injection.test.ts create mode 100644 ccip-sdk/src/ton/adapter.test.ts diff --git a/ccip-sdk/src/aptos/fetch-client.test.ts b/ccip-sdk/src/aptos/fetch-client.test.ts new file mode 100644 index 00000000..d8026183 --- /dev/null +++ b/ccip-sdk/src/aptos/fetch-client.test.ts @@ -0,0 +1,350 @@ +/** + * Tests for the Aptos fetch shim injected by fromAptosConfig. + * + * Strategy: + * - Integration suite: observable contract via fromAptosConfig (the first test populates + * the networkInfo('aptos:2') cache so subsequent tests that skip getChainId() still work). + * - Unit suite: test the shim's provider() directly by extracting it from the AptosConfig + * that fromAptosConfig installs. + */ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Client, type ClientRequest, AptosConfig, Network } from '@aptos-labs/ts-sdk' + +import { AptosChain } from './index.ts' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CHAIN_ID = 2 // Aptos Testnet — a valid supported chain + +/** Safely extract the URL string from any fetch `input` argument. */ +function toUrlString(input: Parameters[0]): string { + if (typeof input === 'string') return input + if (input instanceof URL) return input.href + return input.url +} + +function ledgerInfo(chainId = CHAIN_ID) { + return { + chain_id: chainId, + ledger_version: '1', + epoch: '1', + ledger_timestamp: '0', + node_role: 'full_node', + oldest_ledger_version: '0', + oldest_block_height: '0', + block_height: '1', + git_hash: '', + } +} + +/** Build a fake Aptos Client that returns a fixed JSON response. */ +function makeFakeAptosClient(chainId = CHAIN_ID) { + const providerCalls: Array> = [] + const client: Client = { + async provider(req: ClientRequest) { + providerCalls.push(req) + return { + status: 200, + statusText: 'OK', + data: ledgerInfo(chainId) as unknown as Res, + headers: {}, + config: req, + request: null, + response: null, + } + }, + } + return { client, providerCalls } +} + +/** + * Extract the Client shim that fromAptosConfig installs by capturing the AptosConfig + * that gets built inside fromAptosConfig. We do this by patching AptosConfig constructor. + */ +async function extractInstalledClient( + settings: Parameters[0], + ctx?: Parameters[1], +): Promise { + let capturedClient: Client | undefined + + // Wrap the AptosConfig constructor to capture what gets passed as `client` + const OrigAptosConfig = AptosConfig + const PatchedConfig = class extends OrigAptosConfig { + constructor(s: ConstructorParameters[0]) { + super(s) + if (s?.client && s.client !== capturedClient) { + capturedClient = s.client + } + } + } as typeof AptosConfig + + // Temporarily replace the global AptosConfig — only works if the module's reference + // is accessible. Since we can't easily mock ESM imports, we instead call fromAptosConfig + // and then directly inspect via the Aptos provider instance on the returned chain. + // + // Approach: call fromAptosConfig, then read provider.config.client from the Aptos instance. + const chain = await AptosChain.fromAptosConfig(settings, ctx) + // `chain.provider` is the `Aptos` instance; `chain.provider.config` is the AptosConfig. + const installedClient: Client = chain.provider.config.client + + void PatchedConfig + void capturedClient + return installedClient +} + +// --------------------------------------------------------------------------- +// Integration tests: shim contract via fromAptosConfig +// Note: The FIRST test in this suite must populate networkInfo('aptos:2') cache +// using a spy fetch so we can assert that spy was called. +// --------------------------------------------------------------------------- + +describe('createAptosFetchClient shim (integration via fromAptosConfig)', () => { + it('routes all Aptos REST calls through ctx.fetch (populates networkInfo cache)', async () => { + const fetchCalls: Array<{ url: string; method: string | undefined }> = [] + const spyFetch: typeof fetch = async (input, init) => { + fetchCalls.push({ + url: toUrlString(input), + method: init?.method, + }) + return new Response(JSON.stringify(ledgerInfo()), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + await AptosChain.fromAptosConfig( + { network: Network.MAINNET, fullnode: 'https://fullnode.example.internal' }, + { fetch: spyFetch }, + ) + + assert.ok( + fetchCalls.length >= 1, + `fetch spy should be called at least once; got ${fetchCalls.length}`, + ) + assert.ok( + fetchCalls.every((c) => c.url.startsWith('https://fullnode.example.internal')), + `all calls must use the fullnode URL; got: ${fetchCalls.map((c) => c.url).join(', ')}`, + ) + assert.ok( + fetchCalls.every((c) => !c.method || c.method === 'GET'), + 'getLedgerInfo should be GET', + ) + }) + + it('raw AptosSettings with explicit client: ctx.fetch not called', async () => { + const { client: fakeClient } = makeFakeAptosClient() + const ctxFetchCalls: string[] = [] + const spyFetch: typeof fetch = async (input) => { + ctxFetchCalls.push(toUrlString(input)) + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) + } + + await AptosChain.fromAptosConfig( + { network: Network.MAINNET, client: fakeClient }, + { + fetch: spyFetch, + }, + ) + + assert.equal( + ctxFetchCalls.length, + 0, + 'ctx.fetch must not be called when explicit client is set', + ) + }) + + it('pre-built AptosConfig with explicit client: ctx.fetch not called', async () => { + const { client: fakeClient } = makeFakeAptosClient() + const ctxFetchCalls: string[] = [] + const spyFetch: typeof fetch = async (input) => { + ctxFetchCalls.push(toUrlString(input)) + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) + } + + await AptosChain.fromAptosConfig( + new AptosConfig({ network: Network.MAINNET, client: fakeClient }), + { fetch: spyFetch }, + ) + + assert.equal( + ctxFetchCalls.length, + 0, + 'ctx.fetch must not be called when explicit client is set', + ) + }) + + it('fromProvider: provider instance is passed through unchanged (no shim)', async () => { + const { Aptos } = await import('@aptos-labs/ts-sdk') + const { client: fakeClient } = makeFakeAptosClient() + + const aptosProvider = new Aptos( + new AptosConfig({ network: Network.MAINNET, client: fakeClient }), + ) + const chain = await AptosChain.fromProvider(aptosProvider, {}) + + // fromProvider must NOT wrap the provider — it should be the exact same reference + assert.strictEqual( + chain.provider, + aptosProvider, + 'fromProvider must not wrap the Aptos provider', + ) + // The config's client must still be fakeClient (not replaced by the shim) + assert.strictEqual( + chain.provider.config.client, + fakeClient, + 'fromProvider must not replace config.client with a shim', + ) + }) +}) + +// --------------------------------------------------------------------------- +// Unit tests: test the shim's provider() function directly. +// We extract the installed client from the AptosConfig via the chain's provider. +// --------------------------------------------------------------------------- + +describe('createAptosFetchClient provider() contract (unit)', () => { + it('GET request: calls fetch with method=GET and no body', async () => { + const fetchCalls: Array<{ method: string | undefined; body: unknown }> = [] + const spyFetch: typeof fetch = async (input, init) => { + fetchCalls.push({ method: init?.method, body: init?.body }) + return new Response(JSON.stringify(ledgerInfo()), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + // Extract the installed client + const client = await extractInstalledClient( + { network: Network.MAINNET, fullnode: 'https://probe.test.internal' }, + { fetch: spyFetch }, + ) + + // Reset and call provider() directly with a GET request + fetchCalls.length = 0 + const req: ClientRequest = { + url: 'https://probe.test.internal/v1', + method: 'GET', + params: { ledger_version: 1 }, + } + const resp = await client.provider(req) + + assert.equal(fetchCalls.length, 1) + assert.equal(fetchCalls[0]!.method, 'GET') + assert.ok(!fetchCalls[0]!.body, 'GET must not have a body') + assert.equal(resp.status, 200) + assert.ok(resp.data, 'response data should be parsed') + // Query param should be appended + const calledUrl = typeof fetchCalls[0] === 'object' ? '' : '' + void calledUrl + }) + + it('GET with params: query params appended to URL', async () => { + const calledUrls: string[] = [] + const spyFetch: typeof fetch = async (input, _init) => { + calledUrls.push(toUrlString(input)) + return new Response(JSON.stringify(ledgerInfo()), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + const client = await extractInstalledClient( + { network: Network.MAINNET, fullnode: 'https://probe.test.internal' }, + { fetch: spyFetch }, + ) + + calledUrls.length = 0 + await client.provider({ + url: 'https://probe.test.internal/v1/accounts/0x1', + method: 'GET', + params: { ledger_version: 999, extra: 'val' }, + }) + + assert.equal(calledUrls.length, 1) + const url = new URL(calledUrls[0]!) + assert.equal(url.searchParams.get('ledger_version'), '999') + assert.equal(url.searchParams.get('extra'), 'val') + }) + + it('POST with JSON body: serializes body and sets content-type', async () => { + const fetchCalls: Array<{ method: string | undefined; body: unknown; ct: string | undefined }> = + [] + const spyFetch: typeof fetch = async (input, init) => { + const headers = init?.headers as Record | undefined + fetchCalls.push({ method: init?.method, body: init?.body, ct: headers?.['content-type'] }) + return new Response(JSON.stringify([1, 2, 3]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + const client = await extractInstalledClient( + { network: Network.MAINNET, fullnode: 'https://probe.test.internal' }, + { fetch: spyFetch }, + ) + + fetchCalls.length = 0 + const payload = { function: '0x1::foo::bar', arguments: ['0x1'] } + await client.provider({ + url: 'https://probe.test.internal/v1/view', + method: 'POST', + body: payload, + contentType: 'application/json', + }) + + assert.equal(fetchCalls.length, 1) + assert.equal(fetchCalls[0]!.method, 'POST') + assert.ok(fetchCalls[0]!.ct?.includes('application/json'), `content-type: ${fetchCalls[0]!.ct}`) + assert.doesNotThrow(() => JSON.parse(fetchCalls[0]!.body as string), 'body must be valid JSON') + assert.deepStrictEqual(JSON.parse(fetchCalls[0]!.body as string), payload) + }) + + it('non-2xx response: returned (not thrown) with correct status', async () => { + const spyFetch: typeof fetch = async () => + new Response(JSON.stringify({ error_code: 'not_found' }), { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'application/json' }, + }) + + const client = await extractInstalledClient( + { network: Network.MAINNET, fullnode: 'https://probe.test.internal' }, + { fetch: spyFetch }, + ) + + // provider() must NOT throw on 404 — it returns the response for the SDK to handle + const resp = await client.provider({ + url: 'https://probe.test.internal/v1/accounts/0xdead', + method: 'GET', + }) + + assert.equal(resp.status, 404) + assert.equal(resp.statusText, 'Not Found') + assert.deepStrictEqual(resp.data, { error_code: 'not_found' }) + }) + + it('response JSON is parsed into data field', async () => { + const spyFetch: typeof fetch = async () => + new Response(JSON.stringify({ value: 42, nested: { x: 1 } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + + const client = await extractInstalledClient( + { network: Network.MAINNET, fullnode: 'https://probe.test.internal' }, + { fetch: spyFetch }, + ) + + const resp = await client.provider({ + url: 'https://probe.test.internal/v1/something', + method: 'GET', + }) + + assert.deepStrictEqual(resp.data, { value: 42, nested: { x: 1 } }) + assert.equal(resp.status, 200) + }) +}) diff --git a/ccip-sdk/src/aptos/index.ts b/ccip-sdk/src/aptos/index.ts index cdd41075..8e833ee0 100644 --- a/ccip-sdk/src/aptos/index.ts +++ b/ccip-sdk/src/aptos/index.ts @@ -1,4 +1,8 @@ import { + type AptosSettings, + type Client, + type ClientRequest, + type ClientResponse, Aptos, AptosApiError, AptosConfig, @@ -21,6 +25,7 @@ import { type TokenTransferFeeOpts, Chain, } from '../chain.ts' +import { createRateLimitedFetch, fetchProfileForUrl } from '../fetch.ts' import { generateUnsignedExecuteReport } from './exec.ts' import { getAptosLeafHasher } from './hasher.ts' import { getUserTxByVersion, getVersionTimestamp, streamAptosLogs } from './logs.ts' @@ -80,6 +85,60 @@ import type { CCIPMessage_V1_6_EVM } from '../evm/messages.ts' import { buildMessageForDest, decodeMessage, normalizeDeep } from '../requests.ts' export type { UnsignedAptosTx } +/** + * Creates an Aptos SDK `Client` that routes all HTTP calls through the supplied fetch function. + * Non-2xx responses are returned (not thrown) so the SDK can build its own `AptosApiError`. + */ +function createAptosFetchClient(fetchFn: typeof fetch): Client { + return { + async provider(req: ClientRequest): Promise> { + const url = new URL(req.url) + if (req.params) { + for (const [k, v] of Object.entries( + req.params as Record, + )) { + if (v != null) url.searchParams.set(k, String(v)) + } + } + const headers: Record = {} + for (const [k, v] of Object.entries(req.headers ?? {})) { + if (v != null) headers[k] = String(v) + } + const contentType = req.contentType ?? 'application/json' + type FetchBody = NonNullable[1]>['body'] + let body: FetchBody + if (req.body != null) { + headers['content-type'] ??= contentType + body = contentType.includes('json') ? JSON.stringify(req.body) : (req.body as FetchBody) + } + const resp = await fetchFn(url.toString(), { method: req.method, headers, body }) + const text = await resp.text() + let data: unknown = text + const respCT = resp.headers.get('content-type') ?? '' + if (respCT.includes('json') || (!respCT && text)) { + try { + data = text ? JSON.parse(text) : undefined + } catch { + // keep text as-is + } + } + const respHeaders: Record = {} + resp.headers.forEach((v, k) => { + respHeaders[k] = v + }) + return { + status: resp.status, + statusText: resp.statusText, + data: data as Res, + headers: respHeaders, + config: req, + request: null, + response: null, + } + }, + } +} + /** * Aptos chain implementation supporting Aptos networks. */ @@ -171,13 +230,52 @@ export class AptosChain extends Chain { } /** - * Creates an AptosChain instance from an Aptos configuration. - * @param config - Aptos configuration object. - * @param ctx - context containing logger. + * Creates an AptosChain instance from Aptos configuration settings. + * + * Installs a fetch-based `Client` shim so that all Aptos REST calls are routed + * through `ctx.fetch` (when provided) or through the built-in rate-limited fetch. + * If the settings already include an explicit `client`, that client is used as-is. + * + * Use {@link AptosChain.fromProvider} instead when you have a fully constructed + * `Aptos` instance and want no shim to be installed. + * + * @param settings - Aptos configuration settings (AptosSettings or AptosConfig). + * @param ctx - context containing logger and optional fetch override. * @returns A new AptosChain instance. */ - static async fromAptosConfig(config: AptosConfig, ctx?: WithLogger): Promise { - const provider = new Aptos(config) + static async fromAptosConfig( + settings: AptosSettings | AptosConfig, + ctx?: ChainContext, + ): Promise { + // Detect whether the caller explicitly set a custom HTTP client adapter: + // - For raw AptosSettings: `client` is undefined unless explicitly set. + // - For a pre-built AptosConfig: the SDK always fills in `config.client` with a default + // whose `.provider` is named `"aptosClient"`. Any other name or identity indicates a + // user-supplied adapter. + const explicitClient = settings.client + const hasExplicitClient = + explicitClient != null && explicitClient.provider.name !== 'aptosClient' + let effectiveConfig: AptosConfig + if (hasExplicitClient) { + effectiveConfig = settings instanceof AptosConfig ? settings : new AptosConfig(settings) + } else { + const fetchFn = + ctx?.fetch ?? createRateLimitedFetch(fetchProfileForUrl(settings.fullnode ?? ''), ctx) + effectiveConfig = new AptosConfig({ + network: settings.network, + fullnode: settings.fullnode, + faucet: settings.faucet, + indexer: settings.indexer, + pepper: settings.pepper, + prover: settings.prover, + clientConfig: settings.clientConfig, + fullnodeConfig: settings.fullnodeConfig, + indexerConfig: settings.indexerConfig, + faucetConfig: settings.faucetConfig, + client: createAptosFetchClient(fetchFn), + }) + } + const provider = new Aptos(effectiveConfig) return this.fromProvider(provider, ctx) } @@ -200,12 +298,14 @@ export class AptosChain extends Chain { else if (url.includes('testnet')) network = Network.TESTNET else if (url.includes('local')) network = Network.LOCAL else throw new CCIPAptosNetworkUnknownError(util.inspect(url)) - const config: AptosConfig = new AptosConfig({ + // Pass raw AptosSettings (not a pre-built AptosConfig) so fromAptosConfig can + // detect the absence of an explicit `client` and install the fetch shim. + const settings: AptosSettings = { network, fullnode: typeof url === 'string' && url.includes('://') ? url : undefined, // indexer: url.includes('://') ? `${url}/v1/graphql` : undefined, - }) - return this.fromAptosConfig(config, ctx) + } + return this.fromAptosConfig(settings, ctx) } /** {@inheritDoc Chain.getBlockInfo} */ @@ -677,7 +777,7 @@ export class AptosChain extends Chain { } /** {@inheritDoc Chain.getSupportedTokens} */ - async getSupportedTokens(address: string, opts?: { page?: number }): Promise { + async getSupportedTokens(address: string, opts?: Pick): Promise { const res = [] let page, nextKey = '0x0', diff --git a/ccip-sdk/src/canton/client/client.test.ts b/ccip-sdk/src/canton/client/client.test.ts new file mode 100644 index 00000000..c4d8e9fa --- /dev/null +++ b/ccip-sdk/src/canton/client/client.test.ts @@ -0,0 +1,82 @@ +/** + * Unit tests for createCantonClient fetch-adapter threading. + * + * Verifies that: + * - When `fetch` is supplied in config, HTTP traffic is routed through it. + * - The shared createAxiosFetchAdapter helper is used (same underlying helper as TON). + */ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createCantonClient } from './client.ts' +import { createAxiosFetchAdapter } from '../../fetch.ts' + +const BASE_URL = 'http://localhost:7575' +const JWT = 'test-jwt' + +/** + * Build a spy fetch that returns a 200 JSON response. + */ +function makeFetchSpy(body: unknown = {}): { + spy: typeof fetch + calls: Array<{ url: string }> +} { + const calls: Array<{ url: string }> = [] + const spy: typeof fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input) + calls.push({ url }) + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + return { spy, calls } +} + +describe('canton/client — custom fetch threading', () => { + it('createAxiosFetchAdapter produces a callable adapter from a fetch spy', () => { + // Verify the shared helper is importable and returns an adapter function + const { spy } = makeFetchSpy() + const adapter = createAxiosFetchAdapter(spy) + assert.equal(typeof adapter, 'function') + }) + + it('routes requests through custom fetch when provided (isAlive → GET /livez)', async () => { + const { spy, calls } = makeFetchSpy() + + const client = createCantonClient({ baseUrl: BASE_URL, jwt: JWT, fetch: spy }) + // isAlive() issues GET /livez through get2 → request → adapter + const alive = await client.isAlive() + + assert.ok(alive, 'expected isAlive() to return true with spy fetch returning 200') + assert.ok(calls.length >= 1, `expected at least 1 call to spy fetch, got ${calls.length}`) + assert.ok( + calls[0]!.url.includes('/livez'), + `expected /livez to be fetched, got: ${calls[0]!.url}`, + ) + }) + + it('custom fetch with abort signal is routed through spy', async () => { + const ac = new AbortController() + const { spy, calls } = makeFetchSpy() + + const client = createCantonClient({ + baseUrl: BASE_URL, + jwt: JWT, + fetch: spy, + signal: ac.signal, + }) + await client.isAlive() + + assert.ok(calls.length >= 1, 'expected fetch spy to be called when abort signal is set') + void spy + }) + + it('fetch is optional — omitting it does not break createCantonClient()', () => { + // No fetch → no fetchAdapter; the client falls back to cantonHttp (HTTP/2). + // We only assert construction succeeds; network calls are not made here. + const client = createCantonClient({ baseUrl: BASE_URL, jwt: JWT }) + assert.equal(typeof client.isAlive, 'function', 'client should expose isAlive method') + assert.equal(typeof client.isReady, 'function', 'client should expose isReady method') + }) +}) diff --git a/ccip-sdk/src/canton/client/client.ts b/ccip-sdk/src/canton/client/client.ts index c21c64f4..620ef551 100644 --- a/ccip-sdk/src/canton/client/client.ts +++ b/ccip-sdk/src/canton/client/client.ts @@ -1,8 +1,9 @@ -import axios from 'axios' +import axios, { type AxiosAdapter } from 'axios' import type { components } from './generated/ledger-api.ts' import { CCIPError } from '../../errors/CCIPError.ts' import { CCIPErrorCode } from '../../errors/codes.ts' +import { createAxiosFetchAdapter } from '../../fetch.ts' // Canton JSON Ledger API requires HTTP/2. // On Node.js, axios uses its http adapter with native http2.connect(). @@ -79,6 +80,12 @@ export interface CantonClientConfig { timeout?: number /** Abort signal for cancelling in-flight requests (e.g., from Chain.abort) */ signal?: AbortSignal + /** + * Custom fetch implementation. When provided, routes all HTTP traffic through + * the fetch adapter instead of the default axios HTTP/2 transport. + * Omit to preserve the default HTTP/2 behaviour. + */ + fetch?: typeof fetch } /** @@ -89,6 +96,11 @@ export function createCantonClient(config: CantonClientConfig) { const headers = buildHeaders(config.jwt) const timeoutMs = config.timeout ?? 30_000 const signal = config.signal + // Build a fetch adapter only when the caller explicitly supplies a fetch function. + // When absent, the default HTTP/2 transport (cantonHttp) is used unchanged. + const fetchAdapter: AxiosAdapter | undefined = config.fetch + ? createAxiosFetchAdapter(config.fetch, signal) + : undefined // Internal helpers that capture baseUrl/headers/timeoutMs/signal for // cleaner call sites inside createCantonClient. @@ -107,6 +119,7 @@ export function createCantonClient(config: CantonClientConfig) { retries, undefined, signal, + fetchAdapter, ) const post2 = ( @@ -126,6 +139,7 @@ export function createCantonClient(config: CantonClientConfig) { retries, undefined, signal, + fetchAdapter, ) return { @@ -509,6 +523,7 @@ async function request( retries = DEFAULT_RETRY_COUNT, retryDelayMs = DEFAULT_RETRY_DELAY_MS, signal?: AbortSignal, + fetchAdapter?: AxiosAdapter, ): Promise { // Check if signal is already aborted before attempting any requests if (signal?.aborted) { @@ -530,6 +545,9 @@ async function request( signal, // Prevent axios from throwing on non-2xx so we can handle retries ourselves validateStatus: () => true, + // Route through the caller-supplied fetch adapter when present; otherwise + // cantonHttp's HTTP/2 transport is used (the safeHttp2Request path below). + ...(fetchAdapter ? { adapter: fetchAdapter } : {}), } as Parameters[0] response = await safeHttp2Request(requestConfig) } catch (err) { diff --git a/ccip-sdk/src/canton/index.ts b/ccip-sdk/src/canton/index.ts index 9411df87..76b09c64 100644 --- a/ccip-sdk/src/canton/index.ts +++ b/ccip-sdk/src/canton/index.ts @@ -106,6 +106,8 @@ export class CantonChain extends Chain { readonly tokenMetadataClient: TokenMetadataClient readonly indexerUrl: string readonly ccipParty: string + /** Custom fetch function supplied via ctx, used for indexer requests. Falls back to globalThis.fetch. */ + private readonly fetchFn: typeof fetch /** * Creates a new CantonChain instance. @@ -139,6 +141,7 @@ export class CantonChain extends Chain { this.tokenMetadataClient = tokenMetadataClient this.ccipParty = ccipParty this.indexerUrl = indexerUrl + this.fetchFn = ctx?.fetch ?? globalThis.fetch.bind(globalThis) } /** @@ -254,10 +257,12 @@ export class CantonChain extends Chain { ) } + const fetchFn = ctx.fetch const client = createCantonClient({ baseUrl: url, jwt: ctx.cantonConfig.jwt, signal: ctx.abort, + fetch: fetchFn, }) try { const alive = await client.isAlive() @@ -1202,7 +1207,7 @@ export class CantonChain extends Chain { const indexerMessageId = normalizeIndexerMessageId(request.message.messageId) const url = `${this.indexerUrl}/v1/verifierresults/${indexerMessageId}` - const res = await fetch(url) + const res = await this.fetchFn(url) if (!res.ok) { const body = await res.text() throw new CCIPError( diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index 0b378fc1..0efd38d4 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -122,6 +122,14 @@ function hasV3ExtraArgs(extraArgs: Partial | undefined): boolean { * ``` */ export type ChainContext = WithLogger & { + /** + * Custom fetch implementation. When provided, it is used verbatim for all HTTP + * requests on this chain and the built-in rate-limit/retry wrapper is NOT applied. + * When omitted, a default per-endpoint rate-limited fetch (with adaptive throttle, + * 429/Retry-After handling and retries) is installed automatically. + */ + fetch?: typeof fetch + /** * CCIP API client instance for lane information queries. * diff --git a/ccip-sdk/src/evm/fetch-injection.test.ts b/ccip-sdk/src/evm/fetch-injection.test.ts new file mode 100644 index 00000000..012a1296 --- /dev/null +++ b/ccip-sdk/src/evm/fetch-injection.test.ts @@ -0,0 +1,170 @@ +/** + * Tests proving the opt-out + default-on fetch injection rule for EVM and Solana chains. + * + * Rule: if ctx.fetch is provided, use it verbatim (no wrapping); + * if omitted, install createRateLimitedFetch automatically. + * + * Because createRateLimitedFetch and fetchProfileForUrl are named ESM exports they cannot + * be spied upon with mock.method after module load. Instead we test the behavioral contract: + * - when ctx.fetch is provided, that exact function receives the network request + * - when omitted, the connection/provider still works (default wrapping installed) + */ +import assert from 'node:assert/strict' +import { afterEach, describe, it, mock } from 'node:test' + +import { JsonRpcProvider } from 'ethers' + +import { createRateLimitedFetch, fetchProfileForUrl } from '../fetch.ts' +import { EVMChain } from './index.ts' +import { SolanaChain } from '../solana/index.ts' + +// --------------------------------------------------------------------------- +// EVM — _getProvider helper +// --------------------------------------------------------------------------- + +describe('EVMChain._getProvider fetch injection', () => { + afterEach(() => { + mock.restoreAll() + }) + + it('uses ctx.fetch verbatim: custom fetch receives the actual RPC request', async () => { + // Our custom fetch records calls + let callCount = 0 + const customFetch = mock.fn(async () => { + callCount++ + // Return a valid JSON-RPC response so ethers doesn't throw + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + const ac = new AbortController() + const provider = await EVMChain._getProvider('http://localhost:8545', { + fetch: customFetch as unknown as typeof fetch, + abort: ac.signal, + }) + + // Trigger a request to see if our fetch is used + try { + await provider.send('eth_chainId', []) + } catch { + // may fail due to response parsing, but the important thing is our fetch was called + } + ac.abort() + provider.destroy() + + assert.ok(callCount > 0, `custom fetch should have been called, got ${callCount} calls`) + }) + + it('returns a JsonRpcProvider for HTTP URLs', async () => { + const ac = new AbortController() + ac.abort() + + // Use a custom fetch that returns immediately to avoid hanging + const fastFetch = mock.fn( + async () => + new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const provider = await EVMChain._getProvider('https://eth-mainnet.example.com', { + fetch: fastFetch as unknown as typeof fetch, + abort: ac.signal, + }) + + assert.ok(provider instanceof JsonRpcProvider, 'should return JsonRpcProvider for http') + provider.destroy() + }) + + it('uses rate-limited fetch when ctx.fetch is NOT provided: does not throw', async () => { + // Without ctx.fetch, a rate-limited fetch is installed. The provider should be created + // without errors (no network call is made until a request is issued). + const ac = new AbortController() + ac.abort() + + const provider = await EVMChain._getProvider('https://eth-mainnet.example.com', { + abort: ac.signal, + }) + + assert.ok(provider instanceof JsonRpcProvider) + provider.destroy() + }) +}) + +// --------------------------------------------------------------------------- +// Solana — _getConnection helper +// --------------------------------------------------------------------------- + +describe('SolanaChain._getConnection fetch injection', () => { + it('installs custom fetch: connection is created with the provided fetch function', () => { + const customFetch = mock.fn(async () => new Response('{}', { status: 200 })) + + const connection = SolanaChain._getConnection('https://api.devnet.solana.com', { + fetch: customFetch as unknown as typeof fetch, + }) + + // The Connection object stores its fetch in _rpcWebSocket or config. We verify + // behaviorally: no exception thrown, and the connection object is created. + assert.ok(connection, 'connection should be created with custom fetch') + }) + + it('creates connection without error when ctx.fetch is omitted (rate-limited default)', () => { + // Should not throw — default rate-limited fetch is installed automatically + const connection = SolanaChain._getConnection('http://localhost:8899') + assert.ok(connection, 'connection should be created with default rate-limited fetch') + }) + + it('creates connection for public solana.com endpoint (profile-based rate limiting)', () => { + const connection = SolanaChain._getConnection('https://api.mainnet-beta.solana.com') + assert.ok( + connection, + 'connection should be created for solana.com with profile-based rate limiting', + ) + }) + + it('throws for invalid URL format', () => { + assert.throws( + () => SolanaChain._getConnection('ftp://invalid'), + /Invalid Solana RPC URL format/, + ) + }) +}) + +// --------------------------------------------------------------------------- +// Helper selection logic — unit test of the rule itself +// --------------------------------------------------------------------------- + +describe('fetch selection rule (unit)', () => { + it('verbatim custom fetch wins over rate-limited default', () => { + const customFetch = mock.fn() as unknown as typeof fetch + // Simulate the rule: ctx.fetch is defined → it wins + const ctx: { fetch?: typeof fetch } = { fetch: customFetch } + const result = ctx.fetch ?? createRateLimitedFetch({}) + assert.equal(result, customFetch, 'custom fetch should be selected verbatim') + }) + + it('createRateLimitedFetch is used when ctx.fetch is undefined', () => { + const ctx: { fetch?: typeof fetch } = {} + const wrapped = createRateLimitedFetch({}) + const result = ctx.fetch ?? wrapped + assert.equal(result, wrapped, 'rate-limited fetch should be selected when ctx.fetch is absent') + }) + + it('fetchProfileForUrl seeds toncenter.com paced', () => { + const profile = fetchProfileForUrl('https://toncenter.com/api/v2/jsonRPC') + assert.deepEqual(profile.seed, { limit: 1, windowMs: 1500 }) + }) + + it('fetchProfileForUrl leaves solana.com unseeded (header-driven)', () => { + const profile = fetchProfileForUrl('https://api.mainnet-beta.solana.com') + assert.equal(profile.seed, undefined) + }) + + it('fetchProfileForUrl returns empty opts for unknown hosts', () => { + const profile = fetchProfileForUrl('https://my-private-rpc.example.com') + assert.deepEqual(profile, {}) + }) +}) diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 8d81f2c4..92bdc34a 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -11,6 +11,7 @@ import { type TransactionRequest, type TransactionResponse, Contract, + FetchRequest, JsonRpcProvider, WebSocketProvider, ZeroAddress, @@ -69,6 +70,7 @@ import { decodeFinalityAllowed, encodeFinality, } from '../extra-args.ts' +import { fetchProfileForUrl } from '../fetch.ts' import { getDestTokenAmount } from '../gas.ts' import type { LeafHasher } from '../hasher/common.ts' import { decodeMessageV1 } from '../messages.ts' @@ -91,6 +93,7 @@ import { CCIPVersion, } from '../types.ts' import { + createRateLimitedFetch, decodeAddress, decodeOnRampAddress, encodeAddressToAny, @@ -374,7 +377,13 @@ export class EVMChain extends Chain { * @param url - WebSocket (wss://) or HTTP (https://) endpoint URL. * @returns A ready JSON-RPC provider. */ - static async _getProvider(url: string, abort?: AbortSignal): Promise { + static async _getProvider( + url: string, + ctx?: { abort?: AbortSignal; fetch?: typeof fetch } & Parameters< + typeof createRateLimitedFetch + >[1], + ): Promise { + const abort = ctx?.abort let providerReady: Promise if (url.startsWith('ws')) { const provider = new WebSocketProvider(url, undefined, { staticNetwork: true }) @@ -387,7 +396,23 @@ export class EVMChain extends Chain { .catch(reject) }) } else if (url.startsWith('http')) { - const provider = new JsonRpcProvider(url, undefined, { staticNetwork: true }) + const fetchFn = ctx?.fetch ?? createRateLimitedFetch(fetchProfileForUrl(url), ctx) + const req = new FetchRequest(url) + req.getUrlFunc = async (r, _signal) => { + const resp = await fetchFn(r.url, { + method: r.method || 'POST', + headers: Object.fromEntries(Object.entries(r.headers).map(([k, v]) => [k, String(v)])), + body: r.body ?? undefined, + }) + const headers: Record = {} + resp.headers.forEach((v, k) => { + headers[k] = v + }) + const body = new Uint8Array(await resp.arrayBuffer()) + return { statusCode: resp.status, statusMessage: resp.statusText, headers, body } + } + req.retryFunc = () => Promise.resolve(false) // our wrapper owns retries + const provider = new JsonRpcProvider(req, undefined, { staticNetwork: true }) abort?.addEventListener('abort', () => provider.destroy(), { once: true }) providerReady = Promise.resolve(provider) } else { @@ -429,7 +454,7 @@ export class EVMChain extends Chain { * ``` */ static async fromUrl(url: string, ctx?: ChainContext): Promise { - return this.fromProvider(await this._getProvider(url, ctx?.abort), ctx) + return this.fromProvider(await this._getProvider(url, ctx), ctx) } /** {@inheritDoc Chain.getBlockInfo} */ diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index bfdf84dc..34bcc3cf 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -71,6 +71,7 @@ import { type SVMExtraArgsV1, EVMExtraArgsV2Tag, } from '../extra-args.ts' +import { fetchProfileForUrl } from '../fetch.ts' import { getDestTokenAmount } from '../gas.ts' import type { LeafHasher } from '../hasher/common.ts' import { type NetworkInfo, ChainFamily, networkInfo } from '../networks.ts' @@ -311,8 +312,10 @@ export class SolanaChain extends Chain { * @returns Solana Connection instance. * @throws {@link CCIPDataFormatUnsupportedError} if URL format is invalid */ - static _getConnection(url: string, ctx?: WithLogger): Connection { - const { logger = console } = ctx ?? {} + static _getConnection( + url: string, + ctx?: WithLogger & { fetch?: typeof fetch; abort?: AbortSignal }, + ): Connection { if (!url.startsWith('http') && !url.startsWith('ws')) { throw new CCIPDataFormatUnsupportedError( `Invalid Solana RPC URL format (should be https://, http://, wss://, or ws://): ${url}`, @@ -320,10 +323,7 @@ export class SolanaChain extends Chain { } const config: ConnectionConfig = { commitment: 'confirmed' } - if (url.includes('.solana.com')) { - config.fetch = createRateLimitedFetch(undefined, ctx) // public nodes - logger.warn('Using rate-limited fetch for public solana nodes, commands may be slow') - } + config.fetch = ctx?.fetch ?? createRateLimitedFetch(fetchProfileForUrl(url), ctx) return new Connection(url, config) } diff --git a/ccip-sdk/src/sui/index.ts b/ccip-sdk/src/sui/index.ts index cb793e6e..12eed7d7 100644 --- a/ccip-sdk/src/sui/index.ts +++ b/ccip-sdk/src/sui/index.ts @@ -1,7 +1,7 @@ import { bcs } from '@mysten/sui/bcs' import type { Keypair } from '@mysten/sui/cryptography' import { SuiGraphQLClient } from '@mysten/sui/graphql' -import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc' +import { JsonRpcHTTPTransport, SuiJsonRpcClient } from '@mysten/sui/jsonRpc' import { Transaction } from '@mysten/sui/transactions' import { isValidSuiAddress, isValidTransactionDigest, normalizeSuiAddress } from '@mysten/sui/utils' import { type BytesLike, dataLength, hexlify, isBytesLike, isHexString } from 'ethers' @@ -32,7 +32,9 @@ import { CCIPTopicsInvalidError, } from '../errors/index.ts' import type { EVMExtraArgsV2, ExtraArgs, SVMExtraArgsV1, SuiExtraArgsV1 } from '../extra-args.ts' +import { fetchProfileForUrl } from '../fetch.ts' import type { LeafHasher } from '../hasher/common.ts' +import { type NetworkInfo, ChainFamily, networkInfo } from '../networks.ts' import { decodeMessage } from '../requests.ts' import { decodeMoveExtraArgs, getMoveAddress } from '../shared/bcs-codecs.ts' import { supportedChains } from '../supported-chains.ts' @@ -51,6 +53,7 @@ import type { WithLogger, } from '../types.ts' import { + createRateLimitedFetch, decodeAddress, decodeOnRampAddress, getDataBytes, @@ -59,7 +62,6 @@ import { } from '../utils.ts' import { generateUnsignedExecutePTB, signAndExecuteSuiTx } from './exec.ts' import type { CCIPMessage_V1_6_Sui, UnsignedSuiTx } from './types.ts' -import { type NetworkInfo, ChainFamily, networkInfo } from '../networks.ts' export type { UnsignedSuiTx } const DEFAULT_GAS_LIMIT = 1000000n @@ -124,8 +126,11 @@ export class SuiChain extends Chain { * @throws {@link CCIPError} if chain identifier is not supported */ static async fromUrl(url: string, ctx?: ChainContext): Promise { + const fetchFn = ctx?.fetch ?? createRateLimitedFetch(fetchProfileForUrl(url), ctx) + const transport = new JsonRpcHTTPTransport({ url, fetch: fetchFn }) + // Create a temporary client to detect the network (network name unknown yet) - const tempClient = new SuiJsonRpcClient({ url, network: url }) + const tempClient = new SuiJsonRpcClient({ transport, network: url }) // Get chain identifier from the client and map to network info format const rawChainId = await tempClient.getChainIdentifier().catch(() => null) @@ -153,7 +158,7 @@ export class SuiChain extends Chain { ) } - const client = new SuiJsonRpcClient({ url, network: suiNetwork }) + const client = new SuiJsonRpcClient({ transport, network: suiNetwork }) const network = networkInfo(chainId) as NetworkInfo const chain = new SuiChain(client, network, ctx) return Object.assign(chain, { url }) diff --git a/ccip-sdk/src/ton/adapter.test.ts b/ccip-sdk/src/ton/adapter.test.ts new file mode 100644 index 00000000..ae959e37 --- /dev/null +++ b/ccip-sdk/src/ton/adapter.test.ts @@ -0,0 +1,63 @@ +/** + * Unit tests verifying that TONChain.fromUrl wires the shared + * createAxiosFetchAdapter helper and threads ctx.fetch correctly. + */ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createAxiosFetchAdapter } from '../fetch.ts' + +describe('TON fromUrl adapter wiring', () => { + it('createAxiosFetchAdapter is callable and returns a function', () => { + // Minimal smoke-test: the shared helper is importable and returns an adapter + const fakeFetch: typeof fetch = async () => new Response('{}', { status: 200 }) + const adapter = createAxiosFetchAdapter(fakeFetch) + assert.equal(typeof adapter, 'function') + }) + + it('createAxiosFetchAdapter wraps abort signal when provided', () => { + const fakeFetch: typeof fetch = async () => new Response('{}', { status: 200 }) + const ac = new AbortController() + const adapter = createAxiosFetchAdapter(fakeFetch, ac.signal) + assert.equal(typeof adapter, 'function') + // Adapter with abort differs from adapter without (wrapping adds an extra closure) + const adapterNoAbort = createAxiosFetchAdapter(fakeFetch) + assert.notEqual(adapter, adapterNoAbort) + }) + + it('ctx.fetch flows into fetchFn used by the adapter (via createAxiosFetchAdapter)', async () => { + // When a custom fetch is supplied, the adapter routes requests through it. + let called = 0 + const customFetch: typeof fetch = async () => { + called++ + return new Response(JSON.stringify({ ok: false, result: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + const abort = new AbortController() + const adapter = createAxiosFetchAdapter(customFetch, abort.signal) + + // Invoke the adapter to confirm customFetch is called. + try { + await adapter({ + method: 'POST', + url: 'https://example.com/jsonRPC', + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'getAddressInformation', + params: {}, + }), + timeout: 5000, + responseType: 'json', + } as unknown as Parameters[0]) + } catch { + // adapter may throw due to missing axios internals in unit context; that's OK + } + + assert.ok(called >= 1, `Expected customFetch to be called at least once, got ${called}`) + }) +}) diff --git a/ccip-sdk/src/ton/index.ts b/ccip-sdk/src/ton/index.ts index edde294b..e7b341e9 100644 --- a/ccip-sdk/src/ton/index.ts +++ b/ccip-sdk/src/ton/index.ts @@ -2,10 +2,14 @@ import { Buffer } from 'buffer' import { type Transaction, Address, Cell, beginCell, fromNano, toNano } from '@ton/core' import { TonClient } from '@ton/ton' -import { type AxiosAdapter, getAdapter } from 'axios' import { type BytesLike, hexlify, isBytesLike, isHexString, toBeArray, toBeHex } from 'ethers' import { type Memoized, memoize } from 'micro-memoize' +import { + decodeLegacyEVMTONExtraArgs, + decodeTONExtraArgsCell, + encodeExtraArgsCell, +} from './extra-args.ts' import { streamTransactionsForAddress } from './logs.ts' import { generateUnsignedCcipSend, getFee as getFeeImpl } from './send.ts' import { @@ -17,6 +21,7 @@ import { type TokenTransferFeeOpts, Chain, } from '../chain.ts' +import { type UnsignedTONTx, isTONWallet } from './types.ts' import { CCIPArgumentInvalidError, CCIPExecutionReportChainMismatchError, @@ -28,8 +33,11 @@ import { CCIPTransactionNotFoundError, CCIPWalletInvalidError, } from '../errors/index.ts' +import type { CCIPMessage_V1_6_EVM } from '../evm/messages.ts' import type { EVMExtraArgsV2, ExtraArgs, SVMExtraArgsV1, SuiExtraArgsV1 } from '../extra-args.ts' +import { createAxiosFetchAdapter, fetchProfileForUrl } from '../fetch.ts' import type { LeafHasher } from '../hasher/common.ts' +import { type NetworkInfo, ChainFamily, networkInfo } from '../networks.ts' import { buildMessageForDest } from '../requests.ts' import { supportedChains } from '../supported-chains.ts' import { @@ -54,16 +62,8 @@ import { parseTypeAndVersion, } from '../utils.ts' import { generateUnsignedExecuteReport } from './exec.ts' -import { - decodeLegacyEVMTONExtraArgs, - decodeTONExtraArgsCell, - encodeExtraArgsCell, -} from './extra-args.ts' import { getTONLeafHasher } from './hasher.ts' -import { type UnsignedTONTx, isTONWallet } from './types.ts' import { crc32, lookupTxByRawHash, parseJettonContent } from './utils.ts' -import type { CCIPMessage_V1_6_EVM } from '../evm/messages.ts' -import { type NetworkInfo, ChainFamily, networkInfo } from '../networks.ts' export type { TONWallet, UnsignedTONTx } from './types.ts' /** @@ -149,9 +149,12 @@ export class TONChain extends Chain { return txs } - // Rate-limited fetch for TonCenter API (public tier: ~1 req/sec) + // Use caller-supplied fetch verbatim; fetchFn is a still-supported alias for back-compat. + // When neither is provided, fall back to a rate-limited default. this.rateLimitedFetch = - ctx?.fetchFn ?? createRateLimitedFetch({ maxRequests: 1, windowMs: 1500, maxRetries: 5 }, ctx) + ctx?.fetch ?? + ctx?.fetchFn ?? + createRateLimitedFetch({ seed: { limit: 1, windowMs: 1500 }, maxRetries: 5 }, ctx) this.getTransaction = memoize(this.getTransaction.bind(this), { async: true, @@ -215,37 +218,21 @@ export class TONChain extends Chain { const { logger = console } = ctx ?? {} if (!url.endsWith('/jsonRPC')) url += '/jsonRPC' - let fetchFn: typeof fetch | undefined - let httpAdapter: AxiosAdapter | undefined + // Resolve the fetch function: user-supplied verbatim, then rate-limited default. + const fetchFn: typeof fetch = ctx?.fetch ?? createRateLimitedFetch(fetchProfileForUrl(url), ctx) + // For known public providers, detect network from URL to avoid an API call during init // (free-tier endpoints are rate-limited and return transient 5xx errors). let isMainnetHint: boolean | undefined if (['toncenter.com', 'tonapi.io'].some((d) => url.includes(d))) { - logger.warn( - 'Public TONCenter API calls are rate-limited to ~1 req/sec, some commands may be slow', - ) - fetchFn = createRateLimitedFetch({ maxRequests: 1, windowMs: 1500, maxRetries: 5 }, ctx) - httpAdapter = (getAdapter as (name: string, config: object) => AxiosAdapter)('fetch', { - env: { fetch: fetchFn }, - }) // testnet.toncenter.com / testnet.tonapi.io → testnet; bare domain → mainnet isMainnetHint = !url.includes('testnet.') } - // Wrap the adapter (or the default 'http' adapter) so that every TonClient axios - // request inherits the abort signal. Without this, raceAc.abort() fires and prints - // "Aborting RPC race" but the in-flight axios socket has no signal to cancel against - // and stays alive in the keep-alive pool, preventing natural process exit. - if (ctx?.abort) { - const abort = ctx.abort - const base = httpAdapter ?? (getAdapter as (name: string) => AxiosAdapter)('http') - httpAdapter = (config) => - base({ - ...config, - signal: config.signal ? AbortSignal.any([config.signal as AbortSignal, abort]) : abort, - }) - } + // Always use the fetch adapter so our fetch function is used for all requests. + // Also merges ctx.abort into every request signal so raceAc.abort() cancels in-flight sockets. + const httpAdapter = createAxiosFetchAdapter(fetchFn, ctx?.abort) const client = new TonClient({ endpoint: url, httpAdapter }) try { From 7d1580e047f9cb3c4cf3d317891e4c779b59ac58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Mon, 8 Jun 2026 17:06:22 -0400 Subject: [PATCH 3/8] feat(evm): auto-paginate getLogs on range-too-large errors; drop --page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect "block range too large" RPC errors (Alchemy/Infura/QuickNode/erpc and a generic block-range matcher), learn the max range per endpoint, and transparently re-chunk — removing the need for the manual --page CLI flag. - CCIPLogRangeTooLargeError + parseLogRangeError (extracts max/suggested range) - per-endpoint learned range shared across getLogs calls - remove ccip-cli --page option (LogFilter.page kept as an optional override) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 + ccip-cli/README.md | 2 - ccip-cli/src/index.ts | 4 - ccip-sdk/src/errors/codes.ts | 1 + ccip-sdk/src/errors/index.ts | 1 + ccip-sdk/src/errors/specialized.ts | 32 +++++ ccip-sdk/src/evm/logs.test.ts | 216 +++++++++++++++++++++++++++++ ccip-sdk/src/evm/logs.ts | 135 +++++++++++++++--- 8 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 ccip-sdk/src/evm/logs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a732c3a..dc3444b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- SDK: `createRateLimitedFetch` wrapper applied by default, auto-detect and adapt to RPC rate limits +- EVM: `getLogs` auto-detect `page` / `log range` errors and retry with suggested or halvened ranges + ## [1.8.0] - 2026-06-05 - SDK: `checkSendMessage` method called at `getFee` time, checks source rate limits before sending diff --git a/ccip-cli/README.md b/ccip-cli/README.md index 69e90d9f..e715ad7f 100644 --- a/ccip-cli/README.md +++ b/ccip-cli/README.md @@ -141,8 +141,6 @@ ccip-cli send -- # lists all send options - `--format=pretty` (default): Human-readable tabular output - `--format=log`: Basic console logging, may show some more details (e.g. token addresses) - `--format=json`: Machine-readable JSON -- `--page=10000`: limits `eth_getLogs` (and others) pagination/scanning ranges (e.g. for RPCs which - don't support large ranges) - `--no-api`: Disable CCIP API integration (fully decentralized mode, RPC-only) - `--api=`: Use a custom CCIP API URL instead of the default `api.ccip.chain.link` diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index 51461675..485254f5 100755 --- a/ccip-cli/src/index.ts +++ b/ccip-cli/src/index.ts @@ -87,10 +87,6 @@ const globalOpts = { describe: 'enable debug logging', type: 'boolean', }, - page: { - type: 'number', - describe: 'getLogs page/range size', - }, api: { type: 'string', describe: 'CCIP API URL (use --no-api to disable, enabled by default)', diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index 83750bdc..6f9b099f 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -128,6 +128,7 @@ export const CCIPErrorCode = { LOGS_REQUIRES_START: 'LOGS_REQUIRES_START', LOGS_ADDRESS_REQUIRED: 'LOGS_ADDRESS_REQUIRED', TOPICS_INVALID: 'TOPICS_INVALID', + LOG_RANGE_TOO_LARGE: 'LOG_RANGE_TOO_LARGE', // Solana SOLANA_REF_ADDRESSES_NOT_FOUND: 'SOLANA_REF_ADDRESSES_NOT_FOUND', diff --git a/ccip-sdk/src/errors/index.ts b/ccip-sdk/src/errors/index.ts index d1a14787..f828c2ac 100644 --- a/ccip-sdk/src/errors/index.ts +++ b/ccip-sdk/src/errors/index.ts @@ -18,6 +18,7 @@ export { CCIPBlockNotFoundError, CCIPTransactionNotFoundError } from './speciali // Specialized errors - Logs export { + CCIPLogRangeTooLargeError, CCIPLogsAddressRequiredError, CCIPLogsRequiresStartError, CCIPLogsWatchRequiresFinalityError, diff --git a/ccip-sdk/src/errors/specialized.ts b/ccip-sdk/src/errors/specialized.ts index 34154fd2..86119b3d 100644 --- a/ccip-sdk/src/errors/specialized.ts +++ b/ccip-sdk/src/errors/specialized.ts @@ -1751,6 +1751,38 @@ export class CCIPLogsAddressRequiredError extends CCIPError { } } +/** + * Thrown when a getLogs request is rejected because the requested block range is too large. + * Transient: the caller should retry with a smaller range. + * + * @example + * ```typescript + * try { + * const logs = await chain.getLogs({ startBlock: 0, endBlock: 100000 }) + * } catch (error) { + * if (error instanceof CCIPLogRangeTooLargeError) { + * const { maxRange, suggestedRange } = error.context + * console.log(`Reduce range to ${maxRange} blocks`) + * } + * } + * ``` + */ +export class CCIPLogRangeTooLargeError extends CCIPError { + override readonly name = 'CCIPLogRangeTooLargeError' + /** Creates a log range too large error. */ + constructor( + info: { requestedRange?: number; maxRange?: number; suggestedRange?: [number, number] }, + options?: CCIPErrorOptions, + ) { + const maxRangeStr = info.maxRange !== undefined ? ` (max: ${info.maxRange})` : '' + super(CCIPErrorCode.LOG_RANGE_TOO_LARGE, `getLogs block range is too large${maxRangeStr}`, { + ...options, + isTransient: true, + context: { ...options?.context, ...info }, + }) + } +} + // Chain Family /** diff --git a/ccip-sdk/src/evm/logs.test.ts b/ccip-sdk/src/evm/logs.test.ts new file mode 100644 index 00000000..dfd26d09 --- /dev/null +++ b/ccip-sdk/src/evm/logs.test.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict' +import { beforeEach, describe, it } from 'node:test' + +import type { JsonRpcApiProvider, Log } from 'ethers' + +import { CCIPLogRangeTooLargeError } from '../errors/index.ts' +import { getEndpointLogRange, setEndpointLogRange } from '../fetch.ts' +import { getEvmLogs } from './logs.ts' + +/** Minimal fake log factory */ +function makeLog(blockNumber: number, index = 0): Log { + return { + blockNumber, + logIndex: index, + blockHash: `0x${'00'.repeat(31)}${blockNumber.toString(16).padStart(2, '0')}`, + transactionHash: `0x${'00'.repeat(32)}`, + transactionIndex: 0, + address: '0x0000000000000000000000000000000000000001', + topics: [], + data: '0x', + index, + removed: false, + } as unknown as Log +} + +/** Build a fake provider that throws a range error when span exceeds maxSpan, else returns logs. */ +function makeFakeProvider( + maxSpan: number, + logsPerChunk: number = 1, + url: string = 'https://fake-rpc.example.com/v2/key', +): JsonRpcApiProvider { + return { + _getConnection: () => ({ url }), + getBlock: async (tag: string | number) => { + const num = typeof tag === 'number' ? tag : 10_000 + return { number: num, timestamp: num * 12 } + }, + _getBlockTag: async (tag: string | number) => tag, + getLogs: async (filter: { fromBlock: number; toBlock: number }) => { + const span = filter.toBlock - filter.fromBlock + 1 + if (span > maxSpan) { + throw Object.assign( + new Error(`getLogs failed: up to a ${maxSpan} block range is allowed`), + { error: { code: -32005 } }, + ) + } + // Return logsPerChunk fake logs for the chunk + return Array.from({ length: logsPerChunk }, (_, i) => makeLog(filter.fromBlock, i)) + }, + on: () => {}, + off: () => {}, + once: (_event: unknown, cb: () => void) => { + // immediately fire so watch loop doesn't hang + setTimeout(cb, 0) + }, + } as unknown as JsonRpcApiProvider +} + +/** Minimal getBlockInfo helper. */ +const getBlockInfo = async (block: number | string) => { + const num = typeof block === 'number' ? block : 10_000 + return { number: num, timestamp: num * 12 } +} + +/** Drain an async iterator into an array. */ +async function collect(iter: AsyncIterable): Promise { + const result: T[] = [] + for await (const item of iter) result.push(item) + return result +} + +// ── Reset the endpoint registry between tests ────────────────────────────── +// The registry is module-level; we reset entries by writing a large sentinel +// and then overwriting with the actual test values. Easier: just use unique +// URLs per test so they don't interfere. + +describe('getEvmLogs — adaptive range pagination', () => { + beforeEach(() => { + // Use unique URLs per test to avoid cross-test state pollution + }) + + it('subdivides when getLogs throws a range error and returns all logs without gaps or dups', async () => { + const url = 'https://fake-rpc-a.example.com/rpc' + // Provider allows max 500 blocks per call; we request 1000 blocks + const provider = makeFakeProvider(500, 1, url) + + const logs = await collect( + getEvmLogs({ startBlock: 1000, endBlock: 2000 }, { provider, getBlockInfo, logger: console }), + ) + + // 1001 blocks total (1000–2000 inclusive), chunks of 500 → 3 chunks (500+500+1 blocks) + // logsPerChunk=1 so we get 1 log per 500-block chunk + the last partial chunk + assert.ok(logs.length >= 1, `expected at least 1 log, got ${logs.length}`) + // All logs should have blockTimestamp set + assert.ok(logs.every((l) => typeof l.blockTimestamp === 'number')) + }) + + it('persists learned maxRange to endpoint registry (setEndpointLogRange)', async () => { + const url = 'https://fake-rpc-b.example.com/rpc' + const provider = makeFakeProvider(300, 1, url) + + // Before first call — no entry + assert.equal(getEndpointLogRange(url), undefined) + + await collect( + getEvmLogs({ startBlock: 1, endBlock: 700 }, { provider, getBlockInfo, logger: console }), + ) + + // After first call, registry should have learned the max range + const learned = getEndpointLogRange(url) + assert.ok(learned !== undefined, 'expected a learned log range') + assert.ok(learned <= 300, `expected learned range <= 300, got ${learned}`) + }) + + it('second call starts at the smaller page (cross-instance learning)', async () => { + const url = 'https://fake-rpc-c.example.com/rpc' + const provider = makeFakeProvider(200, 1, url) + + // First call learns the range + await collect( + getEvmLogs({ startBlock: 1, endBlock: 500 }, { provider, getBlockInfo, logger: console }), + ) + const learnedAfterFirst = getEndpointLogRange(url) + assert.ok(learnedAfterFirst !== undefined) + + // Manually verify the registry is seeded: a second call should use the learned range + // We confirm by calling setEndpointLogRange and checking getEndpointLogRange + setEndpointLogRange(url, learnedAfterFirst, 'error') + assert.equal(getEndpointLogRange(url), learnedAfterFirst) + + // A fresh call with the same URL should start at learnedAfterFirst (not 10e3) + // We can't easily inspect the internal page, but we can confirm no extra errors occur + await collect( + getEvmLogs({ startBlock: 1, endBlock: 400 }, { provider, getBlockInfo, logger: console }), + ) + }) + + it('propagates non-range errors unchanged', async () => { + const url = 'https://fake-rpc-d.example.com/rpc' + const networkError = new Error('connection refused') + const provider = { + _getConnection: () => ({ url }), + getBlock: async () => ({ number: 100, timestamp: 1200 }), + getLogs: async () => { + throw networkError + }, + on: () => {}, + off: () => {}, + once: (_e: unknown, cb: () => void) => setTimeout(cb, 0), + } as unknown as JsonRpcApiProvider + + await assert.rejects( + () => + collect( + getEvmLogs({ startBlock: 1, endBlock: 100 }, { provider, getBlockInfo, logger: console }), + ), + (err: unknown) => err === networkError, + ) + }) + + it('honors an explicit filter.page as the initial page size', async () => { + const url = 'https://fake-rpc-e.example.com/rpc' + // Registry has no entry for this URL; provider allows 10000 blocks + const provider = makeFakeProvider(10_000, 1, url) + + // Explicit page=50 → should use 50-block chunks (not the default 10e3) + const calls: Array<{ fromBlock: number; toBlock: number }> = [] + const trackingProvider = { + ...provider, + getLogs: async (filter: { fromBlock: number; toBlock: number }) => { + calls.push({ fromBlock: filter.fromBlock, toBlock: filter.toBlock }) + return [makeLog(filter.fromBlock)] + }, + } as unknown as JsonRpcApiProvider + + await collect( + getEvmLogs( + { startBlock: 1, endBlock: 200, page: 50 }, + { provider: trackingProvider, getBlockInfo, logger: console }, + ), + ) + + // Each chunk should be <=50 blocks wide + for (const call of calls) { + const span = call.toBlock - call.fromBlock + 1 + assert.ok(span <= 50, `expected span<=50, got ${span} (fromBlock=${call.fromBlock})`) + } + assert.ok(calls.length >= 4, `expected at least 4 chunks, got ${calls.length}`) + }) + + it('throws CCIPLogRangeTooLargeError when subdivision is impossible (page=1 still fails)', async () => { + const url = 'https://fake-rpc-f.example.com/rpc' + // Provider rejects everything (no blocks allowed) + const provider = { + _getConnection: () => ({ url }), + getBlock: async () => ({ number: 100, timestamp: 1200 }), + getLogs: async () => { + const err = Object.assign(new Error('up to a 0 block range is allowed'), { + error: { code: -32005 }, + }) + throw err + }, + on: () => {}, + off: () => {}, + once: (_e: unknown, cb: () => void) => setTimeout(cb, 0), + } as unknown as JsonRpcApiProvider + + await assert.rejects( + () => + collect( + getEvmLogs({ startBlock: 1, endBlock: 100 }, { provider, getBlockInfo, logger: console }), + ), + CCIPLogRangeTooLargeError, + ) + }) +}) diff --git a/ccip-sdk/src/evm/logs.ts b/ccip-sdk/src/evm/logs.ts index 3abb4e4d..e8dc2dd9 100644 --- a/ccip-sdk/src/evm/logs.ts +++ b/ccip-sdk/src/evm/logs.ts @@ -3,14 +3,16 @@ import type { SetFieldType } from 'type-fest' import type { LogFilter } from '../chain.ts' import { + CCIPLogRangeTooLargeError, CCIPLogTopicsNotFoundError, CCIPLogsRequiresStartError, CCIPLogsWatchRequiresFinalityError, } from '../errors/index.ts' import type { FinalityRequested } from '../extra-args.ts' +import { getEndpointLogRange, parseLogRangeError, setEndpointLogRange } from '../fetch.ts' import { blockRangeGenerator, getSomeBlockNumberBefore, signalToPromise } from '../utils.ts' import { getAllFragmentsMatchingEvents } from './const.ts' -import type { WithLogger } from '../types.ts' +import type { Logger, WithLogger } from '../types.ts' /** Tags or values which can be used as `endBlock` in {@link EVMChain.getLogs} filter */ export type EVMEndBlockTag = FinalityRequested | 'latest' @@ -31,6 +33,89 @@ function isInvalidBlockRangesError( ) } +/** + * Derives a stable URL string from a JsonRpcApiProvider, or undefined if not obtainable. + * Tries `_getConnection()` which works for JsonRpcProvider (HTTP/HTTPS). + */ +function getProviderUrl(provider: JsonRpcApiProvider): string | undefined { + try { + const conn = (provider as { _getConnection?: () => { url: string } })._getConnection?.() + if (conn?.url) return conn.url + } catch { + // WebSocketProvider or other providers may not have _getConnection + } + return undefined +} + +/** + * Yields logs for a single [fromBlock, toBlock] range, subdividing adaptively on range errors. + * Mutates `pageBox` so caller can observe learned page size. + */ +async function* getLogsPaginated( + provider: JsonRpcApiProvider, + baseFilter: { address?: string | string[]; topics?: (string | string[] | null)[] }, + fromBlock: number, + toBlock: number, + pageBox: { value: number }, + url: string | undefined, + logger: Logger, +): AsyncGenerator { + const filter_ = { + fromBlock, + toBlock, + ...(baseFilter.address ? { address: baseFilter.address } : {}), + ...(baseFilter.topics?.length ? { topics: baseFilter.topics } : {}), + } + logger.debug('evm getLogs:', filter_) + try { + const logs = await provider.getLogs(filter_) + yield* logs + } catch (err) { + const rangeInfo = parseLogRangeError(err) + if (rangeInfo === null) throw err + + const currentSpan = toBlock - fromBlock + 1 + let newPage: number + if (rangeInfo.maxRange !== undefined) { + newPage = rangeInfo.maxRange + } else if (rangeInfo.suggestedRange !== undefined) { + newPage = rangeInfo.suggestedRange[1] - rangeInfo.suggestedRange[0] + 1 + } else { + newPage = Math.floor(currentSpan / 2) + } + // Clamp: must be >=1 and strictly less than currentSpan to make progress + newPage = Math.max(1, newPage) + if (newPage >= currentSpan) { + // Cannot subdivide further — surface a typed error + throw new CCIPLogRangeTooLargeError( + { requestedRange: currentSpan, ...rangeInfo }, + { cause: err instanceof Error ? err : undefined }, + ) + } + + logger.warn(`evm getLogs: range too large (span=${currentSpan}), shrinking page to ${newPage}`) + if (url !== undefined) setEndpointLogRange(url, newPage, 'error') + pageBox.value = Math.min(pageBox.value, newPage) + + // Re-chunk the same [fromBlock, toBlock] with the smaller page + for (const chunk of blockRangeGenerator({ + startBlock: fromBlock, + endBlock: toBlock, + page: newPage, + })) { + yield* getLogsPaginated( + provider, + baseFilter, + chunk.fromBlock, + chunk.toBlock, + pageBox, + url, + logger, + ) + } + } +} + /** * Implements Chain.getLogs for EVM. * Walks logs forward from `startBlock` or `startTime`; if neither is provided, throws. @@ -68,7 +153,14 @@ export async function* getEvmLogs( filter.topics = [Array.from(topics)] } - filter.page ??= 10e3 + // Determine endpoint URL for cross-instance log-range learning + const endpointUrl = getProviderUrl(provider) + + // Seed initial page: explicit user value > learned endpoint value > default 10e3 + filter.page ??= getEndpointLogRange(endpointUrl ?? 'unknown') ?? 10e3 + // Mutable box so getLogsPaginated can propagate learned page shrinks back to watch loop + const pageBox = { value: filter.page } + filter.endBlock ||= 'latest' const { number: endBlock } = (await provider.getBlock(filter.endBlock))! filter.startBlock ??= await getSomeBlockNumberBefore( @@ -79,36 +171,41 @@ export async function* getEvmLogs( ) let latestLogBlockNumber = filter.startBlock - 1 + const baseFilter = { + ...(filter.address ? { address: filter.address } : {}), + ...(filter.topics?.length ? { topics: filter.topics } : {}), + } + for (const blockRange of blockRangeGenerator({ ...filter, startBlock: filter.startBlock, endBlock, })) { - const filter_ = { - ...blockRange, - ...(filter.address ? { address: filter.address } : {}), - ...(filter.topics?.length ? { topics: filter.topics } : {}), + for await (const log of getLogsPaginated( + provider, + baseFilter, + blockRange.fromBlock, + blockRange.toBlock, + pageBox, + endpointUrl, + logger, + )) { + if (log.blockNumber > latestLogBlockNumber) latestLogBlockNumber = log.blockNumber + const log_ = Object.assign(log, { + blockTimestamp: (await ctx.getBlockInfo(log.blockNumber)).timestamp, + }) + yield log_ } - logger.debug('evm getLogs:', filter_) - const logs = await provider.getLogs(filter_) - if (logs.length) - latestLogBlockNumber = Math.max(latestLogBlockNumber, logs[logs.length - 1]!.blockNumber) - const logs_ = await Promise.all( - logs.map(async (l) => - Object.assign(l, { blockTimestamp: (await ctx.getBlockInfo(l.blockNumber)).timestamp }), - ), - ) - yield* logs_ } // watch mode, otherwise return let lastEvent while (filter.watch && (!(filter.watch instanceof AbortSignal) || !filter.watch.aborted)) { + const watchFrom = Math.max(latestLogBlockNumber, endBlock - pageBox.value) + 1 const filter_ = { - fromBlock: Math.max(latestLogBlockNumber, endBlock - filter.page) + 1, + fromBlock: watchFrom, toBlock: await provider._getBlockTag(filter.endBlock), - ...(filter.address ? { address: filter.address } : {}), - ...(filter.topics?.length ? { topics: filter.topics } : {}), + ...baseFilter, } logger.debug('evm watch getLogs:', { ...filter_, lastEvent }) const logs = await provider.getLogs(filter_).catch((err) => { From 0f4d6a96eee62872a41baa2a695089dd21563d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Mon, 8 Jun 2026 18:55:38 -0400 Subject: [PATCH 4/8] fix test --- ccip-sdk/src/aptos/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ccip-sdk/src/aptos/index.ts b/ccip-sdk/src/aptos/index.ts index 8e833ee0..eca0594d 100644 --- a/ccip-sdk/src/aptos/index.ts +++ b/ccip-sdk/src/aptos/index.ts @@ -104,12 +104,14 @@ function createAptosFetchClient(fetchFn: typeof fetch): Client { for (const [k, v] of Object.entries(req.headers ?? {})) { if (v != null) headers[k] = String(v) } - const contentType = req.contentType ?? 'application/json' + // The SDK moves contentType into headers before calling provider(), so + // req.contentType is undefined here. Check the header value directly. + const resolvedCT = headers['content-type'] ?? req.contentType ?? 'application/json' type FetchBody = NonNullable[1]>['body'] let body: FetchBody if (req.body != null) { - headers['content-type'] ??= contentType - body = contentType.includes('json') ? JSON.stringify(req.body) : (req.body as FetchBody) + headers['content-type'] ??= resolvedCT + body = resolvedCT.includes('json') ? JSON.stringify(req.body) : (req.body as FetchBody) } const resp = await fetchFn(url.toString(), { method: req.method, headers, body }) const text = await resp.text() From 3c0c2e9c1b432df1a2d8a36ba5b011ce14edd948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Mon, 8 Jun 2026 19:42:54 -0400 Subject: [PATCH 5/8] fix: handle non-URL inputs in endpointKey + add LOG_RANGE_TOO_LARGE recovery hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit endpointKey() now wraps new URL() in try/catch so block tags ('safe', 'latest') and other non-URL strings don't throw when passed as endpoint identifiers. Adds missing DEFAULT_RECOVERY_HINTS entry for LOG_RANGE_TOO_LARGE — the recovery.ts exhaustiveness check was failing CI. Co-Authored-By: Claude Sonnet 4.6 --- ccip-sdk/src/errors/recovery.ts | 2 ++ ccip-sdk/src/fetch.ts | 21 +++++++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ccip-sdk/src/errors/recovery.ts b/ccip-sdk/src/errors/recovery.ts index 86174537..402da525 100644 --- a/ccip-sdk/src/errors/recovery.ts +++ b/ccip-sdk/src/errors/recovery.ts @@ -175,6 +175,8 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { APTOS_TOPIC_INVALID: 'Invalid Aptos event topic. Ensure the topic matches a known CCIP event type.', + LOG_RANGE_TOO_LARGE: + 'The RPC endpoint limits getLogs block range. The SDK auto-paginates by halving the range; if this persists, the range cannot be subdivided further.', HTTP_ERROR: 'HTTP request failed. 429 indicates rate limiting.', RPC_NOT_FOUND: 'No RPC endpoint found. Configure an RPC URL.', TIMEOUT: diff --git a/ccip-sdk/src/fetch.ts b/ccip-sdk/src/fetch.ts index 52865216..3abad32d 100644 --- a/ccip-sdk/src/fetch.ts +++ b/ccip-sdk/src/fetch.ts @@ -180,15 +180,20 @@ const endpointRegistry = new Map() /** Derive a stable key from a fetch input (string | URL | Request). */ export function endpointKey(input: Parameters[0]): string { - let url: URL - if (typeof input === 'string') { - url = new URL(input) - } else if (input instanceof Request) { - url = new URL(input.url) - } else { - url = input + try { + let url: URL + if (typeof input === 'string') { + url = new URL(input) + } else if (input instanceof Request) { + url = new URL(input.url) + } else { + url = input + } + return url.origin + url.pathname + } catch { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return typeof input === 'string' ? input : String(input) } - return url.origin + url.pathname } function getOrCreateEndpoint( From 35df40e8569c82151cb22410fb128457261655b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Tue, 9 Jun 2026 07:10:22 -0400 Subject: [PATCH 6/8] evm: allow fetching v1.5 ramps configs without remote selectors --- ccip-sdk/src/evm/index.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 92bdc34a..fb8cca09 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -791,7 +791,7 @@ export class EVMChain extends Chain { } /** {@inheritDoc Chain.getOnRampConfig} */ - async getOnRampConfig(onRamp: string, destChainSelector: bigint) { + async getOnRampConfig(onRamp: string, destChainSelector?: bigint) { const [, version, typeAndVersion] = await this.typeAndVersion(onRamp) let onRampABI switch (version) { @@ -822,7 +822,7 @@ export class EVMChain extends Chain { ...dynamicConfig, priceRegistryConfig: await this._getFeeQuoterDest( dynamicConfig.priceRegistry, - destChainSelector, + staticConfig.destChainSelector, ), typeAndVersion, } @@ -836,7 +836,7 @@ export class EVMChain extends Chain { const [staticConfig, dynamicConfig, destChainConfigRaw] = await Promise.all([ resultToObject(contract.getStaticConfig()), resultToObject(contract.getDynamicConfig()), - contract.getDestChainConfig(destChainSelector), + contract.getDestChainConfig(destChainSelector!), ]) const [_, allowlistEnabled, router] = destChainConfigRaw const destChainConfig = { allowlistEnabled, router } @@ -845,7 +845,10 @@ export class EVMChain extends Chain { destChainSelector, ...dynamicConfig, ...resultToObject(destChainConfig), - feeQuoterConfig: await this._getFeeQuoterDest(dynamicConfig.feeQuoter, destChainSelector), + feeQuoterConfig: await this._getFeeQuoterDest( + dynamicConfig.feeQuoter, + destChainSelector!, + ), typeAndVersion, } } @@ -858,14 +861,17 @@ export class EVMChain extends Chain { const [staticConfig, dynamicConfig, destChainConfig] = await Promise.all([ resultToObject(contract.getStaticConfig()), resultToObject(contract.getDynamicConfig()), - resultToObject(contract.getDestChainConfig(destChainSelector)), + resultToObject(contract.getDestChainConfig(destChainSelector!)), ]) return { ...staticConfig, ...dynamicConfig, destChainSelector, ...destChainConfig, - feeQuoterConfig: await this._getFeeQuoterDest(dynamicConfig.feeQuoter, destChainSelector), + feeQuoterConfig: await this._getFeeQuoterDest( + dynamicConfig.feeQuoter, + destChainSelector!, + ), typeAndVersion, } } @@ -908,9 +914,11 @@ export class EVMChain extends Chain { } /** {@inheritDoc Chain.getOffRampConfig} */ - async getOffRampConfig(offRamp: string, sourceChainSelector: bigint) { + async getOffRampConfig(offRamp: string, sourceChainSelector?: bigint) { const [, version, typeAndVersion] = await this.typeAndVersion(offRamp) - const sourceFamily = networkInfo(sourceChainSelector).family + const sourceFamily = sourceChainSelector + ? networkInfo(sourceChainSelector).family + : ChainFamily.EVM let offRampABI, commitStoreABI switch (version) { case CCIPVersion.V1_2: @@ -966,7 +974,7 @@ export class EVMChain extends Chain { const [staticConfig, dynamicConfig, { onRamp, ...sourceChainConfig }] = await Promise.all([ resultToObject(contract.getStaticConfig()), resultToObject(contract.getDynamicConfig()), - resultToObject(contract.getSourceChainConfig(sourceChainSelector)), + resultToObject(contract.getSourceChainConfig(sourceChainSelector!)), ]) const onRamps = [] try { @@ -992,7 +1000,7 @@ export class EVMChain extends Chain { ) as unknown as TypedContract const [staticConfig, sourceChainConfig] = await Promise.all([ resultToObject(contract.getStaticConfig()), - resultToObject(contract.getSourceChainConfig(sourceChainSelector)), + resultToObject(contract.getSourceChainConfig(sourceChainSelector!)), ]) const onRamps = sourceChainConfig.onRamps.map((o) => decodeOnRampAddress(o, sourceFamily)) return { From 4ab98411d922d6a10a83aea1a8593c4ab833fe8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Tue, 9 Jun 2026 12:42:01 -0400 Subject: [PATCH 7/8] chore: bump selectors --- ccip-sdk/src/selectors.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ccip-sdk/src/selectors.ts b/ccip-sdk/src/selectors.ts index 302ee4d5..388f07f8 100644 --- a/ccip-sdk/src/selectors.ts +++ b/ccip-sdk/src/selectors.ts @@ -7,6 +7,7 @@ type Selectors = Record< readonly name?: string family: ChainFamily network_type: NetworkType + deprecated?: boolean } > @@ -198,12 +199,14 @@ const SELECTORS: Selectors = { selector: 17164792800244661392n, name: 'mint-mainnet', network_type: 'MAINNET', + deprecated: true, family: 'EVM', }, '195': { selector: 2066098519157881736n, name: 'ethereum-testnet-sepolia-xlayer-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '196': { @@ -270,6 +273,7 @@ const SELECTORS: Selectors = { selector: 3719320017875267166n, name: 'ethereum-mainnet-kroma-1', network_type: 'MAINNET', + deprecated: true, family: 'EVM', }, '259': { @@ -384,6 +388,7 @@ const SELECTORS: Selectors = { selector: 5059197667603797935n, name: 'janction-testnet-sepolia', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '682': { @@ -486,6 +491,7 @@ const SELECTORS: Selectors = { selector: 1948510578179542068n, name: 'bitcoin-testnet-bsquared-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '1135': { @@ -545,6 +551,7 @@ const SELECTORS: Selectors = { selector: 11059667695644972511n, name: 'ethereum-testnet-goerli-polygon-zkevm-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '1513': { @@ -563,6 +570,7 @@ const SELECTORS: Selectors = { selector: 10749384167430721561n, name: 'mint-testnet', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '1740': { @@ -617,6 +625,7 @@ const SELECTORS: Selectors = { selector: 13116810400804392105n, name: 'ronin-testnet-saigon', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '2023': { @@ -653,6 +662,7 @@ const SELECTORS: Selectors = { selector: 12168171414969487009n, name: 'memento-testnet', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '2201': { @@ -677,6 +687,7 @@ const SELECTORS: Selectors = { selector: 5990477251245693094n, name: 'ethereum-testnet-sepolia-kroma-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '2391': { @@ -695,6 +706,7 @@ const SELECTORS: Selectors = { selector: 8901520481741771655n, name: 'ethereum-testnet-holesky-fraxtal-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '2741': { @@ -707,6 +719,7 @@ const SELECTORS: Selectors = { selector: 8304510386741731151n, name: 'ethereum-testnet-holesky-morph-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '2818': { @@ -731,6 +744,7 @@ const SELECTORS: Selectors = { selector: 1467223411771711614n, name: 'bitcoin-testnet-botanix', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '3637': { @@ -833,6 +847,7 @@ const SELECTORS: Selectors = { selector: 2443239559770384419n, name: 'megaeth-testnet', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '6343': { @@ -965,12 +980,14 @@ const SELECTORS: Selectors = { selector: 16088006396410204581n, name: '0g-testnet-newton', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '16601': { selector: 2131427466778448014n, name: '0g-testnet-galileo', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '16602': { @@ -989,12 +1006,14 @@ const SELECTORS: Selectors = { selector: 7717148896336251131n, name: 'ethereum-testnet-holesky', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '25327': { selector: 9723842205701363942n, name: 'everclear-mainnet', network_type: 'MAINNET', + deprecated: true, family: 'EVM', }, '26888': { @@ -1067,6 +1086,7 @@ const SELECTORS: Selectors = { selector: 3963528237232804922n, name: 'tempo-testnet', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '42431': { @@ -1103,6 +1123,7 @@ const SELECTORS: Selectors = { selector: 3552045678561919002n, name: 'celo-testnet-alfajores', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '45439': { @@ -1145,6 +1166,7 @@ const SELECTORS: Selectors = { selector: 6473245816409426016n, name: 'memento-mainnet', network_type: 'MAINNET', + deprecated: true, family: 'EVM', }, '53302': { @@ -1157,6 +1179,7 @@ const SELECTORS: Selectors = { selector: 3676871237479449268n, name: 'sonic-testnet-blaze', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '57073': { @@ -1205,6 +1228,7 @@ const SELECTORS: Selectors = { selector: 5214452172935136222n, name: 'treasure-mainnet', network_type: 'MAINNET', + deprecated: true, family: 'EVM', }, '68414': { @@ -1240,18 +1264,21 @@ const SELECTORS: Selectors = { selector: 8999465244383784164n, name: 'berachain-testnet-bartio', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '80085': { selector: 12336603543561911511n, name: 'berachain-testnet-artio', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '80087': { selector: 2285225387454015855n, name: 'zero-g-testnet-galileo', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '80094': { @@ -1329,12 +1356,14 @@ const SELECTORS: Selectors = { selector: 1910019406958449359n, name: 'etherlink-testnet', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '129399': { selector: 9090863410735740267n, name: 'polygon-testnet-tatara', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '167000': { @@ -1347,6 +1376,7 @@ const SELECTORS: Selectors = { selector: 7248756420937879088n, name: 'ethereum-testnet-holesky-taiko-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '167012': { @@ -1449,6 +1479,7 @@ const SELECTORS: Selectors = { selector: 4012524741200567430n, name: 'pharos-testnet', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '688689': { @@ -1521,18 +1552,21 @@ const SELECTORS: Selectors = { selector: 10443705513486043421n, name: 'ethereum-testnet-sepolia-arbitrum-1-treasure-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '978658': { selector: 3676916124122457866n, name: 'treasure-testnet-topaz', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '978670': { selector: 1010349088906777999n, name: 'ethereum-mainnet-arbitrum-1-treasure-1', network_type: 'MAINNET', + deprecated: true, family: 'EVM', }, '2019775': { @@ -1617,6 +1651,7 @@ const SELECTORS: Selectors = { selector: 2027362563942762617n, name: 'ethereum-testnet-sepolia-blast-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '728126428': { From ecc3a3974a7955f26bd4a021cf9a472cfaa92562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Tue, 9 Jun 2026 11:17:08 -0400 Subject: [PATCH 8/8] fixup! evm: allow fetching v1.5 ramps configs without remote selectors --- ccip-sdk/src/evm/fork.test.ts | 36 +++++++++++++++++------------------ ccip-sdk/src/fetch.test.ts | 26 +++++++++++++++++++++++++ ccip-sdk/src/fetch.ts | 17 ++++++++++++++--- ccip-sdk/src/ton/index.ts | 2 +- 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/ccip-sdk/src/evm/fork.test.ts b/ccip-sdk/src/evm/fork.test.ts index 0e51ccb4..09ccb6a5 100644 --- a/ccip-sdk/src/evm/fork.test.ts +++ b/ccip-sdk/src/evm/fork.test.ts @@ -26,7 +26,7 @@ const SEPOLIA_CHAIN_ID = 11155111 const SEPOLIA_SELECTOR = 16015286601757825753n const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' -const FUJI_RPC = process.env['RPC_FUJI'] || 'https://api.avax-test.network/ext/bc/C/rpc' +const FUJI_RPC = process.env['RPC_FUJI'] || 'https://avalanche-fuji-c-chain-rpc.publicnode.com' const FUJI_CHAIN_ID = 43113 const ARB_SEP_RPC = process.env['RPC_ARB_SEPOLIA'] || 'https://arbitrum-sepolia-rpc.publicnode.com' @@ -138,24 +138,22 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { timeout: 60_000, forkRetryBackoff: 1_000, } as const - sepoliaInstance = Instance.anvil({ - forkUrl: SEPOLIA_RPC, - chainId: SEPOLIA_CHAIN_ID, - port: 8646, - ...forkOpts, - }) - fujiInstance = Instance.anvil({ - forkUrl: FUJI_RPC, - chainId: FUJI_CHAIN_ID, - port: 8645, - ...forkOpts, - }) - arbSepInstance = Instance.anvil({ - forkUrl: ARB_SEP_RPC, - chainId: ARB_SEP_CHAIN_ID, - port: 8644, - ...forkOpts, - }) + // Pass {} as prool options so no startup timer is set on the Instance. + // Without the second arg, prool extracts timeout from the first arg (Anvil CLI params), + // sets a 60s timer, and after stop() resets startResolver, the timer fires on the new + // unlistened resolver → unhandled rejection → test file fails even when tests pass. + sepoliaInstance = Instance.anvil( + { forkUrl: SEPOLIA_RPC, chainId: SEPOLIA_CHAIN_ID, port: 8646, ...forkOpts }, + {}, + ) + fujiInstance = Instance.anvil( + { forkUrl: FUJI_RPC, chainId: FUJI_CHAIN_ID, port: 8645, ...forkOpts }, + {}, + ) + arbSepInstance = Instance.anvil( + { forkUrl: ARB_SEP_RPC, chainId: ARB_SEP_CHAIN_ID, port: 8644, ...forkOpts }, + {}, + ) await Promise.all([sepoliaInstance.start(), fujiInstance.start(), arbSepInstance.start()]) const sepoliaProvider = new JsonRpcProvider( diff --git a/ccip-sdk/src/fetch.test.ts b/ccip-sdk/src/fetch.test.ts index 1a258ecf..dcba870c 100644 --- a/ccip-sdk/src/fetch.test.ts +++ b/ccip-sdk/src/fetch.test.ts @@ -664,6 +664,32 @@ describe('adaptive limiting', () => { assert.ok(Date.now() - t0 < 4000, `expected burst+retry (no pacing), took ${Date.now() - t0}ms`) }) + it('seeded (TON-like) limiter doubles window on consecutive header-less 429s', async () => { + let calls = 0 + globalThis.fetch = mock.fn(() => { + calls++ + if (calls <= 3) { + return Promise.resolve({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: new Headers(), + } as Response) + } + return Promise.resolve(ok()) + }) + // seed at 10ms (simulates TON's 1.5s but fast enough for a unit test) + const f = createRateLimitedFetch({ seed: { limit: 1, windowMs: 10 } }) + const url = 'https://ton-backoff.example.com/rpc' + const t0 = Date.now() + await f(url, rpc('m', 0)) + const elapsed = Date.now() - t0 + // window doubles each 429: 10→20→40→80ms. Total pacing wait ≥ 70ms. + // Without doubling: only 3×10ms = 30ms. So ≥ 50ms distinguishes the two. + assert.ok(elapsed >= 50, `expected exponential window doubling, took ${elapsed}ms`) + assert.equal(calls, 4) + }) + it('caps concurrent in-flight requests per endpoint, flushing as each completes', async () => { let inFlight = 0 let maxObserved = 0 diff --git a/ccip-sdk/src/fetch.ts b/ccip-sdk/src/fetch.ts index 3abad32d..7f5bdcfb 100644 --- a/ccip-sdk/src/fetch.ts +++ b/ccip-sdk/src/fetch.ts @@ -134,9 +134,20 @@ class AdaptiveLimiter { if (at > now) await sleep(at - now) } - /** On a 429: activate + pace ONLY when an explicit reset window is known. */ + /** On a 429: activate + pace ONLY when an explicit reset window is known. + * For already-active (seeded) limiters with no reset hint, back off by doubling + * the window so retries space out exponentially instead of hammering at fixed pace. */ onLimited(hint: { limit?: number; windowMs?: number }): void { - if (hint.windowMs == null) return // no window → caller just retries + backs off + if (hint.windowMs == null) { + // No explicit reset window. Inactive limiters (e.g. Solana) rely on jittered backoff + // in the retry loop. Active (seeded) limiters — like TON — double the pacing window + // so each consecutive 429 waits twice as long before the next attempt. + if (this.active) { + this.windowMs = clampWindow(this.windowMs * 2) + this.lastLimitTs = Date.now() + } + return + } this.limit = Math.max(1, hint.limit ?? this.limit) this.windowMs = clampWindow(hint.windowMs) this.lastLimitTs = Date.now() @@ -374,7 +385,7 @@ export function fetchProfileForUrl(url: string): Partial { hostname === 'tonapi.io' || hostname.endsWith('.tonapi.io') ) { - return { seed: { limit: 1, windowMs: 1500 }, maxRetries: 5 } + return { seed: { limit: 1, windowMs: 1500 }, maxRetries: 6 } } // Public Solana: no proactive seed. Its responses carry precise per-method // limit headers (`x-ratelimit-method-*`), so the adaptive limiter learns the diff --git a/ccip-sdk/src/ton/index.ts b/ccip-sdk/src/ton/index.ts index e7b341e9..28a4982f 100644 --- a/ccip-sdk/src/ton/index.ts +++ b/ccip-sdk/src/ton/index.ts @@ -154,7 +154,7 @@ export class TONChain extends Chain { this.rateLimitedFetch = ctx?.fetch ?? ctx?.fetchFn ?? - createRateLimitedFetch({ seed: { limit: 1, windowMs: 1500 }, maxRetries: 5 }, ctx) + createRateLimitedFetch({ seed: { limit: 1, windowMs: 1500 }, maxRetries: 6 }, ctx) this.getTransaction = memoize(this.getTransaction.bind(this), { async: true,