From 2cd21c7b757de06405a19acd6ea8e142acdd864c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Sat, 5 Sep 2026 13:28:12 -0400 Subject: [PATCH 1/5] fix(sdk): leaks caused by AbortSignal --- ccip-sdk/src/evm/index.ts | 17 +++- ccip-sdk/src/fetch.test.ts | 203 ++++++++++++++++++++++++++++++++++++- ccip-sdk/src/fetch.ts | 109 +++++++++++++++----- ccip-sdk/src/utils.test.ts | 110 ++++++++++++++++++++ ccip-sdk/src/utils.ts | 77 ++++++++++++-- 5 files changed, 479 insertions(+), 37 deletions(-) diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 50820dcb7..a3da8c1f0 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -115,6 +115,7 @@ import { getAddressBytes, getBlockNumberAtOrAfter, getDataBytes, + linkAbortSignals, parseTypeAndVersion, } from '../utils.ts' import type Token_ABI from './abi/BurnMintERC677Token.ts' @@ -566,21 +567,29 @@ export class EVMChain extends Chain { // 90s comfortably exceeds any legitimate slow call (a chunked // eth_getLogs under active pacing). const timeoutSignal = AbortSignal.timeout(90_000) - let requestSignal: AbortSignal = timeoutSignal + // The cancel bridge and the 90s bound are followed through a LINK, not + // composed into a fresh AbortSignal.any composite: a composite over a + // kTimeout source is never listened to directly (undici attaches to the + // downstream merge), so its following never activates and it pins in + // Node's gcPersistentSignals for the process's lifetime — one composite + // per RPC request. The `using` link detaches on return instead (see + // linkAbortSignals). + let linkSource: AbortSignal | undefined if (signal) { const cancel = new AbortController() try { signal.addListener(() => cancel.abort()) - requestSignal = AbortSignal.any([cancel.signal, timeoutSignal]) + linkSource = cancel.signal } catch { - requestSignal = AbortSignal.abort() // already cancelled by ethers + linkSource = AbortSignal.abort() // already cancelled by ethers } } + using link = linkSource ? linkAbortSignals([linkSource, timeoutSignal]) : null 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, - signal: requestSignal, + signal: link?.signal ?? timeoutSignal, }) const headers: Record = {} resp.headers.forEach((v, k) => { diff --git a/ccip-sdk/src/fetch.test.ts b/ccip-sdk/src/fetch.test.ts index 33c3301ca..031f5d403 100644 --- a/ccip-sdk/src/fetch.test.ts +++ b/ccip-sdk/src/fetch.test.ts @@ -1,11 +1,16 @@ import assert from 'node:assert/strict' +import { getEventListeners } from 'node:events' import { afterEach, beforeEach, describe, it, mock } from 'node:test' +import { setFlagsFromString } from 'node:v8' +import { runInNewContext } from 'node:vm' +import { CCIPAbortError, CCIPTimeoutError } from './errors/index.ts' import { createAxiosFetchAdapter, createRateLimitedFetch, endpointKey, fetchProfileForUrl, + fetchWithTimeout, getEndpointLogRange, getEndpointTopicLimit, originKey, @@ -702,14 +707,18 @@ describe('createRateLimitedFetch', () => { ) assert.equal(result.ok, true) assert.equal(seenSignals.length, 3) - // No per-attempt re-wrap: attempts 1..3 must all see the SAME merged signal. + // No per-attempt re-wrap: attempts 1..3 must all see the SAME linked signal. assert.ok(seenSignals[0]) assert.equal(seenSignals[1], seenSignals[0]) assert.equal(seenSignals[2], seenSignals[0]) - // It must still reflect BOTH sources (composite semantics preserved). + // The (bodiless) responses settled, so the link already detached: a later + // source abort must not propagate into a completed request — that permanent + // coupling is exactly what pinned composites on long-lived sources. assert.equal(seenSignals[0]!.aborted, false) + assert.equal(getEventListeners(callerAc.signal, 'abort').length, 0) + assert.equal(getEventListeners(ctxAc.signal, 'abort').length, 0) callerAc.abort() - assert.equal(seenSignals[0]!.aborted, true) + assert.equal(seenSignals[0]!.aborted, false) // Without a per-request signal, the ctx abort itself is passed through // verbatim (no composite is created at all). @@ -731,6 +740,26 @@ describe('createRateLimitedFetch', () => { )('https://rl-test-signal-once2.example.com') assert.equal(callCount, 1) assert.equal(seenSignals[0], ctxAc.signal) + + // In flight, the linked signal still reflects EITHER source aborting. + globalThis.fetch = mockedFetch = mock.fn( + (_input: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal + // like undici: an already-aborted signal rejects immediately + if (signal.aborted) return reject(signal.reason) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }), + ) + const callerAc2 = new AbortController() + const ctxAc2 = new AbortController() + const pending = createRateLimitedFetch({}, { abort: ctxAc2.signal })( + 'https://rl-test-signal-inflight.example.com', + { signal: callerAc2.signal }, + ) + // after the limiter/semaphore microtasks, when the fetch is truly in flight + setTimeout(() => callerAc2.abort(), 0) + await assert.rejects(pending, /aborted/i) }) it('should handle network errors with retry logic', async () => { @@ -1220,3 +1249,171 @@ describe('redactEndpointUrl', () => { assert.ok(flat.includes('https://ton-gateway.example.com/api/v2')) }) }) + +// --------------------------------------------------------------------------- +// abort-signal lifetime: linked signals detach when the response body settles +// --------------------------------------------------------------------------- + +/** Exposes V8's GC for this process (node --test does not pass --expose-gc). */ +function forceGc(): () => void { + const direct = globalThis.gc as (() => void) | undefined + if (direct) return () => void direct() + setFlagsFromString('--expose_gc') + return runInNewContext('gc') as () => void +} + +describe('fetchWithTimeout abort lifetime', () => { + it('detaches the caller signal once the body is consumed', async () => { + const caller = new AbortController() + let seen: AbortSignal | undefined + const stubFetch = mock.fn(async (_input: unknown, init?: RequestInit) => { + seen = init?.signal as AbortSignal + return new Response('{"ok":true}') + }) + const res = await fetchWithTimeout('https://example.com/x', 'test', { + fetch: stubFetch as unknown as typeof fetch, + signal: caller.signal, + }) + // linked while the body is still streaming + assert.equal(getEventListeners(caller.signal, 'abort').length, 1) + assert.ok(seen && seen !== caller.signal, 'fetch must receive the linked signal') + assert.equal(await res.text(), '{"ok":true}') + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(getEventListeners(caller.signal, 'abort').length, 0) + }) + + it('keeps propagating aborts while the body streams', async () => { + const caller = new AbortController() + let fail: (reason: unknown) => void = () => {} + const stubFetch = mock.fn(async (_input: unknown, init?: RequestInit) => { + const signal = init?.signal as AbortSignal + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('part1')) + fail = (reason) => controller.error(reason) + }, + }) + // what undici does: error the body when the request signal fires + signal.addEventListener('abort', () => fail(signal.reason), { once: true }) + return new Response(body) + }) + const res = await fetchWithTimeout('https://example.com/stream', 'test', { + fetch: stubFetch as unknown as typeof fetch, + signal: caller.signal, + }) + const reader = res.body!.getReader() + assert.deepEqual((await reader.read()).value, new TextEncoder().encode('part1')) + const stop = new Error('stop') + caller.abort(stop) + await assert.rejects(reader.read(), (err: unknown) => err === stop) + // a fired link detaches itself + assert.equal(getEventListeners(caller.signal, 'abort').length, 0) + }) + + it('maps a stalled request to CCIPTimeoutError and cleans up', async () => { + const caller = new AbortController() + const stubFetch = mock.fn( + (_input: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }), + ) + await assert.rejects( + fetchWithTimeout('https://example.com/slow', 'op', { + fetch: stubFetch as unknown as typeof fetch, + signal: caller.signal, + timeoutMs: 20, + }), + (err: unknown) => err instanceof CCIPTimeoutError, + ) + assert.equal(getEventListeners(caller.signal, 'abort').length, 0) + }) + + it('maps a caller abort to CCIPAbortError', async () => { + const caller = new AbortController() + const stubFetch = mock.fn( + (_input: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }), + ) + const pending = fetchWithTimeout('https://example.com/never', 'op', { + fetch: stubFetch as unknown as typeof fetch, + signal: caller.signal, + timeoutMs: 60_000, + }) + caller.abort() + await assert.rejects(pending, (err: unknown) => err instanceof CCIPAbortError) + assert.equal(getEventListeners(caller.signal, 'abort').length, 0) + }) +}) + +describe('createRateLimitedFetch abort lifetime', () => { + let originalFetch: typeof fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('detaches merged caller and ctx signals once the body settles', async () => { + const ctx = new AbortController() + const caller = new AbortController() + globalThis.fetch = mock.fn(async () => new Response('{"ok":true}')) + const rateLimitedFetch = createRateLimitedFetch({}, { abort: ctx.signal }) + const res = await rateLimitedFetch('https://rl-abort-life-1.example.com', { + signal: caller.signal, + }) + assert.equal(getEventListeners(ctx.signal, 'abort').length, 1) + assert.equal(getEventListeners(caller.signal, 'abort').length, 1) + assert.equal(await res.text(), '{"ok":true}') + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(getEventListeners(ctx.signal, 'abort').length, 0) + assert.equal(getEventListeners(caller.signal, 'abort').length, 0) + }) + + it('detaches immediately on a terminal error', async () => { + const ctx = new AbortController() + const caller = new AbortController() + globalThis.fetch = mock.fn(async () => { + throw new Error('permanent failure') + }) + const rateLimitedFetch = createRateLimitedFetch({}, { abort: ctx.signal }) + await assert.rejects( + rateLimitedFetch('https://rl-abort-life-2.example.com', { signal: caller.signal }), + /permanent failure/, + ) + assert.equal(getEventListeners(ctx.signal, 'abort').length, 0) + assert.equal(getEventListeners(caller.signal, 'abort').length, 0) + }) + + it('releases caller-created nested composites once the body settles (GC)', async () => { + // The pre-fix deployed evm getUrlFunc shape: a bare AbortSignal.any + // composite over a kTimeout source, never listened to directly — pinned in + // gcPersistentSignals for the process's lifetime (.repro-abort S1). The + // downstream link's attach/detach must release even that (S7). + const gc = forceGc() + const ctx = new AbortController() + globalThis.fetch = mock.fn(async () => new Response('{"ok":true}')) + const rateLimitedFetch = createRateLimitedFetch({}, { abort: ctx.signal }) + const N = 20 + const callers: WeakRef[] = [] + for (let i = 0; i < N; i++) { + const caller = AbortSignal.any([new AbortController().signal, AbortSignal.timeout(60_000)]) + callers.push(new WeakRef(caller)) + const res = await rateLimitedFetch('https://rl-abort-life-3.example.com', { signal: caller }) + await res.text() + } + for (let i = 0; i < 8; i++) gc() + await new Promise((resolve) => setImmediate(resolve)) + for (let i = 0; i < 8; i++) gc() + // The final iteration's bindings can stay reachable from the frame. + const alive = callers.filter((r) => r.deref()).length + assert.ok(alive <= 1, `caller composites still reachable: ${alive}/${N}`) + }) +}) diff --git a/ccip-sdk/src/fetch.ts b/ccip-sdk/src/fetch.ts index 885c8b023..442dd7d2d 100644 --- a/ccip-sdk/src/fetch.ts +++ b/ccip-sdk/src/fetch.ts @@ -7,7 +7,7 @@ import { isTransientHttpStatus, } from './errors/index.ts' import type { WithLogger } from './types.ts' -import { sleep } from './utils.ts' +import { linkAbortSignals, sleep } from './utils.ts' /** * Tuning for the rate-limited fetch wrapper. @@ -627,6 +627,34 @@ export function redactEndpointUrl(input: unknown): string { } } +/** + * Returns a Response whose body runs `onDone` exactly once when it is fully + * consumed, errors, or is cancelled — whichever comes first. Used to keep + * linked abort signals attached for exactly the body's lifetime: aborts and + * timeouts keep propagating while the body streams, and the moment it settles + * the sources are detached (see linkAbortSignals). A Response without a body + * runs `onDone` immediately and is returned unchanged. + */ +export function onResponseBodySettled(response: Response, onDone: () => void): Response { + const body = response.body + if (!body) { + onDone() + return response + } + const { readable, writable } = new TransformStream() + // pipeTo resolves on full consumption and rejects on source error or + // downstream cancel; all three mean the body no longer needs the signals. + void body.pipeTo(writable).then(onDone, onDone) + const wrapped = new Response(readable, response) + // Wrapping drops url/redirected/type; copy them back. + Object.defineProperties(wrapped, { + url: { value: response.url }, + redirected: { value: response.redirected }, + type: { value: response.type }, + }) + return wrapped +} + /** * 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) @@ -683,22 +711,35 @@ export function createRateLimitedFetch( ) // Merge the caller's per-request signal with the context abort ONCE, before - // the retry loop: wrapping per attempt would nest a fresh composite over the - // previous attempt's (depth = retry count), and every wrapper that never - // aborts keeps its abort listener registered (Node holds such composites in - // its gcPersistentSignals set for as long as any source lives). One composite - // per request keeps undici's listener attach/detach churn flat too. - if (init?.signal && abort) init.signal = AbortSignal.any([init.signal, abort]) - else if (abort) { + // the retry loop: linking per attempt would re-register on the sources per + // attempt. The caller's signal may itself be a composite (e.g. ethers' + // timeout bundle), so follow rather than compose: a fresh AbortSignal.any + // composite over a caller-provided kTimeout composite can pin forever in + // Node's gcPersistentSignals, while the link's attach/detach even releases + // such caller-created pins (see linkAbortSignals). The link stays attached + // while the returned response's body streams (aborts still propagate + // mid-read) and detaches when the body settles or the request errors. + let link: ReturnType | null = null + if (init?.signal && abort) { + link = linkAbortSignals([init.signal, abort]) + init.signal = link.signal + } else if (abort) { if (!init) init = {} init.signal = abort } + // Returned responses carry the link's cleanup on their body. + const finish = (response: Response): Response => + link ? onResponseBodySettled(response, link.unlink) : response + for (let attempt = 0; attempt <= opts_.maxRetries; attempt++) { // Bail out promptly when the caller aborts (e.g. a per-request timeout): // don't burn further attempts/backoff/pacing under a dead signal. The waits // below (pacing + backoff) are also abort-aware so an in-progress one wakes. - abort?.throwIfAborted() + if (abort?.aborted) { + link?.unlink() // terminal exit: no response body will carry the cleanup + abort.throwIfAborted() + } // 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 : '*' @@ -755,7 +796,10 @@ export function createRateLimitedFetch( 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 (!isRetryableError(lastError)) { + link?.unlink() // terminal exit: no response body will carry the cleanup + 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). @@ -780,7 +824,7 @@ export function createRateLimitedFetch( response.status, init?.body ? bodyStr(init.body) : redactEndpointUrl(input), ) - return response + return finish(response) } if (isTransientHttpStatus(response.status)) { if (attempt < opts_.maxRetries) { @@ -789,7 +833,7 @@ export function createRateLimitedFetch( continue } logger.debug('fetch transient error, retries exhausted', response.status) - return response + return finish(response) } // Non-transient non-ok (4xx etc): return immediately, no retry. logger.debug( @@ -798,9 +842,10 @@ export function createRateLimitedFetch( response.status, bodyStr(init?.body), ) - return response + return finish(response) } + link?.unlink() // retries exhausted: no response body will carry the cleanup throw lastError || CCIPError.from('Request failed after all retries', 'HTTP_ERROR') } } @@ -811,7 +856,7 @@ export function createRateLimitedFetch( * * 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 + * it is linked (see `linkAbortSignals`) 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`). @@ -830,11 +875,22 @@ export function createAxiosFetchAdapter(fetchFn: typeof fetch, abort?: AbortSign env: { fetch: fetchFn }, }) if (!abort) return base - return (config) => - base({ - ...config, - signal: config.signal ? AbortSignal.any([config.signal as AbortSignal, abort]) : abort, - }) + return (config) => { + if (!config.signal) return base({ ...config, signal: abort }) + // Link rather than compose with AbortSignal.any (see linkAbortSignals): + // axios's fetch adapter consumes the response body before its promise + // settles, so unlinking on settle detaches exactly when the request is + // done with the signals. + const link = linkAbortSignals([config.signal as AbortSignal, abort]) + let result: ReturnType + try { + result = base({ ...config, signal: link.signal }) + } catch (error) { + link.unlink() + throw error + } + return result.finally(link.unlink) + } } /** @@ -863,14 +919,19 @@ export async function fetchWithTimeout( ): 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 + // Follow the caller's signal and bound the request with AbortSignal.timeout + // WITHOUT composing them into a fresh AbortSignal.any composite: a composite + // over a kTimeout source can pin in Node's gcPersistentSignals long after the + // request completed (see linkAbortSignals). The link stays attached while the + // body streams and detaches when it settles; the bare timeout signal is + // timer-bounded and cleans itself up when it fires. + const link = linkAbortSignals([opts?.signal, AbortSignal.timeout(timeoutMs)]) try { - return await fetchFn(url, { ...opts?.init, signal: combinedSignal }) + const response = await fetchFn(url, { ...opts?.init, signal: link.signal }) + return onResponseBodySettled(response, link.unlink) } catch (error) { + link.unlink() if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { if (opts?.signal?.aborted) { throw new CCIPAbortError(operation) diff --git a/ccip-sdk/src/utils.test.ts b/ccip-sdk/src/utils.test.ts index c0d4820c3..dea6ec3b5 100644 --- a/ccip-sdk/src/utils.test.ts +++ b/ccip-sdk/src/utils.test.ts @@ -1,5 +1,8 @@ import assert from 'node:assert/strict' +import { getEventListeners } from 'node:events' import { describe, it, mock } from 'node:test' +import { setFlagsFromString } from 'node:v8' +import { runInNewContext } from 'node:vm' import { NATIVE_MINT } from '@solana/spl-token' import { dataLength } from 'ethers' @@ -20,6 +23,7 @@ import { jsonParse, jsonStringify, leToBigInt, + linkAbortSignals, parseTypeAndVersion, passesTypeAndVersion, scaleDecimals, @@ -1508,3 +1512,109 @@ describe('scaleDecimals', () => { assert.equal(scaleDecimals(1_999_999_999n, 18, 9), 1n) }) }) + +// --------------------------------------------------------------------------- +// AbortSignal utilities: no gcPersistentSignals pins, deterministic detach +// --------------------------------------------------------------------------- + +/** Exposes V8's GC for this process (node --test does not pass --expose-gc). */ +function forceGc(): () => void { + const direct = globalThis.gc as (() => void) | undefined + if (direct) return () => void direct() + setFlagsFromString('--expose_gc') + return runInNewContext('gc') as () => void +} + +describe('linkAbortSignals', () => { + it('fires with the source reason when any source aborts', () => { + const a = new AbortController() + const b = new AbortController() + const link = linkAbortSignals([a.signal, b.signal]) + assert.equal(link.signal.aborted, false) + const err = new Error('boom') + a.abort(err) + assert.equal(link.signal.aborted, true) + assert.equal(link.signal.reason, err) + // a fired link is terminal: it detaches from the other source immediately + assert.equal(getEventListeners(b.signal, 'abort').length, 0) + }) + + it('stops propagating after unlink and leaves no listeners behind', () => { + const a = new AbortController() + const link = linkAbortSignals([a.signal]) + link.unlink() + assert.equal(getEventListeners(a.signal, 'abort').length, 0) + a.abort() + assert.equal(link.signal.aborted, false) + }) + + it('is aborted from the start when a source already aborted', () => { + const a = new AbortController() + a.abort() + const link = linkAbortSignals([undefined, a.signal]) + assert.equal(link.signal.aborted, true) + assert.ok(link.signal.reason instanceof DOMException) + assert.equal((link.signal.reason as DOMException).name, 'AbortError') + }) + + it('supports manual abort with a custom reason', () => { + const link = linkAbortSignals([]) + link.abort('because') + assert.equal(link.signal.aborted, true) + assert.equal(link.signal.reason, 'because') + }) +}) + +describe('linkAbortSignals GC behavior', () => { + it('releases caller-created pinned composites on unlink', async () => { + // The pinned shape (.repro-abort S1): a bare AbortSignal.any composite over + // a kTimeout source, never listened to directly. The link's attach/detach + // must release even that (S7: listener-count drop is a set exit condition). + const gc = forceGc() + const N = 20 + const refs: WeakRef[] = [] + for (let i = 0; i < N; i++) { + const caller = AbortSignal.any([new AbortController().signal, AbortSignal.timeout(60_000)]) + refs.push(new WeakRef(caller)) + const link = linkAbortSignals([caller]) + link.unlink() + } + for (let i = 0; i < 8; i++) gc() + await new Promise((resolve) => setImmediate(resolve)) + for (let i = 0; i < 8; i++) gc() + // The final iteration's bindings can stay reachable from the frame. + const alive = refs.filter((r) => r.deref()).length + assert.ok(alive <= 1, `caller composites still reachable: ${alive}/${N}`) + }) + + it('is Disposable: `using` disposes the link at scope exit', () => { + const a = new AbortController() + let linkedSignal!: AbortSignal + { + using link = linkAbortSignals([a.signal]) + linkedSignal = link.signal + assert.equal(getEventListeners(a.signal, 'abort').length, 1) + } + assert.equal(getEventListeners(a.signal, 'abort').length, 0) + a.abort() + assert.equal(linkedSignal.aborted, false) + }) +}) + +describe('sleep abort hygiene', () => { + it('leaves no listener on a long-lived signal after waking', async () => { + const controller = new AbortController() + for (let i = 0; i < 20; i++) await sleep(1, controller.signal) + assert.equal(getEventListeners(controller.signal, 'abort').length, 0) + }) + + it('wakes early on abort and detaches', async () => { + const controller = new AbortController() + const start = Date.now() + const pending = sleep(60_000, controller.signal) + setTimeout(() => controller.abort(), 10) + await pending + assert.ok(Date.now() - start < 5_000, 'sleep returned before its full duration') + assert.equal(getEventListeners(controller.signal, 'abort').length, 0) + }) +}) diff --git a/ccip-sdk/src/utils.ts b/ccip-sdk/src/utils.ts index 8e2dca9ff..72c815d51 100644 --- a/ccip-sdk/src/utils.ts +++ b/ccip-sdk/src/utils.ts @@ -388,21 +388,29 @@ export function convertKeysToCamelCase( /** * Promise-based sleep utility. - * AbortSignal.timeout is unref'd on purpose; a script using it should be wrapped - * in a setTimeout to avoid the process exiting mid-sleep. + * Plain timer + explicit listener detach: allocates no AbortSignal.timeout/any + * composites at all, so nothing can pin in Node's gcPersistentSignals, and the + * abort listener is removed on both wake paths — no reliance on Node's lazy + * composite following, on any runtime. The timer is unref'd on purpose (as + * AbortSignal.timeout was); a script using it should hold another handle + * (e.g. a setTimeout) to avoid the process exiting mid-sleep. * @param ms - Duration in milliseconds. * @returns Promise that resolves after the specified duration. */ export const sleep = (ms: number, abort?: AbortSignal): Promise => new Promise((resolve) => { if (abort?.aborted || !ms) return resolve() - let timeout = AbortSignal.timeout(Math.ceil(ms)) - if (abort) timeout = AbortSignal.any([abort, timeout]) const onAbort = () => { - timeout.removeEventListener('abort', onAbort) + clearTimeout(timeout) resolve() } - timeout.addEventListener('abort', onAbort, { once: true }) + const timeout = setTimeout(() => { + // Happy path: detach so a long-lived `abort` retains nothing per sleep. + abort?.removeEventListener('abort', onAbort) + resolve() + }, Math.ceil(ms)) + timeout.unref() + abort?.addEventListener('abort', onAbort, { once: true }) }) /** @@ -613,6 +621,63 @@ export async function passesTypeAndVersion( } } +/** + * Follows one or more caller-provided signals with a plain AbortController, + * instead of composing them with `AbortSignal.any`. This is the DOWNSTREAM-side + * pattern for functions that receive signals of unknown provenance. + * + * Why not `AbortSignal.any` downstream: a composite over a kTimeout source (an + * `AbortSignal.timeout`, or another composite containing one) is pinned + * STRONGLY in Node's `gcPersistentSignals` set, and — because composite + * following is lazy and only activates when the composite itself gets a + * listener — a composite nobody listens to never aborts and never leaves the + * set, even after its sources abort or the operation completes (measured: + * 500/500 retained on Node 22.23/24.19/26.7; see repro-nested.mjs). Linking + * creates no composite at all, and the strong-listener attach + detach even + * releases caller-created pinned composites (listener-count drop is an exit + * condition of the set), so legacy caller shapes are cleaned up too. + * + * @param sources - Signals to follow; undefined entries are ignored. + * @returns `signal` to hand to fetch & co, `abort` to fire it directly, and + * `unlink` to detach from the sources. Every link MUST be disposed when the + * operation settles: the Disposable contract supports `using` when the + * link's lifetime is lexical; the `unlink` member covers cases where cleanup + * is forwarded elsewhere (e.g. a response body's settle hook). + */ +export function linkAbortSignals(sources: readonly (AbortSignal | undefined)[]): { + signal: AbortSignal + abort: (reason?: unknown) => void + unlink: () => void +} & Disposable { + const controller = new AbortController() + const linked: AbortSignal[] = [] + const unlink = (): void => { + for (const source of linked) source.removeEventListener('abort', onAbort) + linked.length = 0 + } + // `function` so `this` is the firing source. Once the link fires it is + // terminal, so detach from the other sources immediately. + const onAbort = function (this: AbortSignal): void { + unlink() + controller.abort(this.reason) + } + for (const source of sources) { + if (!source) continue + if (source.aborted) { + controller.abort(source.reason) + break + } + source.addEventListener('abort', onAbort, { once: true }) + linked.push(source) + } + return { + signal: controller.signal, + abort: controller.abort.bind(controller), + unlink, + [Symbol.dispose]: unlink, + } +} + /** * Converts an AbortSignal into a Promise that rejects when the signal is aborted. * From 0e5b79935b82492e297568f55e2dc51410a658b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Tue, 8 Sep 2026 11:17:47 -0400 Subject: [PATCH 2/5] chore: bump deps --- ccip-api-ref/package.json | 2 +- ccip-cli/package.json | 2 +- ccip-sdk/package.json | 6 +++--- package-lock.json | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ccip-api-ref/package.json b/ccip-api-ref/package.json index 4a8cf51ad..4ba418b8e 100644 --- a/ccip-api-ref/package.json +++ b/ccip-api-ref/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@docusaurus/module-type-aliases": "^3.10.2", "@docusaurus/types": "^3.10.2", - "@types/react-dom": "^19.2.4", + "@types/react-dom": "^19.2.5", "@typescript/native": "npm:typescript@7.0.2", "docusaurus-plugin-typedoc": "^1.4.2", "typedoc": "^0.28.20", diff --git a/ccip-cli/package.json b/ccip-cli/package.json index 17d51b197..5c73b76fd 100644 --- a/ccip-cli/package.json +++ b/ccip-cli/package.json @@ -58,7 +58,7 @@ "@ledgerhq/hw-app-aptos": "6.37.0", "@ledgerhq/hw-app-solana": "7.9.0", "@ledgerhq/hw-transport-node-hid": "6.32.0", - "@mysten/sui": "^2.23.2", + "@mysten/sui": "^2.26.2", "@solana/web3.js": "^1.98.4", "@ton-community/ton-ledger": "^7.3.0", "@ton/crypto": "^3.3.0", diff --git a/ccip-sdk/package.json b/ccip-sdk/package.json index b0c536111..905d12324 100644 --- a/ccip-sdk/package.json +++ b/ccip-sdk/package.json @@ -63,13 +63,13 @@ "ethers-abitype": "1.0.3", "prool": "^0.2.14", "typescript": "7.0.2", - "viem": "^2.55.13" + "viem": "^2.55.19" }, "dependencies": { "@aptos-labs/ts-sdk": "^7.3.0", "@coral-xyz/anchor": "^0.29.0", - "@mysten/bcs": "^2.1.0", - "@mysten/sui": "^2.23.2", + "@mysten/bcs": "^2.1.1", + "@mysten/sui": "^2.26.2", "@noble/hashes": "^2.3.0", "@solana/spl-token": "0.4.15", "@solana/web3.js": "^1.98.4", diff --git a/package-lock.json b/package-lock.json index 3435d0ade..37e98671c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "devDependencies": { "@docusaurus/module-type-aliases": "^3.10.2", "@docusaurus/types": "^3.10.2", - "@types/react-dom": "^19.2.4", + "@types/react-dom": "^19.2.5", "@typescript/native": "npm:typescript@7.0.2", "docusaurus-plugin-typedoc": "^1.4.2", "typedoc": "^0.28.20", @@ -93,7 +93,7 @@ "@ledgerhq/hw-app-aptos": "6.37.0", "@ledgerhq/hw-app-solana": "7.9.0", "@ledgerhq/hw-transport-node-hid": "6.32.0", - "@mysten/sui": "^2.23.2", + "@mysten/sui": "^2.26.2", "@solana/web3.js": "^1.98.4", "@ton-community/ton-ledger": "^7.3.0", "@ton/crypto": "^3.3.0", @@ -157,8 +157,8 @@ "dependencies": { "@aptos-labs/ts-sdk": "^7.3.0", "@coral-xyz/anchor": "^0.29.0", - "@mysten/bcs": "^2.1.0", - "@mysten/sui": "^2.23.2", + "@mysten/bcs": "^2.1.1", + "@mysten/sui": "^2.26.2", "@noble/hashes": "^2.3.0", "@solana/spl-token": "0.4.15", "@solana/web3.js": "^1.98.4", @@ -182,7 +182,7 @@ "ethers-abitype": "1.0.3", "prool": "^0.2.14", "typescript": "7.0.2", - "viem": "^2.55.13" + "viem": "^2.55.19" }, "peerDependencies": { "viem": "^2.0.0" From 5e60a1e3d1e42f06e6da5780f137361781e28855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Tue, 8 Sep 2026 15:22:29 -0400 Subject: [PATCH 3/5] solana: support reading v1 txs --- CHANGELOG.md | 1 + ccip-cli/package.json | 2 +- ccip-cli/src/providers/solana.ts | 3 +- ccip-sdk/package.json | 2 +- ccip-sdk/src/solana/index.ts | 4 +- ccip-sdk/src/solana/logs.integration.test.ts | 74 ++++++++++++++++++ package-lock.json | 79 ++++++++++++-------- 7 files changed, 128 insertions(+), 37 deletions(-) create mode 100644 ccip-sdk/src/solana/logs.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5792632..bcaa6f594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Solana: `getTransaction`/`getLogs` now work on clusters emitting version-1 transactions (Agave 4.x devnet and later), via the `@solana/web3.js` 1.99.0 bump (v1 transaction read support); all dependents already accept `^1.99.0`, so no `overrides` needed and anchor stays at 0.29 - Tests: the whole suite runs as one parallel `node --test` invocation — networked e2e/integration suites moved to disjoint low-activity lanes/fixtures with per-network endpoint sets configurable via `RPC_*` env vars (one per network, comma-separated lists allowed, wired to CI secrets), so suites never contend on a rate-limited endpoint and the full run finishes in ~5min - Aptos and Sui now support detecting execution failures — bundled Sui fixes: deep-history `getLogs` walks ascending checkpoint slices instead of paging from the tip, empty `getOwnedObjects` pointer lookups are memoized instead of retried for ~30s, and `offRamp` receipt filters no longer drop successful Aptos/Sui receipts diff --git a/ccip-cli/package.json b/ccip-cli/package.json index 5c73b76fd..597f6d59a 100644 --- a/ccip-cli/package.json +++ b/ccip-cli/package.json @@ -59,7 +59,7 @@ "@ledgerhq/hw-app-solana": "7.9.0", "@ledgerhq/hw-transport-node-hid": "6.32.0", "@mysten/sui": "^2.26.2", - "@solana/web3.js": "^1.98.4", + "@solana/web3.js": "^1.99.0", "@ton-community/ton-ledger": "^7.3.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^16.3.0", diff --git a/ccip-cli/src/providers/solana.ts b/ccip-cli/src/providers/solana.ts index 11984ee10..d9dda2842 100644 --- a/ccip-cli/src/providers/solana.ts +++ b/ccip-cli/src/providers/solana.ts @@ -12,6 +12,7 @@ import HIDTransport from '@ledgerhq/hw-transport-node-hid' import { type Message, type MessageV0, + type MessageV1, type VersionedTransaction, Keypair, PublicKey, @@ -72,7 +73,7 @@ export class LedgerSolanaWallet { this.logger.debug('Ledger: Request to sign message from', this.publicKey.toBase58()) // serializeMessage on v0, serialize on v1 - let msg: Message | MessageV0 + let msg: Message | MessageV0 | MessageV1 if (tx instanceof Transaction) { msg = tx.compileMessage() } else { diff --git a/ccip-sdk/package.json b/ccip-sdk/package.json index 905d12324..33a04b654 100644 --- a/ccip-sdk/package.json +++ b/ccip-sdk/package.json @@ -72,7 +72,7 @@ "@mysten/sui": "^2.26.2", "@noble/hashes": "^2.3.0", "@solana/spl-token": "0.4.15", - "@solana/web3.js": "^1.98.4", + "@solana/web3.js": "^1.99.0", "@ton/core": "0.63.1", "@ton/ton": "^16.3.0", "abitype": "1.3.0", diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index e631441b6..dbeb52df7 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -403,7 +403,7 @@ export class SolanaChain extends Chain { async getTransaction(hash: string): Promise { const tx = await this.connection.getTransaction(hash, { commitment: 'confirmed', - maxSupportedTransactionVersion: 0, + maxSupportedTransactionVersion: 1, }) if (!tx) throw new CCIPTransactionNotFoundError(hash, { context: { network: this.network.name } }) @@ -826,7 +826,7 @@ export class SolanaChain extends Chain { const sigs = await this.connection.getSignaturesForAddress(marker, { limit: 10 }) for (const { signature } of sigs) { const tx = await this.connection.getTransaction(signature, { - maxSupportedTransactionVersion: 0, + maxSupportedTransactionVersion: 1, commitment: 'confirmed', }) if (!tx) continue diff --git a/ccip-sdk/src/solana/logs.integration.test.ts b/ccip-sdk/src/solana/logs.integration.test.ts new file mode 100644 index 000000000..feee9892e --- /dev/null +++ b/ccip-sdk/src/solana/logs.integration.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { before, describe, it } from 'node:test' + +// Register every chain family the way SDK consumers do via the package root +import '../index.ts' +import { rpcEndpoint } from '../../../scripts/test-endpoints.ts' +import { useResource } from '../../../scripts/useResource.ts' +import { networkInfo } from '../networks.ts' +import { hexDiscriminator } from './utils.ts' +import { SolanaChain } from './index.ts' + +// Live RPC: solana-devnet. Devnet runs Agave 4.x, which emits version-1 +// transactions (version byte bumped, message layout still v0-compatible); +// @solana/web3.js >= 1.99.0 adds v1 transaction read support (its TransactionVersion +// struct and VersionedMessage deserialization accept version 1 and build a MessageV1). +// The fixture below is a real router tx (CcipSend, Solana → TON testnet). +// Override via RPC_SOLANA_DEVNET. +await useResource(['solana-devnet']) +const SOLANA_RPC = rpcEndpoint('RPC_SOLANA_DEVNET') + +const skip = !!process.env.SKIP_INTEGRATION_TESTS + +describe('Solana devnet v1 transaction logs', { skip, timeout: 120_000 }, () => { + // A real router transaction (CcipSend, Solana → TON testnet) on the Agave 4.x + // devnet: transaction version 1, with the CCIPMessageSent event emitted as an + // anchor "Program data:" log. + const V1_TX = + '4bNhirt1ekTBac7pmNsGuwvzZWu3jLYEtJWDMSNmytzwjxYzBBU9M3TeRUS58nQ6LtJtwB5ue9NrsGpzM3vA4hfk' + const V1_TX_SLOT = 494_724_511 + const ROUTER = 'Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C' + const MESSAGE_SENT = hexDiscriminator('CCIPMessageSent') + const MESSAGE_ID = '0x77f89a907830b14988ce1a5675b77007521d2410dd0fb31227238d349dfb874b' + const TON_TESTNET_SELECTOR = networkInfo('ton-testnet').chainSelector + + let chain: SolanaChain + before(async () => { + chain = await SolanaChain.fromUrl(SOLANA_RPC) + }) + + it('getTransaction parses a version-1 transaction', async () => { + const tx = await chain.getTransaction(V1_TX) + assert.equal(tx.blockNumber, V1_TX_SLOT) + assert.ok(tx.logs.length > 0, 'the tx carries parsed logs') + const sender = tx.logs.find((log) => log.address === ROUTER && log.type === 'data') + assert.ok(sender, 'the router emits a CCIPMessageSent anchor event log') + assert.equal(sender.topics[0], MESSAGE_SENT) + }) + + it('getLogs streams the CCIPMessageSent event from the version-1 transaction', async () => { + const logs = [] + for await (const log of chain.getLogs({ + address: ROUTER, + topics: ['CCIPMessageSent'], + startBlock: V1_TX_SLOT - 10, + endBlock: V1_TX_SLOT, + })) { + logs.push(log) + } + const event = logs.find((log) => log.transactionHash === V1_TX) + assert.ok(event, 'the v1 tx event is streamed by getLogs') + assert.equal(event.address, ROUTER) + assert.equal(event.topics[0], MESSAGE_SENT) + }) + + it('getMessagesInTx decodes the message carried by the version-1 transaction', async () => { + const requests = await chain.getMessagesInTx(V1_TX) + assert.equal(requests.length, 1) + const message = requests[0]!.message + assert.equal(message.messageId, MESSAGE_ID) + if (!('destChainSelector' in message)) throw new Error('unexpected message variant') + assert.equal(message.destChainSelector, TON_TESTNET_SELECTOR) + assert.match(String(message.data), /^0x417262206d7367/, 'the payload starts with "Arb msg"') + }) +}) diff --git a/package-lock.json b/package-lock.json index 37e98671c..7322436a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,7 @@ "@ledgerhq/hw-app-solana": "7.9.0", "@ledgerhq/hw-transport-node-hid": "6.32.0", "@mysten/sui": "^2.26.2", - "@solana/web3.js": "^1.98.4", + "@solana/web3.js": "^1.99.0", "@ton-community/ton-ledger": "^7.3.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^16.3.0", @@ -161,7 +161,7 @@ "@mysten/sui": "^2.26.2", "@noble/hashes": "^2.3.0", "@solana/spl-token": "0.4.15", - "@solana/web3.js": "^1.98.4", + "@solana/web3.js": "^1.99.0", "@ton/core": "0.63.1", "@ton/ton": "^16.3.0", "abitype": "1.3.0", @@ -9658,23 +9658,23 @@ } }, "node_modules/@solana/web3.js": { - "version": "1.98.4", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", - "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.99.0.tgz", + "integrity": "sha512-QZYQ2T1z6xWisoyALPq25i/QZTsRlM02BABtAsfaQ1p8wX4SdTfxrKTRue/ZZqrhNVh5oL7T/DUFiTS9DRgxow==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.0", - "@noble/curves": "^1.4.2", - "@noble/hashes": "^1.4.0", + "@babel/runtime": "^7.29.7", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", "@solana/buffer-layout": "^4.0.1", - "@solana/codecs-numbers": "^2.1.0", - "agentkeepalive": "^4.5.0", - "bn.js": "^5.2.1", + "@solana/codecs-numbers": "^5.5.1", + "agentkeepalive": "^4.6.0", + "bn.js": "^5.2.5", "borsh": "^0.7.0", "bs58": "^4.0.1", "buffer": "6.0.3", "fast-stable-stringify": "^1.0.0", - "jayson": "^4.1.1", + "jayson": "^4.3.0", "node-fetch": "^2.7.0", "rpc-websockets": "^9.0.2", "superstruct": "^2.0.2" @@ -9693,44 +9693,54 @@ } }, "node_modules/@solana/web3.js/node_modules/@solana/codecs-core": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", - "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz", + "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==", "license": "MIT", "dependencies": { - "@solana/errors": "2.3.0" + "@solana/errors": "5.5.1" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": ">=5.3.3" + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@solana/web3.js/node_modules/@solana/codecs-numbers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", - "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz", + "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==", "license": "MIT", "dependencies": { - "@solana/codecs-core": "2.3.0", - "@solana/errors": "2.3.0" + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": ">=5.3.3" + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@solana/web3.js/node_modules/@solana/errors": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", - "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz", + "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==", "license": "MIT", "dependencies": { - "chalk": "^5.4.1", - "commander": "^14.0.0" + "chalk": "5.6.2", + "commander": "14.0.2" }, "bin": { "errors": "bin/cli.mjs" @@ -9739,7 +9749,12 @@ "node": ">=20.18.0" }, "peerDependencies": { - "typescript": ">=5.3.3" + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@solana/web3.js/node_modules/base-x": { @@ -9784,9 +9799,9 @@ } }, "node_modules/@solana/web3.js/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", "license": "MIT", "engines": { "node": ">=20" From 134ccde581f45133b41e4e0eb3b52252760d5fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Tue, 8 Sep 2026 22:55:13 -0400 Subject: [PATCH 4/5] solana: support sending v1 txs --- .github/workflows/ci.yml | 6 +- CHANGELOG.md | 2 +- ccip-sdk/src/errors/codes.ts | 1 + ccip-sdk/src/errors/index.ts | 6 +- ccip-sdk/src/errors/recovery.ts | 2 + ccip-sdk/src/errors/specialized.ts | 28 +++ ccip-sdk/src/solana/fork.test.ts | 60 +++++ ccip-sdk/src/solana/utils.ts | 215 ++++++++++++----- ccip-sdk/src/solana/v1.test.ts | 370 +++++++++++++++++++++++++++++ ccip-sdk/src/solana/v1.ts | 206 ++++++++++++++++ 10 files changed, 837 insertions(+), 59 deletions(-) create mode 100644 ccip-sdk/src/solana/v1.test.ts create mode 100644 ccip-sdk/src/solana/v1.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38040aa71..2b3921eea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,9 +50,11 @@ jobs: - name: Verify anvil is available run: anvil --version - - name: Install Surfpool v1.1.2 (for SVM fork tests) + # >= 1.5.0 forks as Agave 4.x (solana-core 4.1.2) and accepts version-1 + # transactions, which the SDK sends when a tx exceeds the v0 packet limit + - name: Install Surfpool v1.5.0 (for SVM fork tests) run: | - curl -sSL https://github.com/solana-foundation/surfpool/releases/download/v1.1.2/surfpool-linux-x64.tar.gz \ + curl -sSL https://github.com/solana-foundation/surfpool/releases/download/v1.5.0/surfpool-linux-x64.tar.gz \ | tar xz -C /usr/local/bin - name: Verify surfpool is available diff --git a/CHANGELOG.md b/CHANGELOG.md index bcaa6f594..75a70cd8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Solana: `getTransaction`/`getLogs` now work on clusters emitting version-1 transactions (Agave 4.x devnet and later), via the `@solana/web3.js` 1.99.0 bump (v1 transaction read support); all dependents already accept `^1.99.0`, so no `overrides` needed and anchor stays at 0.29 +- Solana: supports reading and sending Version-1 transactions (Agave 4.x devnet and later), up to 4096 bytes per message - Tests: the whole suite runs as one parallel `node --test` invocation — networked e2e/integration suites moved to disjoint low-activity lanes/fixtures with per-network endpoint sets configurable via `RPC_*` env vars (one per network, comma-separated lists allowed, wired to CI secrets), so suites never contend on a rate-limited endpoint and the full run finishes in ~5min - Aptos and Sui now support detecting execution failures — bundled Sui fixes: deep-history `getLogs` walks ascending checkpoint slices instead of paging from the tip, empty `getOwnedObjects` pointer lookups are memoized instead of retried for ~30s, and `offRamp` receipt filters no longer drop successful Aptos/Sui receipts diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index 70e2f0410..a1737ad7a 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -15,6 +15,7 @@ export const CCIPErrorCode = { BLOCK_TIME_NOT_FOUND: 'BLOCK_TIME_NOT_FOUND', BLOCK_BEFORE_TIMESTAMP_NOT_FOUND: 'BLOCK_BEFORE_TIMESTAMP_NOT_FOUND', TRANSACTION_NOT_FINALIZED: 'TRANSACTION_NOT_FINALIZED', + TRANSACTION_TOO_LARGE: 'TRANSACTION_TOO_LARGE', // CCIP Message MESSAGE_INVALID: 'MESSAGE_INVALID', diff --git a/ccip-sdk/src/errors/index.ts b/ccip-sdk/src/errors/index.ts index c547eaaaa..69cde2307 100644 --- a/ccip-sdk/src/errors/index.ts +++ b/ccip-sdk/src/errors/index.ts @@ -14,7 +14,11 @@ export { } from './specialized.ts' // Specialized errors - Block & Transaction -export { CCIPBlockNotFoundError, CCIPTransactionNotFoundError } from './specialized.ts' +export { + CCIPBlockNotFoundError, + CCIPTransactionNotFoundError, + CCIPTransactionTooLargeError, +} from './specialized.ts' // Specialized errors - Logs export { diff --git a/ccip-sdk/src/errors/recovery.ts b/ccip-sdk/src/errors/recovery.ts index 7350e5679..9b70771fa 100644 --- a/ccip-sdk/src/errors/recovery.ts +++ b/ccip-sdk/src/errors/recovery.ts @@ -21,6 +21,8 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { BLOCK_TIME_NOT_FOUND: 'Wait and retry. Block time data may not be available yet.', BLOCK_BEFORE_TIMESTAMP_NOT_FOUND: 'No block exists before the specified timestamp.', TRANSACTION_NOT_FINALIZED: 'Wait for transaction finality.', + TRANSACTION_TOO_LARGE: + 'Transaction exceeds its version wire size limit (1232 bytes for v0, 4096 for v1). Reduce the message data or split into smaller transactions.', MESSAGE_INVALID: 'Verify the message format matches the expected CCIP message structure.', MESSAGE_DECODE_FAILED: diff --git a/ccip-sdk/src/errors/specialized.ts b/ccip-sdk/src/errors/specialized.ts index 96d475de1..65b0148c9 100644 --- a/ccip-sdk/src/errors/specialized.ts +++ b/ccip-sdk/src/errors/specialized.ts @@ -123,6 +123,34 @@ export class CCIPTransactionNotFoundError extends CCIPError { } } +/** + * Thrown when a transaction exceeds the wire size limits of its version + * (1232 bytes for legacy/v0, 4096 bytes for v1) or the account/instruction + * capacity of its message format. + * + * @example + * ```typescript + * try { + * await chain.execute(input) + * } catch (error) { + * if (error instanceof CCIPTransactionTooLargeError) { + * console.log(`Transaction needs ${error.context.wireBytes} bytes`) + * } + * } + * ``` + */ +export class CCIPTransactionTooLargeError extends CCIPError { + override readonly name = 'CCIPTransactionTooLargeError' + /** Creates a transaction too large error. */ + constructor(message: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.TRANSACTION_TOO_LARGE, message, { + ...options, + isTransient: false, + context: { ...options?.context }, + }) + } +} + // CCIP Message /** diff --git a/ccip-sdk/src/solana/fork.test.ts b/ccip-sdk/src/solana/fork.test.ts index ad201ebf9..a2d75379e 100644 --- a/ccip-sdk/src/solana/fork.test.ts +++ b/ccip-sdk/src/solana/fork.test.ts @@ -251,6 +251,66 @@ describe('Solana Fork Tests', { skip, timeout: 180_000 }, () => { 'decoded messageId should match', ) }) + + it('should send an oversized token-transfer message via a v1 transaction', async () => { + assert.ok(solanaChain, 'chain should be initialized') + assert.ok(wallet, 'wallet should be initialized') + assert.ok(connection, 'connection should be initialized') + + // Fund the wallet's USDC associated token account through the surfpool + // cheatcode (the forked mainnet USDC pool burns from the sender's ATA) + const rpc = connection as unknown as { + _rpcRequest(m: string, a: unknown[]): Promise<{ result?: unknown }> + } + const usdcMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + await rpc._rpcRequest('surfnet_setTokenAccount', [ + wallet.publicKey.toBase58(), + usdcMint, + { amount: 1_000_000_000, state: 'initialized' }, // 1000 USDC + ]) + + // Pad the message data so the ccipSend wire exceeds the 1232-byte v0 packet + // even with address-lookup-table compression (v0 ≈ 1370 bytes), while still + // fitting the 4096-byte v1 limit (SIMD-0385) the SDK falls back to. Token + // transfers require allowOutOfOrderExecution (the router pulls the tokens in + // a follow-up transaction). + const data = `0x${'ab'.repeat(256)}` + const request = await solanaChain.sendMessage({ + router: SOLANA_ROUTER, + destChainSelector: ETH_MAINNET_SELECTOR, + message: { + receiver: '0x9eC0e4A4c411493773E01e2ABF4D42395788846b', + data, + tokenAmounts: [{ token: usdcMint, amount: 1_000_000n }], + extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: true }, + }, + wallet, + }) + + // The SDK prefers v0 and only falls back to v1 when the v0 wire does not fit; + // re-read the transaction to assert the version the cluster recorded + const tx = await solanaChain.getTransaction(request.tx.hash) + assert.equal( + tx.tx.version, + 1, + `the oversized send should have been a v1 transaction (got ${tx.tx.version})`, + ) + + // Token transfer assertions + assert.equal(request.message.tokenAmounts?.length, 1) + assert.equal(request.message.tokenAmounts?.[0]?.amount, 1_000_000n) + + // Verify the message (incl. tokenAmounts) decodes from the on-chain logs + const decoded = await solanaChain.getMessagesInTx(tx) + assert.equal(decoded.length, 1, 'should find exactly one CCIP message in tx') + assert.equal( + decoded[0]!.message.messageId, + request.message.messageId, + 'decoded messageId should match', + ) + assert.equal(decoded[0]!.message.tokenAmounts?.length, 1) + assert.equal(decoded[0]!.message.tokenAmounts?.[0]?.amount, 1_000_000n) + }) }) describe('execute', () => { diff --git a/ccip-sdk/src/solana/utils.ts b/ccip-sdk/src/solana/utils.ts index 5c5eb5ae0..78d44755f 100644 --- a/ccip-sdk/src/solana/utils.ts +++ b/ccip-sdk/src/solana/utils.ts @@ -10,6 +10,7 @@ import { type Connection, type Signer, type SimulateTransactionConfig, + type SimulatedTransactionResponse, type Transaction, type TransactionInstruction, type VersionedTransactionResponse, @@ -24,6 +25,7 @@ import { dataLength, dataSlice, encodeBase64, hexlify } from 'ethers' import type { RateLimiterState } from '../chain.ts' import { + CCIPDataFormatUnsupportedError, CCIPTokenMintInvalidError, CCIPTokenMintNotFoundError, CCIPTransactionNotFinalizedError, @@ -32,6 +34,7 @@ import type { WithLogger } from '../types.ts' import { getDataBytes, jsonStringify, sleep } from '../utils.ts' import type { IDL as BASE_TOKEN_POOL_IDL } from './idl/1.6.0/BASE_TOKEN_POOL.ts' import type { UnsignedSolanaTx, Wallet } from './types.ts' +import { PACKET_DATA_SIZE, compileV1Message, serializeV1Transaction } from './v1.ts' import type { SolanaLog } from './index.ts' /** @@ -403,6 +406,11 @@ export function getErrorFromLogs( /** * Simulates a Solana transaction to estimate compute units. + * + * Prefers a v0 transaction (supports address lookup tables); when the v0 wire does + * not fit the 1232-byte packet (or v0 can't represent the accounts), falls back to a + * v1 transaction (SIMD-0385: all accounts static, compute-unit limit inlined into + * the message's transactionConfig, 4096-byte wire limit) simulated via raw RPC. * @param params - Simulation parameters including connection and payer. * @returns Simulation result with estimated compute units. */ @@ -421,60 +429,130 @@ export async function simulateTransaction( // Add max compute units for simulation const maxComputeUnits = 1_400_000 const recentBlockhash = '11111111111111111111111111111112' - const computeBudgetIx = ComputeBudgetProgram.setComputeUnitLimit({ - units: computeUnitsOverride || maxComputeUnits, - }) + const computeUnitLimit = computeUnitsOverride || maxComputeUnits + const computeBudgetIx = ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnitLimit }) + + const config: SimulateTransactionConfig = { + commitment: 'confirmed', + replaceRecentBlockhash: true, + sigVerify: false, + } + + const finish = (result: SimulatedTransactionResponse) => { + logger.debug('Simulation results:', { + logs: result.logs, + unitsConsumed: result.unitsConsumed, + returnData: result.returnData, + err: result.err, + }) + if (result.err) { + // same error sendTransaction sends, to be catched up + throw new SendTransactionError({ + action: 'simulate', + signature: '', + transactionMessage: jsonStringify(result.err), + logs: result.logs!, + }) + } + return result + } - let tx: VersionedTransaction if (!('tx' in rest)) { - // Create message with compute budget instruction - const message = new TransactionMessage({ + // build the v0 transaction; undefined when v0 can't represent it (e.g. too many + // accounts to compile) or its wire exceeds the 1232-byte packet + let tx: VersionedTransaction | undefined + try { + const message = new TransactionMessage({ + payerKey, + recentBlockhash, + instructions: [computeBudgetIx, ...rest.instructions], + }) + tx = new VersionedTransaction(message.compileToV0Message(rest.addressLookupTableAccounts)) + if (tx.serialize().length > PACKET_DATA_SIZE) tx = undefined + } catch { + tx = undefined + } + + if (tx) { + return finish((await connection.simulateTransaction(tx, config)).value) + } + + // v1 fallback: no address lookup tables — every account static; zero-filled + // signature slots (the count comes from the header) so sigVerify: false passes + const message = compileV1Message({ payerKey, recentBlockhash, - instructions: [computeBudgetIx, ...rest.instructions], + instructions: rest.instructions, + computeUnitLimit, }) + const wire = serializeV1Transaction( + message, + new Array(message.header.numRequiredSignatures).fill(null), + ) + return finish(await simulateRawV1(connection, wire)) + } + + if (!('version' in rest.tx)) { + // legacy Transaction: rebuild as v0, with the same v1 fallback shape as above + let tx: VersionedTransaction | undefined + try { + const message = new TransactionMessage({ + payerKey, + recentBlockhash, + instructions: [computeBudgetIx, ...rest.tx.instructions], + }) + tx = new VersionedTransaction(message.compileToV0Message()) + if (tx.serialize().length > PACKET_DATA_SIZE) tx = undefined + } catch { + tx = undefined + } + + if (tx) { + return finish((await connection.simulateTransaction(tx, config)).value) + } - const messageV0 = message.compileToV0Message(rest.addressLookupTableAccounts) - tx = new VersionedTransaction(messageV0) - } else if (!('version' in rest.tx)) { - // Create message with compute budget instruction - const message = new TransactionMessage({ + const message = compileV1Message({ payerKey, recentBlockhash, - instructions: [computeBudgetIx, ...rest.tx.instructions], + instructions: rest.tx.instructions, + computeUnitLimit, }) - - const messageV0 = message.compileToV0Message(rest.addressLookupTableAccounts) - tx = new VersionedTransaction(messageV0) - } else { - tx = rest.tx + const wire = serializeV1Transaction( + message, + new Array(message.header.numRequiredSignatures).fill(null), + ) + return finish(await simulateRawV1(connection, wire)) } - const config: SimulateTransactionConfig = { - commitment: 'confirmed', - replaceRecentBlockhash: true, - sigVerify: false, - } + // already-versioned transaction: simulate as-is + return finish((await connection.simulateTransaction(rest.tx, config)).value) +} - const result = await connection.simulateTransaction(tx, config) - - logger.debug('Simulation results:', { - logs: result.value.logs, - unitsConsumed: result.value.unitsConsumed, - returnData: result.value.returnData, - err: result.value.err, - }) - if (result.value.err) { - // same error sendTransaction sends, to be catched up - throw new SendTransactionError({ - action: 'simulate', - signature: '', - transactionMessage: jsonStringify(result.value.err), - logs: result.value.logs!, - }) +/** + * Simulates a raw (already serialized) transaction via raw RPC — web3.js' + * `Connection.simulateTransaction` only serializes legacy/v0 envelopes. + */ +async function simulateRawV1(connection: Connection, wire: Uint8Array) { + const res = await ( + connection as unknown as { + _rpcRequest(method: string, args: unknown[]): Promise<{ result?: { value?: unknown } }> + } + )._rpcRequest('simulateTransaction', [ + Buffer.from(wire).toString('base64'), + { + commitment: 'confirmed', + encoding: 'base64', + replaceRecentBlockhash: true, + sigVerify: false, + }, + ]) + const value = res.result?.value + if (!value) { + throw new CCIPDataFormatUnsupportedError( + 'simulateTransaction RPC response for a v1 transaction', + ) } - - return result.value + return value as SimulatedTransactionResponse } /** @@ -558,21 +636,48 @@ export async function simulateAndSendTxs( if (end <= start) throw lastErr const blockhash = await connection.getLatestBlockhash('confirmed') - const txMsg = new TransactionMessage({ - payerKey: wallet.publicKey, - recentBlockhash: blockhash.blockhash, - instructions: [ - ...(computeUnitLimit - ? [ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnitLimit })] - : []), - ...ixs, - ], - }) - const messageV0 = txMsg.compileToV0Message(addressLookupTableAccounts) - const tx = new VersionedTransaction(messageV0) - const signed = await wallet.signTransaction(tx) - const signature = await connection.sendTransaction(signed) + // Prefer a v0 transaction (supports address lookup tables); fall back to a v1 + // transaction (all accounts static, compute-unit limit inlined into the message's + // transactionConfig, 4096-byte wire limit instead of 1232) when the v0 wire does + // not fit the packet or v0 can't represent the accounts + let txV0: VersionedTransaction | undefined + try { + const txMsg = new TransactionMessage({ + payerKey: wallet.publicKey, + recentBlockhash: blockhash.blockhash, + instructions: [ + ...(computeUnitLimit + ? [ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnitLimit })] + : []), + ...ixs, + ], + }) + txV0 = new VersionedTransaction(txMsg.compileToV0Message(addressLookupTableAccounts)) + if (txV0.serialize().length > PACKET_DATA_SIZE) txV0 = undefined + } catch { + txV0 = undefined + } + + let signature: string + if (txV0) { + const signed = await wallet.signTransaction(txV0) + signature = await connection.sendTransaction(signed) + } else { + const messageV1 = compileV1Message({ + payerKey: wallet.publicKey, + recentBlockhash: blockhash.blockhash, + instructions: ixs, + computeUnitLimit, + }) + const txV1 = new VersionedTransaction(messageV1) + // v1 signing flows through the standard tx.sign()/partialSign() paths, which + // sign the message.serialize() bytes — SerializableMessageV1 provides them + await wallet.signTransaction(txV1) + signature = await connection.sendRawTransaction( + serializeV1Transaction(messageV1, txV1.signatures), + ) + } await connection.confirmTransaction({ signature, ...blockhash }, 'confirmed') if (includesMain) mainHash = signature } diff --git a/ccip-sdk/src/solana/v1.test.ts b/ccip-sdk/src/solana/v1.test.ts new file mode 100644 index 000000000..18d53209a --- /dev/null +++ b/ccip-sdk/src/solana/v1.test.ts @@ -0,0 +1,370 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + type Connection, + type MessageV1, + Keypair, + PACKET_DATA_SIZE, + PublicKey, + SystemProgram, + TransactionInstruction, + TransactionMessage, + V1_TRANSACTION_SIZE_LIMIT, + VersionedTransaction, +} from '@solana/web3.js' +import nacl from 'tweetnacl' + +import type { Wallet } from './types.ts' +import { simulateAndSendTxs, simulateTransaction } from './utils.ts' +import { compileV1Message, serializeMessageV1, serializeV1Transaction } from './v1.ts' + +// deterministic keypair for reproducible accounts +function keypairFromSeed(seed: string): Keypair { + const seedBytes = Buffer.alloc(32) + Buffer.from(seed).copy(seedBytes) + return Keypair.fromSeed(seedBytes) +} + +const PAYER = keypairFromSeed('payer') +const PROGRAM = new PublicKey('Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C') +const RECENT_BLOCKHASH = '11111111111111111111111111111112' + +function sampleInstruction(numAccounts: number, dataLength = 16): TransactionInstruction { + const keys = Array.from({ length: numAccounts }, (_, i) => ({ + pubkey: i === 0 ? PAYER.publicKey : keypairFromSeed(`acct${i}`).publicKey, + isSigner: i === 0, + isWritable: i % 2 === 0, + })) + return new TransactionInstruction({ + keys, + programId: PROGRAM, + data: Buffer.alloc(dataLength, 7), + }) +} + +/** Deserializes wire bytes with web3.js' own v1 codec (the test oracle). */ +function deserializeV1(messageBytes: Uint8Array): { message: MessageV1; signatures: Uint8Array[] } { + const tx = VersionedTransaction.deserialize(messageBytes) as VersionedTransaction + assert.equal(tx.message.version, 1) + return { message: tx.message as MessageV1, signatures: tx.signatures } +} + +describe('Solana v1 transaction support (SIMD-0385)', () => { + it('serializeMessageV1 round-trips through web3.js MessageV1 deserialization', () => { + const message = compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions: [sampleInstruction(5), sampleInstruction(3, 40)], + computeUnitLimit: 350_000, + }) + const wire = serializeV1Transaction(message, [null]) + + // route through the versioned dispatcher to prove the wire is self-describing + const deserialized = deserializeV1(wire).message + + assert.equal(deserialized.version, 1) + assert.deepEqual(deserialized.header, message.header) + assert.deepEqual(deserialized.staticAccountKeys, message.staticAccountKeys) + assert.equal(deserialized.recentBlockhash, message.recentBlockhash) + assert.deepEqual(deserialized.transactionConfig, message.transactionConfig) + assert.equal(deserialized.compiledInstructions.length, 2) + for (const [i, compiled] of deserialized.compiledInstructions.entries()) { + const source = message.compiledInstructions[i]! + assert.equal(compiled.programIdIndex, source.programIdIndex) + assert.deepEqual([...compiled.accountKeyIndexes], [...source.accountKeyIndexes]) + assert.deepEqual([...compiled.data], [...source.data]) + } + assert.ok(wire.length <= V1_TRANSACTION_SIZE_LIMIT) + }) + + it('serializes config fields present in the mask at their wire positions', () => { + const message = compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions: [sampleInstruction(2)], + }) + assert.deepEqual(message.transactionConfig, { + computeUnitLimit: null, + heapSize: null, + loadedAccountsDataSizeLimit: null, + priorityFee: null, + }) + const deserialized = deserializeV1(serializeV1Transaction(message, [null])) + assert.deepEqual(deserialized.message.transactionConfig, message.transactionConfig) + }) + + it('uses the v1 envelope: message first, signatures at the tail, no count prefix', () => { + const message = compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions: [sampleInstruction(4)], + }) + const signature = nacl.sign.detached(serializeMessageV1(message), PAYER.secretKey) + const wire = serializeV1Transaction(message, [signature]) + + assert.equal(wire[0], 0x81, 'v1 message prefix') + const { message: deserialized, signatures } = deserializeV1(wire) + assert.equal(signatures.length, 1) + assert.deepEqual([...signatures[0]!], [...signature]) + assert.ok(deserialized.staticAccountKeys[0]!.equals(PAYER.publicKey)) + + // the tail signature must verify against the payer for the serialized message bytes + const messageLength = wire.length - 64 + const valid = nacl.sign.detached.verify( + wire.slice(0, messageLength), + signatures[0]!, + PAYER.publicKey.toBytes(), + ) + assert.ok(valid, 'tail signature verifies over the message bytes') + }) + + it('builds v1 when the v0 wire exceeds the 1232-byte packet limit', () => { + // ~48 accounts × 32B keys plus instruction data: fits neither a v0 packet + const instructions = [sampleInstruction(48, 300)] + const messageV0 = new TransactionMessage({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions, + }).compileToV0Message() + // v0 wire = signatures (count byte + 64B each) + message; MessageV0.serialize() + // has a fixed 1232-byte buffer that overruns for oversized messages, so size the + // wire arithmetically (all lengths here fit a single compact-u16 byte) + const v0MessageSize = + 3 + + 32 + + 1 + + messageV0.staticAccountKeys.length * 32 + + 1 + + messageV0.compiledInstructions.reduce( + (n, ix) => n + 1 + 1 + 2 + ix.data.length + ix.accountKeyIndexes.length, + 0, + ) + const v0Wire = v0MessageSize + 65 + assert.ok( + v0Wire > PACKET_DATA_SIZE, + `v0 wire is ${v0Wire} bytes, expected > ${PACKET_DATA_SIZE}`, + ) + + const message = compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions, + computeUnitLimit: 400_000, + }) + const wire = serializeV1Transaction(message, [null]) + assert.ok(wire.length > v0Wire - 64, 'v1 carries all accounts statically') + assert.ok(wire.length <= V1_TRANSACTION_SIZE_LIMIT, `v1 wire is ${wire.length} bytes`) + const { message: deserialized } = deserializeV1(wire) + assert.equal(deserialized.transactionConfig.computeUnitLimit, 400_000) + }) + + it('compiles accounts like web3.js does (payer first, dedupe, header split)', () => { + const other = keypairFromSeed('acct1').publicKey + const ix = new TransactionInstruction({ + keys: [ + { pubkey: other, isSigner: false, isWritable: true }, + { pubkey: PAYER.publicKey, isSigner: true, isWritable: true }, + { pubkey: other, isSigner: false, isWritable: true }, // duplicate + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + programId: PROGRAM, + data: Buffer.alloc(4), + }) + const message = compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions: [ix], + }) + const v0 = new TransactionMessage({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions: [ix], + }).compileToV0Message() + assert.deepEqual(message.header, v0.header) + assert.deepEqual(message.staticAccountKeys, v0.staticAccountKeys) + assert.deepEqual(message.compiledInstructions, v0.compiledInstructions) + assert.deepEqual(message.staticAccountKeys[0], PAYER.publicKey) + }) + + it('rejects invalid signatures and oversized v1 wires', () => { + const message = compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions: [sampleInstruction(4)], + }) + assert.throws(() => serializeV1Transaction(message, []), /expected 1, got 0/) + assert.throws(() => serializeV1Transaction(message, [new Uint8Array(32)]), /invalid length/) + + // pad instruction data until the 4096-byte v1 limit is exceeded + let count = 4000 + for (;;) { + count += 1000 + const padded = compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions: [sampleInstruction(4, count)], + }) + assert.throws( + () => serializeV1Transaction(padded, [null]), + /Transaction too large/, + `wire at data length ${count} should exceed the v1 limit`, + ) + break + } + }) + + it('rejects more than 255 static account keys (v1 format limit)', () => { + const keys = Array.from({ length: 300 }, (_, i) => ({ + pubkey: i === 0 ? PAYER.publicKey : keypairFromSeed(`acct${i}`).publicKey, + isSigner: i === 0, + isWritable: true, + })) + assert.throws( + () => + compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions: [ + new TransactionInstruction({ keys, programId: PROGRAM, data: Buffer.alloc(4) }), + ], + }), + /max 255/, + ) + }) + + it('rejects more than 255 instructions (v1 format limit)', () => { + const instructions = Array.from( + { length: 300 }, + (_, i) => + new TransactionInstruction({ + keys: [{ pubkey: PAYER.publicKey, isSigner: true, isWritable: true }], + programId: PROGRAM, + data: Buffer.from([i % 256]), + }), + ) + const message = compileV1Message({ + payerKey: PAYER.publicKey, + recentBlockhash: RECENT_BLOCKHASH, + instructions, + }) + assert.equal(message.compiledInstructions.length, 300) + // the u8 instruction count would wrap (300 -> 44); serialize must reject it + // instead of emitting a corrupt wire + assert.throws(() => serializeV1Transaction(message, [null]), /max 255/) + }) + + describe('simulateTransaction / simulateAndSendTxs v1 fallback', () => { + const OVERSIZED = [sampleInstruction(48, 300)] // v0 wire > 1232B, v1 wire < 4096B + const SMALL = [sampleInstruction(4)] + + function mockConnection() { + const captured: Record = {} + const connection = { + getLatestBlockhash: async () => ({ + blockhash: RECENT_BLOCKHASH, + lastValidBlockHeight: 999, + }), + simulateTransaction: async (tx: VersionedTransaction) => { + captured.simulatedTx = tx + return { value: { logs: [], unitsConsumed: 5 } } + }, + _rpcRequest: async (method: string, args: unknown[]) => { + captured.rpc = { method, args } + return { result: { value: { logs: [], unitsConsumed: 7 } } } + }, + sendTransaction: async (tx: VersionedTransaction) => { + captured.sentV0 = tx + return 'v0-signature' + }, + sendRawTransaction: async (wire: Uint8Array) => { + captured.sentWire = wire + return 'v1-signature' + }, + confirmTransaction: async (confirm: { signature: string }) => { + captured.confirmedSignature = confirm.signature + }, + } as unknown as Connection + return { connection, captured } + } + + // only VersionedTransactions are ever passed by the send/simulate paths + const wallet = { + publicKey: PAYER.publicKey, + signTransaction: async (tx: VersionedTransaction) => { + tx.sign([PAYER]) + return tx + }, + } as unknown as Wallet + + it('simulateTransaction falls back to a raw v1 RPC simulation when v0 is oversized', async () => { + const { connection, captured } = mockConnection() + const result = await simulateTransaction( + { connection }, + { + payerKey: PAYER.publicKey, + instructions: OVERSIZED, + }, + ) + assert.equal(result.unitsConsumed, 7) + assert.equal(captured.simulatedTx, undefined, 'no v0 simulation was attempted') + const { method, args } = captured.rpc as { method: string; args: [string, unknown] } + assert.equal(method, 'simulateTransaction') + const wire = Buffer.from(args[0], 'base64') + const tx = VersionedTransaction.deserialize(wire) + assert.equal(tx.message.version, 1) + assert.ok( + (tx.message as MessageV1).transactionConfig.computeUnitLimit, + 'compute-unit limit is inlined into the transactionConfig', + ) + }) + + it('simulateTransaction keeps using the v0 path when the tx fits the packet', async () => { + const { connection, captured } = mockConnection() + const result = await simulateTransaction( + { connection }, + { + payerKey: PAYER.publicKey, + instructions: SMALL, + }, + ) + assert.equal(result.unitsConsumed, 5) + assert.equal(captured.rpc, undefined, 'no raw v1 RPC simulation was attempted') + assert.equal((captured.simulatedTx as VersionedTransaction).message.version, 0) + }) + + it('simulateAndSendTxs signs and sends a v1 transaction when v0 is oversized', async () => { + const { connection, captured } = mockConnection() + const signature = await simulateAndSendTxs({ connection }, wallet, { + instructions: OVERSIZED, + mainIndex: 0, + }) + assert.equal(signature, 'v1-signature') + assert.equal(captured.sentV0, undefined, 'no v0 transaction was sent') + assert.equal(captured.confirmedSignature, 'v1-signature') + + const wire = captured.sentWire as Uint8Array + assert.ok(wire.length <= V1_TRANSACTION_SIZE_LIMIT) + assert.equal(wire[0], 0x81, 'v1 envelope: message first') + const tx = VersionedTransaction.deserialize(wire) + assert.equal(tx.message.version, 1) + // the tail signature must verify over the serialized v1 message bytes + const messageBytes = wire.slice(0, wire.length - 64) + assert.ok( + nacl.sign.detached.verify(messageBytes, tx.signatures[0]!, PAYER.publicKey.toBytes()), + 'the payer signature verifies over the v1 message', + ) + }) + + it('simulateAndSendTxs keeps using the v0 path when the tx fits the packet', async () => { + const { connection, captured } = mockConnection() + const signature = await simulateAndSendTxs({ connection }, wallet, { + instructions: SMALL, + mainIndex: 0, + }) + assert.equal(signature, 'v0-signature') + assert.equal(captured.sentWire, undefined, 'no raw v1 transaction was sent') + assert.equal((captured.sentV0 as VersionedTransaction).message.version, 0) + }) + }) +}) diff --git a/ccip-sdk/src/solana/v1.ts b/ccip-sdk/src/solana/v1.ts new file mode 100644 index 000000000..20f9f1f61 --- /dev/null +++ b/ccip-sdk/src/solana/v1.ts @@ -0,0 +1,206 @@ +import { + type Blockhash, + type MessageCompiledInstruction, + type MessageV1Args, + type PublicKey, + type TransactionInstruction, + MessageV1, + PACKET_DATA_SIZE, + SIGNATURE_LENGTH_IN_BYTES, + TransactionMessage, + V1_TRANSACTION_SIZE_LIMIT, + VERSION_1_MESSAGE_PREFIX, +} from '@solana/web3.js' +import bs58 from 'bs58' + +import { CCIPArgumentInvalidError, CCIPTransactionTooLargeError } from '../errors/index.ts' + +// v1 transaction-config wire mask bits (web3.js keeps these internal) +const CONFIG_MASK_PRIORITY_FEE_BITS = 0b00011 +const CONFIG_MASK_COMPUTE_UNIT_LIMIT_BIT = 0b00100 +const CONFIG_MASK_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_BIT = 0b01000 +const CONFIG_MASK_HEAP_SIZE_BIT = 0b10000 + +/** + * A {@link MessageV1} that can be serialized for signing and sending. + * + * web3.js 1.99 adds v1 transactions (SIMD-0385: 4096-byte wire limit vs 1232 for + * v0, inline transactionConfig, no address lookup tables) but only READ support — + * its `MessageV1.serialize()` throws. This subclass restores serialization, so v1 + * transactions flow through the standard `tx.sign()`/`tx.partialSign()` wallet + * paths (which sign `message.serialize()` bytes) and `VersionedTransaction` keeps + * tracking signatures per account index. + */ +export class SerializableMessageV1 extends MessageV1 { + override serialize(): Uint8Array { + return serializeMessageV1(this) + } +} + +/** + * Serializes a v1 transaction message to its wire format: + * `0x81` prefix, 3-byte header, u32 config mask, recent blockhash, u8 instruction + * count, u8 static-account-key count, the static keys, the transaction-config + * values present in the mask (u64 priority fee, then u32 compute-unit limit, + * loaded-accounts data-size limit and heap size), then instruction headers + * (program-id index, u8 account-index count, u16 data length) and payloads. + */ +export function serializeMessageV1(message: MessageV1): Uint8Array { + const { transactionConfig } = message + if (message.staticAccountKeys.length > 255) { + throw new CCIPTransactionTooLargeError( + 'Too many static account keys for a v1 transaction message (max 255)', + ) + } + if (message.compiledInstructions.length > 255) { + throw new CCIPTransactionTooLargeError( + 'Too many instructions for a v1 transaction message (max 255)', + ) + } + + const configMask = + (transactionConfig.priorityFee != null ? CONFIG_MASK_PRIORITY_FEE_BITS : 0) | + (transactionConfig.computeUnitLimit != null ? CONFIG_MASK_COMPUTE_UNIT_LIMIT_BIT : 0) | + (transactionConfig.loadedAccountsDataSizeLimit != null + ? CONFIG_MASK_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_BIT + : 0) | + (transactionConfig.heapSize != null ? CONFIG_MASK_HEAP_SIZE_BIT : 0) + + // prefix + 3-byte header + u32 config mask + blockhash + u8 instruction count + u8 key count + const head = Buffer.alloc(42) + head[0] = VERSION_1_MESSAGE_PREFIX + head[1] = message.header.numRequiredSignatures + head[2] = message.header.numReadonlySignedAccounts + head[3] = message.header.numReadonlyUnsignedAccounts + head.writeUInt32LE(configMask, 4) + head.set(bs58.decode(message.recentBlockhash), 8) + head[40] = message.compiledInstructions.length + head[41] = message.staticAccountKeys.length + + const parts: Buffer[] = [head] + for (const key of message.staticAccountKeys) parts.push(Buffer.from(key.toBytes())) + + // config values, in the mask-bit order the wire format expects + const configField = (value: number | null | undefined, bytes: number) => { + if (value == null) return + const buf = Buffer.alloc(bytes) + if (bytes === 8) buf.writeBigUInt64LE(BigInt(value)) + else buf.writeUInt32LE(value) + parts.push(buf) + } + configField(transactionConfig.priorityFee, 8) + configField(transactionConfig.computeUnitLimit, 4) + configField(transactionConfig.loadedAccountsDataSizeLimit, 4) + configField(transactionConfig.heapSize, 4) + + // instruction headers first, then their payloads + for (const { programIdIndex, accountKeyIndexes, data } of message.compiledInstructions) { + const header = Buffer.alloc(4) + header[0] = programIdIndex + header[1] = accountKeyIndexes.length + if (data.length > 0xffff) { + throw new CCIPTransactionTooLargeError( + 'Instruction data too large for a v1 transaction message (max 65535 bytes)', + ) + } + header.writeUInt16LE(data.length, 2) + parts.push(header) + } + for (const { accountKeyIndexes, data } of message.compiledInstructions) { + parts.push(Buffer.from(accountKeyIndexes), Buffer.from(data)) + } + + return new Uint8Array(Buffer.concat(parts)) +} + +/** + * Serializes a signed v1 transaction to its wire envelope: the message bytes + * followed by the signatures at the tail (no signature-count prefix — the count + * comes from the message header, unlike legacy/v0). Entries may be null/undefined + * (zero-filled slots), e.g. for simulation with `sigVerify: false`. + */ +export function serializeV1Transaction( + message: MessageV1, + signatures: (Uint8Array | null | undefined)[], +): Uint8Array { + const numRequired = message.header.numRequiredSignatures + if (signatures.length !== numRequired) { + throw new CCIPArgumentInvalidError( + 'signatures', + `expected ${numRequired}, got ${signatures.length}`, + ) + } + const messageBytes = message.serialize() + const wire = Buffer.alloc(messageBytes.length + numRequired * SIGNATURE_LENGTH_IN_BYTES) + wire.set(messageBytes, 0) + signatures.forEach((signature, i) => { + if (signature == null) return // zero-filled slot + if (signature.length !== SIGNATURE_LENGTH_IN_BYTES) { + throw new CCIPArgumentInvalidError(`signatures[${i}]`, 'invalid length') + } + wire.set(signature, messageBytes.length + i * SIGNATURE_LENGTH_IN_BYTES) + }) + if (wire.length > V1_TRANSACTION_SIZE_LIMIT) { + throw new CCIPTransactionTooLargeError( + `Transaction too large: ${wire.length} > ${V1_TRANSACTION_SIZE_LIMIT}`, + ) + } + return wire +} + +/** + * Compiles instructions into a v1 transaction message. v1 has no address lookup + * tables, so every account is static; compilation reuses web3.js' v0 compiler + * (same dedupe/ordering/header semantics, same u8 account indexes) and only the + * envelope differs. The compute-unit limit is inlined into the message's + * transactionConfig instead of a ComputeBudget instruction. + * @throws if the compiled accounts exceed the 255 static keys the v1 format allows + */ +export function compileV1Message({ + payerKey, + recentBlockhash, + instructions, + computeUnitLimit, +}: { + payerKey: PublicKey + recentBlockhash: Blockhash + instructions: TransactionInstruction[] + computeUnitLimit?: number +}): SerializableMessageV1 { + let messageV0 + try { + messageV0 = new TransactionMessage({ + payerKey, + recentBlockhash, + instructions, + }).compileToV0Message() + } catch (err) { + // v0 compilation fails before our own limit check when the accounts cannot be + // referenced — surface the v1-specific limit instead + throw new CCIPTransactionTooLargeError( + 'Too many static account keys for a v1 transaction message (max 255)', + { cause: err as Error }, + ) + } + if (messageV0.staticAccountKeys.length > 255) { + throw new CCIPTransactionTooLargeError( + 'Too many static account keys for a v1 transaction message (max 255)', + ) + } + const args: MessageV1Args = { + header: messageV0.header, + staticAccountKeys: messageV0.staticAccountKeys, + recentBlockhash: messageV0.recentBlockhash, + compiledInstructions: messageV0.compiledInstructions as MessageCompiledInstruction[], + transactionConfig: { + computeUnitLimit: computeUnitLimit ?? null, + heapSize: null, + loadedAccountsDataSizeLimit: null, + priorityFee: null, + }, + } + return new SerializableMessageV1(args) +} + +/** Wire size limit for v0 transactions (the UDP packet data size). */ +export { PACKET_DATA_SIZE, V1_TRANSACTION_SIZE_LIMIT } From 7b2696156996afa20e2de0a6ca19257f0290f378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 9 Sep 2026 13:32:40 -0400 Subject: [PATCH 5/5] chore: selectors --- ccip-sdk/src/selectors.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ccip-sdk/src/selectors.ts b/ccip-sdk/src/selectors.ts index 3a5f6e640..9a272b7a9 100644 --- a/ccip-sdk/src/selectors.ts +++ b/ccip-sdk/src/selectors.ts @@ -832,6 +832,7 @@ const SELECTORS: Selectors = { selector: 470401360549526817n, name: 'superseed-mainnet', network_type: 'MAINNET', + deprecated: true, family: 'EVM', }, '5611': { @@ -863,6 +864,7 @@ const SELECTORS: Selectors = { selector: 379340054879810246n, name: 'everclear-testnet-sepolia', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '6900': { @@ -1182,6 +1184,7 @@ const SELECTORS: Selectors = { selector: 13694007683517087973n, name: 'superseed-testnet', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '57054': { @@ -1431,6 +1434,7 @@ const SELECTORS: Selectors = { selector: 3789623672476206327n, name: 'bitcoin-testnet-bitlayer-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '200901': { @@ -1485,6 +1489,7 @@ const SELECTORS: Selectors = { selector: 2279865765895943307n, name: 'ethereum-testnet-sepolia-scroll-1', network_type: 'TESTNET', + deprecated: true, family: 'EVM', }, '534352': {