diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38040aa7..2b3921ee 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 ef579263..75a70cd8 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: 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-api-ref/package.json b/ccip-api-ref/package.json index 4a8cf51a..4ba418b8 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 17d51b19..597f6d59 100644 --- a/ccip-cli/package.json +++ b/ccip-cli/package.json @@ -58,8 +58,8 @@ "@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", - "@solana/web3.js": "^1.98.4", + "@mysten/sui": "^2.26.2", + "@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 11984ee1..d9dda284 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 b0c53611..33a04b65 100644 --- a/ccip-sdk/package.json +++ b/ccip-sdk/package.json @@ -63,16 +63,16 @@ "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", + "@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/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index 70e2f041..a1737ad7 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 c547eaaa..69cde230 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 7350e567..9b70771f 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 96d475de..65b0148c 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/evm/fork.test.ts b/ccip-sdk/src/evm/fork.test.ts index e3451a39..31faa3ac 100644 --- a/ccip-sdk/src/evm/fork.test.ts +++ b/ccip-sdk/src/evm/fork.test.ts @@ -146,7 +146,13 @@ async function startForkWithRetries(instance: ReturnType) } } -describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { +// Generous describe-level budget: a throttled public fork upstream can stall a single +// test 10x+ (one execute test hit 155s under an RPC storm while its normal runtime is +// ~12s). The per-test timeouts below bound each test, so a stall fails that test loudly +// instead of blowing the describe budget and CANCELLING all remaining suites (node +// --test reports the rest as "did not finish before its parent"). 600s matches +// dest-liquidity.fork.test.ts. +describe('EVM Fork Tests', { skip, timeout: 600_000 }, () => { let sepoliaChain: EVMChain | undefined let fujiChain: EVMChain | undefined let arbSepChain: EVMChain | undefined @@ -218,178 +224,203 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { // ── State-mutating tests (sendMessage / execute / ViemTransportProvider) ── describe('sendMessage', () => { - it('should send via v1.5 lane (Sepolia -> Fuji) and emit CCIPSendRequested', async () => { - assert.ok(sepoliaChain, 'chain should be initialized') - const walletAddress = await wallet.getAddress() - - const request = await sepoliaChain.sendMessage({ - router: SEPOLIA_ROUTER, - destChainSelector: FUJI_SELECTOR, - message: { receiver: walletAddress, data: '0x1337' }, - wallet, - }) - - assert.ok(request.message.messageId, 'messageId should be defined') - assert.match(request.message.messageId, /^0x[0-9a-f]{64}$/i) - assert.equal(request.lane.sourceChainSelector, SEPOLIA_SELECTOR) - assert.equal(request.lane.destChainSelector, FUJI_SELECTOR) - assert.ok(request.tx.hash, 'tx hash should be defined') - - // Verify the v1.5 CCIPSendRequested event was emitted - assert.ok(request.log, 'request should contain the event log') - assert.equal(request.log.topics[0], CCIP_SEND_REQUESTED_TOPIC, 'should be CCIPSendRequested') - assert.ok(request.log.address, 'log should have the onRamp address') - assert.equal(request.log.transactionHash, request.tx.hash, 'log tx hash should match') - assert.ok( - String(request.message.data).includes('1337'), - 'message data should contain sent payload', - ) - }) - - it('should send via v1.6 lane (Sepolia -> Aptos) and emit CCIPMessageSent', async () => { - assert.ok(sepoliaChain, 'chain should be initialized') - const walletAddress = await wallet.getAddress() + it( + 'should send via v1.5 lane (Sepolia -> Fuji) and emit CCIPSendRequested', + { timeout: 60_000 }, + async () => { + assert.ok(sepoliaChain, 'chain should be initialized') + const walletAddress = await wallet.getAddress() + + const request = await sepoliaChain.sendMessage({ + router: SEPOLIA_ROUTER, + destChainSelector: FUJI_SELECTOR, + message: { receiver: walletAddress, data: '0x1337' }, + wallet, + }) - const request = await sepoliaChain.sendMessage({ - router: SEPOLIA_ROUTER, - destChainSelector: APTOS_TESTNET_SELECTOR, - message: { receiver: walletAddress, data: '0xdead', extraArgs: { gasLimit: 0n } }, - wallet, - }) + assert.ok(request.message.messageId, 'messageId should be defined') + assert.match(request.message.messageId, /^0x[0-9a-f]{64}$/i) + assert.equal(request.lane.sourceChainSelector, SEPOLIA_SELECTOR) + assert.equal(request.lane.destChainSelector, FUJI_SELECTOR) + assert.ok(request.tx.hash, 'tx hash should be defined') + + // Verify the v1.5 CCIPSendRequested event was emitted + assert.ok(request.log, 'request should contain the event log') + assert.equal( + request.log.topics[0], + CCIP_SEND_REQUESTED_TOPIC, + 'should be CCIPSendRequested', + ) + assert.ok(request.log.address, 'log should have the onRamp address') + assert.equal(request.log.transactionHash, request.tx.hash, 'log tx hash should match') + assert.ok( + String(request.message.data).includes('1337'), + 'message data should contain sent payload', + ) + }, + ) - assert.ok(request.message.messageId, 'messageId should be defined') - assert.match(request.message.messageId, /^0x[0-9a-f]{64}$/i) - assert.equal(request.lane.sourceChainSelector, SEPOLIA_SELECTOR) - assert.equal(request.lane.destChainSelector, APTOS_TESTNET_SELECTOR) - assert.ok(request.tx.hash, 'tx hash should be defined') - - // Verify the v1.6 CCIPMessageSent event was emitted - assert.ok(request.log, 'request should contain the event log') - assert.equal(request.log.topics[0], CCIP_MESSAGE_SENT_TOPIC, 'should be CCIPMessageSent') - assert.ok(request.log.address, 'log should have the onRamp address') - assert.equal(request.log.transactionHash, request.tx.hash, 'log tx hash should match') - assert.ok( - String(request.message.data).includes('dead'), - 'message data should contain sent payload', - ) - }) + it( + 'should send via v1.6 lane (Sepolia -> Aptos) and emit CCIPMessageSent', + { timeout: 60_000 }, + async () => { + assert.ok(sepoliaChain, 'chain should be initialized') + const walletAddress = await wallet.getAddress() + + const request = await sepoliaChain.sendMessage({ + router: SEPOLIA_ROUTER, + destChainSelector: APTOS_TESTNET_SELECTOR, + message: { receiver: walletAddress, data: '0xdead', extraArgs: { gasLimit: 0n } }, + wallet, + }) - it('should send v1.6 token transfer with extraArgs (Sepolia -> Aptos)', async () => { - assert.ok(sepoliaChain, 'chain should be initialized') - const provider = wallet.provider as JsonRpcProvider - const walletAddress = await wallet.getAddress() - - const amount = parseUnits('0.1', 18) - await setERC20Balance(provider, APTOS_SUPPORTED_TOKEN, walletAddress, amount) - - const request = await sepoliaChain.sendMessage({ - router: SEPOLIA_ROUTER, - destChainSelector: APTOS_TESTNET_SELECTOR, - message: { - receiver: walletAddress, - data: '0xcafe', - tokenAmounts: [{ token: APTOS_SUPPORTED_TOKEN, amount }], - extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: true }, - }, - wallet, - }) + assert.ok(request.message.messageId, 'messageId should be defined') + assert.match(request.message.messageId, /^0x[0-9a-f]{64}$/i) + assert.equal(request.lane.sourceChainSelector, SEPOLIA_SELECTOR) + assert.equal(request.lane.destChainSelector, APTOS_TESTNET_SELECTOR) + assert.ok(request.tx.hash, 'tx hash should be defined') + + // Verify the v1.6 CCIPMessageSent event was emitted + assert.ok(request.log, 'request should contain the event log') + assert.equal(request.log.topics[0], CCIP_MESSAGE_SENT_TOPIC, 'should be CCIPMessageSent') + assert.ok(request.log.address, 'log should have the onRamp address') + assert.equal(request.log.transactionHash, request.tx.hash, 'log tx hash should match') + assert.ok( + String(request.message.data).includes('dead'), + 'message data should contain sent payload', + ) + }, + ) - // Event log assertions - assert.ok(request.log, 'request should contain the event log') - assert.equal(request.log.topics[0], CCIP_MESSAGE_SENT_TOPIC, 'should be CCIPMessageSent') - assert.equal(request.log.transactionHash, request.tx.hash, 'log tx hash should match') + it( + 'should send v1.6 token transfer with extraArgs (Sepolia -> Aptos)', + { timeout: 60_000 }, + async () => { + assert.ok(sepoliaChain, 'chain should be initialized') + const provider = wallet.provider as JsonRpcProvider + const walletAddress = await wallet.getAddress() - // Message assertions - assert.ok(request.message.messageId, 'messageId should be defined') - assert.match(request.message.messageId, /^0x[0-9a-f]{64}$/i) - assert.ok( - String(request.message.data).includes('cafe'), - 'message data should contain sent payload', - ) - assert.ok(request.message.feeToken, 'feeToken should be defined') + const amount = parseUnits('0.1', 18) + await setERC20Balance(provider, APTOS_SUPPORTED_TOKEN, walletAddress, amount) - // ExtraArgs assertions (decoded from extraArgs bytes in v1.6 event) - const msg = request.message as Record - assert.equal(msg.gasLimit, 0n, 'gasLimit should round-trip as 0') - assert.equal( - msg.allowOutOfOrderExecution, - true, - 'allowOutOfOrderExecution should round-trip as true', - ) + const request = await sepoliaChain.sendMessage({ + router: SEPOLIA_ROUTER, + destChainSelector: APTOS_TESTNET_SELECTOR, + message: { + receiver: walletAddress, + data: '0xcafe', + tokenAmounts: [{ token: APTOS_SUPPORTED_TOKEN, amount }], + extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: true }, + }, + wallet, + }) - // Token transfer assertions - const tokenAmounts = request.message.tokenAmounts as unknown as Record[] - assert.equal(tokenAmounts.length, 1, 'should have one token transfer') - assert.equal( - (tokenAmounts[0] as { amount: bigint }).amount, - amount, - 'token amount should round-trip', - ) - assert.ok(tokenAmounts[0]!.sourcePoolAddress, 'v1.6 should have sourcePoolAddress') - assert.ok(tokenAmounts[0]!.destTokenAddress, 'v1.6 should have destTokenAddress') - }) + // Event log assertions + assert.ok(request.log, 'request should contain the event log') + assert.equal(request.log.topics[0], CCIP_MESSAGE_SENT_TOPIC, 'should be CCIPMessageSent') + assert.equal(request.log.transactionHash, request.tx.hash, 'log tx hash should match') + + // Message assertions + assert.ok(request.message.messageId, 'messageId should be defined') + assert.match(request.message.messageId, /^0x[0-9a-f]{64}$/i) + assert.ok( + String(request.message.data).includes('cafe'), + 'message data should contain sent payload', + ) + assert.ok(request.message.feeToken, 'feeToken should be defined') + + // ExtraArgs assertions (decoded from extraArgs bytes in v1.6 event) + const msg = request.message as Record + assert.equal(msg.gasLimit, 0n, 'gasLimit should round-trip as 0') + assert.equal( + msg.allowOutOfOrderExecution, + true, + 'allowOutOfOrderExecution should round-trip as true', + ) + + // Token transfer assertions + const tokenAmounts = request.message.tokenAmounts as unknown as Record[] + assert.equal(tokenAmounts.length, 1, 'should have one token transfer') + assert.equal( + (tokenAmounts[0] as { amount: bigint }).amount, + amount, + 'token amount should round-trip', + ) + assert.ok(tokenAmounts[0]!.sourcePoolAddress, 'v1.6 should have sourcePoolAddress') + assert.ok(tokenAmounts[0]!.destTokenAddress, 'v1.6 should have destTokenAddress') + }, + ) }) describe('execute', () => { - it('should manually execute a failed v1.6 message (Fuji -> Sepolia)', async () => { - assert.ok(fujiChain, 'source chain should be initialized') - assert.ok(sepoliaChain, 'dest chain should be initialized') - - // 1. Get source transaction and extract CCIPRequest - const tx = await fujiChain.getTransaction(SOURCE_TX_HASH) - const requests = await fujiChain.getMessagesInTx(tx) - const request = requests.find((r) => r.message.messageId === MESSAGE_ID) ?? requests[0]! - assert.equal(request.message.messageId, MESSAGE_ID, 'should find the expected message') - - // 2. Discover OffRamp on destination chain - const offRamp = await discoverOffRamp(fujiChain, sepoliaChain, request.lane.onRamp, fujiChain) - assert.ok(offRamp, 'offRamp should be discovered') - - // 3. Get commit store and commit report - const verifications = await sepoliaChain.getVerifications({ offRamp, request }) - assert.ok('report' in verifications, 'commit should have a merkle root') - assert.ok(verifications.report.merkleRoot, 'commit should have a merkle root') - - // 4. Get all messages in the commit batch from source - const messagesInBatch = await fujiChain.getMessagesInBatch(request, verifications.report, { - page: 999, - }) - - // 5. Calculate manual execution proof - const execReportProof = calculateManualExecProof( - messagesInBatch, - request.lane, - request.message.messageId, - verifications.report.merkleRoot, - sepoliaChain, - ) - - // 6. Get offchain token data - const offchainTokenData = await fujiChain.getOffchainTokenData(request) + it( + 'should manually execute a failed v1.6 message (Fuji -> Sepolia)', + { timeout: 120_000 }, + async () => { + assert.ok(fujiChain, 'source chain should be initialized') + assert.ok(sepoliaChain, 'dest chain should be initialized') + + // 1. Get source transaction and extract CCIPRequest + const tx = await fujiChain.getTransaction(SOURCE_TX_HASH) + const requests = await fujiChain.getMessagesInTx(tx) + const request = requests.find((r) => r.message.messageId === MESSAGE_ID) ?? requests[0]! + assert.equal(request.message.messageId, MESSAGE_ID, 'should find the expected message') + + // 2. Discover OffRamp on destination chain + const offRamp = await discoverOffRamp( + fujiChain, + sepoliaChain, + request.lane.onRamp, + fujiChain, + ) + assert.ok(offRamp, 'offRamp should be discovered') + + // 3. Get commit store and commit report + const verifications = await sepoliaChain.getVerifications({ offRamp, request }) + assert.ok('report' in verifications, 'commit should have a merkle root') + assert.ok(verifications.report.merkleRoot, 'commit should have a merkle root') + + // 4. Get all messages in the commit batch from source + const messagesInBatch = await fujiChain.getMessagesInBatch(request, verifications.report, { + page: 999, + }) - // 7. Build execution report and execute - const input = { - ...execReportProof, - message: request.message, - offchainTokenData, - } as ExecutionInput - const execution = await sepoliaChain.execute({ - offRamp, - input, - wallet, - gasLimit: 500_000, - }) + // 5. Calculate manual execution proof + const execReportProof = calculateManualExecProof( + messagesInBatch, + request.lane, + request.message.messageId, + verifications.report.merkleRoot, + sepoliaChain, + ) + + // 6. Get offchain token data + const offchainTokenData = await fujiChain.getOffchainTokenData(request) + + // 7. Build execution report and execute + const input = { + ...execReportProof, + message: request.message, + offchainTokenData, + } as ExecutionInput + const execution = await sepoliaChain.execute({ + offRamp, + input, + wallet, + gasLimit: 500_000, + }) - assert.equal(execution.receipt.messageId, MESSAGE_ID, 'receipt messageId should match') - assert.ok(execution.log.transactionHash, 'execution log should have a transaction hash') - assert.ok(execution.log.blockTimestamp > 0, 'execution should have a positive timestamp') - assert.ok( - execution.receipt.state === ExecutionState.Success, - 'execution state should be Success', - ) - }) + assert.equal(execution.receipt.messageId, MESSAGE_ID, 'receipt messageId should match') + assert.ok(execution.log.transactionHash, 'execution log should have a transaction hash') + assert.ok(execution.log.blockTimestamp > 0, 'execution should have a positive timestamp') + assert.ok( + execution.receipt.state === ExecutionState.Success, + 'execution state should be Success', + ) + }, + ) - it('should execute via getExecutionInput (Fuji -> Sepolia)', async () => { + it('should execute via getExecutionInput (Fuji -> Sepolia)', { timeout: 120_000 }, async () => { assert.ok(fujiChain, 'source chain should be initialized') assert.ok(sepoliaChain, 'dest chain should be initialized') @@ -433,174 +464,194 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { ) }) - it('should execute a v2.0 message via API-driven path (Arb-Sep -> Fuji)', async () => { - assert.ok(fujiInstance, 'fuji anvil should be running') - - // Create a fuji chain with staging API client (execution-inputs endpoint) - const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) - const fujiProvider = new JsonRpcProvider(`http://${fujiInstance.host}:${fujiInstance.port}`) - const fujiWithApi = await EVMChain.fromProvider(fujiProvider, { - apiClient: stagingApi, - logger: testLogger, - }) - const w = new Wallet(ANVIL_PRIVATE_KEY, fujiProvider) - - // Execute via messageId only — triggers API-driven path - const execution = await fujiWithApi.execute({ - messageId: V2_API_EXEC_MSG.messageId, - wallet: w, - gasLimit: 500_000, - }) + it( + 'should execute a v2.0 message via API-driven path (Arb-Sep -> Fuji)', + { timeout: 120_000 }, + async () => { + assert.ok(fujiInstance, 'fuji anvil should be running') + + // Create a fuji chain with staging API client (execution-inputs endpoint) + const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) + const fujiProvider = new JsonRpcProvider(`http://${fujiInstance.host}:${fujiInstance.port}`) + const fujiWithApi = await EVMChain.fromProvider(fujiProvider, { + apiClient: stagingApi, + logger: testLogger, + }) + const w = new Wallet(ANVIL_PRIVATE_KEY, fujiProvider) - console.log( - ` executed ${V2_API_EXEC_MSG.messageId.slice(0, 10)}… via API → state=${execution.receipt.state}`, - ) - assert.equal( - execution.receipt.messageId, - V2_API_EXEC_MSG.messageId, - 'receipt messageId should match', - ) - assert.ok(execution.log.transactionHash, 'should have tx hash') - assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') - assert.equal(execution.receipt.state, ExecutionState.Success) + // Execute via messageId only — triggers API-driven path + const execution = await fujiWithApi.execute({ + messageId: V2_API_EXEC_MSG.messageId, + wallet: w, + gasLimit: 500_000, + }) - fujiWithApi.provider.destroy() - }) + console.log( + ` executed ${V2_API_EXEC_MSG.messageId.slice(0, 10)}… via API → state=${execution.receipt.state}`, + ) + assert.equal( + execution.receipt.messageId, + V2_API_EXEC_MSG.messageId, + 'receipt messageId should match', + ) + assert.ok(execution.log.transactionHash, 'should have tx hash') + assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') + assert.equal(execution.receipt.state, ExecutionState.Success) + + fujiWithApi.provider.destroy() + }, + ) - it('should execute a v1.5 message via API-driven path (Sepolia -> Fuji)', async () => { - assert.ok(fujiInstance, 'fuji anvil should be running') + it( + 'should execute a v1.5 message via API-driven path (Sepolia -> Fuji)', + { timeout: 120_000 }, + async () => { + assert.ok(fujiInstance, 'fuji anvil should be running') - const messageId = '0xe654dc68b4d98e8ea2f182ee45d5766af4f62e2417395153a90c4b377d3fcd07' + const messageId = '0xe654dc68b4d98e8ea2f182ee45d5766af4f62e2417395153a90c4b377d3fcd07' - const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) - const fujiProvider = new JsonRpcProvider(`http://${fujiInstance.host}:${fujiInstance.port}`) - const fujiWithApi = await EVMChain.fromProvider(fujiProvider, { - apiClient: stagingApi, - logger: testLogger, - }) - const w = new Wallet(ANVIL_PRIVATE_KEY, fujiProvider) + const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) + const fujiProvider = new JsonRpcProvider(`http://${fujiInstance.host}:${fujiInstance.port}`) + const fujiWithApi = await EVMChain.fromProvider(fujiProvider, { + apiClient: stagingApi, + logger: testLogger, + }) + const w = new Wallet(ANVIL_PRIVATE_KEY, fujiProvider) - const execution = await fujiWithApi.execute({ - messageId, - wallet: w, - gasLimit: 500_000, - }) + const execution = await fujiWithApi.execute({ + messageId, + wallet: w, + gasLimit: 500_000, + }) - console.log( - ` executed ${messageId.slice(0, 10)}… via API (v1.5 Sepolia→Fuji) → state=${execution.receipt.state}`, - ) - assert.equal(execution.receipt.messageId, messageId, 'receipt messageId should match') - assert.ok(execution.log.transactionHash, 'should have tx hash') - assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') - assert.equal(execution.receipt.state, ExecutionState.Success) + console.log( + ` executed ${messageId.slice(0, 10)}… via API (v1.5 Sepolia→Fuji) → state=${execution.receipt.state}`, + ) + assert.equal(execution.receipt.messageId, messageId, 'receipt messageId should match') + assert.ok(execution.log.transactionHash, 'should have tx hash') + assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') + assert.equal(execution.receipt.state, ExecutionState.Success) - fujiWithApi.provider.destroy() - }) + fujiWithApi.provider.destroy() + }, + ) // TON-source messages were historically problematic due to data quality issues // on AtlasDB. This test verifies the API workaround that resolves the issue. - it('should execute a TON-source message via API-driven path (TON -> Sepolia)', async () => { - assert.ok(sepoliaInstance, 'sepolia anvil should be running') - - const messageId = '0xe913d21d8bc14316286646539db34bc7dd14b11c6ae3b0c307e7e52f6af02805' - - const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) - const sepoliaProvider = new JsonRpcProvider( - `http://${sepoliaInstance.host}:${sepoliaInstance.port}`, - ) - const sepoliaWithApi = await EVMChain.fromProvider(sepoliaProvider, { - apiClient: stagingApi, - logger: testLogger, - }) - const w = new Wallet(ANVIL_PRIVATE_KEY, sepoliaProvider) + it( + 'should execute a TON-source message via API-driven path (TON -> Sepolia)', + { timeout: 120_000 }, + async () => { + assert.ok(sepoliaInstance, 'sepolia anvil should be running') + + const messageId = '0xe913d21d8bc14316286646539db34bc7dd14b11c6ae3b0c307e7e52f6af02805' + + const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) + const sepoliaProvider = new JsonRpcProvider( + `http://${sepoliaInstance.host}:${sepoliaInstance.port}`, + ) + const sepoliaWithApi = await EVMChain.fromProvider(sepoliaProvider, { + apiClient: stagingApi, + logger: testLogger, + }) + const w = new Wallet(ANVIL_PRIVATE_KEY, sepoliaProvider) - // Execute via messageId only — triggers API-driven path - const execution = await sepoliaWithApi.execute({ - messageId, - wallet: w, - gasLimit: 500_000, - }) + // Execute via messageId only — triggers API-driven path + const execution = await sepoliaWithApi.execute({ + messageId, + wallet: w, + gasLimit: 500_000, + }) - console.log( - ` executed ${messageId.slice(0, 10)}… via API (TON source) → state=${execution.receipt.state}`, - ) - assert.equal(execution.receipt.messageId, messageId, 'receipt messageId should match') - assert.ok(execution.log.transactionHash, 'should have tx hash') - assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') - assert.equal(execution.receipt.state, ExecutionState.Success) + console.log( + ` executed ${messageId.slice(0, 10)}… via API (TON source) → state=${execution.receipt.state}`, + ) + assert.equal(execution.receipt.messageId, messageId, 'receipt messageId should match') + assert.ok(execution.log.transactionHash, 'should have tx hash') + assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') + assert.equal(execution.receipt.state, ExecutionState.Success) - sepoliaWithApi.provider.destroy() - }) + sepoliaWithApi.provider.destroy() + }, + ) // Another problematic TON-source message with gasLimit=1 and data payload. // Validates the API-driven manual execution path for TON → Sepolia. - it('should execute a problematic TON-source message via API-driven path (TON -> Sepolia, gasLimit=1)', async () => { - assert.ok(sepoliaInstance, 'sepolia anvil should be running') - - const msg = TON_TO_SEPOLIA[0]! - - const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) - const sepoliaProvider = new JsonRpcProvider( - `http://${sepoliaInstance.host}:${sepoliaInstance.port}`, - ) - const sepoliaWithApi = await EVMChain.fromProvider(sepoliaProvider, { - apiClient: stagingApi, - logger: testLogger, - }) - const w = new Wallet(ANVIL_PRIVATE_KEY, sepoliaProvider) + it( + 'should execute a problematic TON-source message via API-driven path (TON -> Sepolia, gasLimit=1)', + { timeout: 120_000 }, + async () => { + assert.ok(sepoliaInstance, 'sepolia anvil should be running') + + const msg = TON_TO_SEPOLIA[0]! + + const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) + const sepoliaProvider = new JsonRpcProvider( + `http://${sepoliaInstance.host}:${sepoliaInstance.port}`, + ) + const sepoliaWithApi = await EVMChain.fromProvider(sepoliaProvider, { + apiClient: stagingApi, + logger: testLogger, + }) + const w = new Wallet(ANVIL_PRIVATE_KEY, sepoliaProvider) - const execution = await sepoliaWithApi.execute({ - messageId: msg.messageId, - wallet: w, - gasLimit: 500_000, - }) + const execution = await sepoliaWithApi.execute({ + messageId: msg.messageId, + wallet: w, + gasLimit: 500_000, + }) - console.log( - ` executed ${msg.messageId.slice(0, 10)}… via API (TON source, gasLimit=1) → state=${execution.receipt.state}`, - ) - assert.equal(execution.receipt.messageId, msg.messageId, 'receipt messageId should match') - assert.ok(execution.log.transactionHash, 'should have tx hash') - assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') - assert.equal(execution.receipt.state, ExecutionState.Success) + console.log( + ` executed ${msg.messageId.slice(0, 10)}… via API (TON source, gasLimit=1) → state=${execution.receipt.state}`, + ) + assert.equal(execution.receipt.messageId, msg.messageId, 'receipt messageId should match') + assert.ok(execution.log.transactionHash, 'should have tx hash') + assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') + assert.equal(execution.receipt.state, ExecutionState.Success) - sepoliaWithApi.provider.destroy() - }) + sepoliaWithApi.provider.destroy() + }, + ) // Solana Devnet → Sepolia message whose lane.version reported by the API is "1.6.2". // The CCIPVersion enum only knows "1.6.0", so the API-driven manual-exec codepath // must normalize patch-level versions to avoid breaking downstream handling // (e.g. leaf hasher selection in calculateManualExecProof). - it('should execute a Solana-source message via API-driven path (Solana Devnet -> Sepolia)', async () => { - assert.ok(sepoliaInstance, 'sepolia anvil should be running') - - const msg = SOLANA_DEVNET_TO_SEPOLIA[0]! + it( + 'should execute a Solana-source message via API-driven path (Solana Devnet -> Sepolia)', + { timeout: 120_000 }, + async () => { + assert.ok(sepoliaInstance, 'sepolia anvil should be running') + + const msg = SOLANA_DEVNET_TO_SEPOLIA[0]! + + const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) + const sepoliaProvider = new JsonRpcProvider( + `http://${sepoliaInstance.host}:${sepoliaInstance.port}`, + ) + const sepoliaWithApi = await EVMChain.fromProvider(sepoliaProvider, { + apiClient: stagingApi, + logger: testLogger, + }) + const w = new Wallet(ANVIL_PRIVATE_KEY, sepoliaProvider) - const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) - const sepoliaProvider = new JsonRpcProvider( - `http://${sepoliaInstance.host}:${sepoliaInstance.port}`, - ) - const sepoliaWithApi = await EVMChain.fromProvider(sepoliaProvider, { - apiClient: stagingApi, - logger: testLogger, - }) - const w = new Wallet(ANVIL_PRIVATE_KEY, sepoliaProvider) + const execution = await sepoliaWithApi.execute({ + messageId: msg.messageId, + wallet: w, + gasLimit: 500_000, + }) - const execution = await sepoliaWithApi.execute({ - messageId: msg.messageId, - wallet: w, - gasLimit: 500_000, - }) + console.log( + ` executed ${msg.messageId.slice(0, 10)}… via API (Solana source) → state=${execution.receipt.state}`, + ) + assert.equal(execution.receipt.messageId, msg.messageId, 'receipt messageId should match') + assert.ok(execution.log.transactionHash, 'should have tx hash') + assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') + assert.equal(execution.receipt.state, ExecutionState.Success) - console.log( - ` executed ${msg.messageId.slice(0, 10)}… via API (Solana source) → state=${execution.receipt.state}`, - ) - assert.equal(execution.receipt.messageId, msg.messageId, 'receipt messageId should match') - assert.ok(execution.log.transactionHash, 'should have tx hash') - assert.ok(execution.log.blockTimestamp > 0, 'should have timestamp') - assert.equal(execution.receipt.state, ExecutionState.Success) - - sepoliaWithApi.provider.destroy() - }) + sepoliaWithApi.provider.destroy() + }, + ) }) describe('ViemTransportProvider — revert data forwarding', () => { @@ -637,113 +688,121 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { // used elsewhere in the suite holds cached nonces from prior tests, which become // stale once other chains on the same fork consume nonces for this wallet. - it('decodes TokenMaxCapacityExceeded custom error on pool-capacity revert', async () => { - assert.ok(sepoliaInstance, 'sepolia anvil should be running') - const anvilUrl = `http://${sepoliaInstance.host}:${sepoliaInstance.port}` - const walletAddress = await wallet.getAddress() - - // Amount = 10× pool's outbound capacity (see overCapacityAmount). Dynamic so the - // test survives rate-limit reconfig upstream. `sendMessage` handles approve internally. - const oversizedAmount = await overCapacityAmount() - const ethersProvider = wallet.provider as JsonRpcProvider - await setERC20Balance(ethersProvider, FTF_TOKEN_SEPOLIA, walletAddress, oversizedAmount) - - // Build a viem PublicClient pointed at the same Anvil fork, wrap with - // ViemTransportProvider, and bind a Wallet to it. Every RPC call - // (estimateGas, eth_call, eth_sendTransaction) now flows through the adapter. - const viemClient = createPublicClient({ - chain: { id: SEPOLIA_CHAIN_ID, name: 'Sepolia Fork' } as never, - transport: http(anvilUrl), - }) - const viemProvider = new ViemTransportProvider(viemClient as never) - const viemWallet = new Wallet(ANVIL_PRIVATE_KEY, viemProvider) - const viemChain = await EVMChain.fromProvider(viemProvider, { - apiClient: null, - logger: testLogger, - }) - // Bypass SDK preflight so the on-chain TokenMaxCapacityExceeded revert reaches EVMChain.parse. - viemChain.checkSendMessage = async () => true as const - - let caught: unknown - try { - await viemChain.sendMessage({ - router: SEPOLIA_V2_0_ROUTER, - destChainSelector: FUJI_SELECTOR, - message: { - receiver: walletAddress, - tokenAmounts: [{ token: FTF_TOKEN_SEPOLIA, amount: oversizedAmount }], - extraArgs: { gasLimit: 0n }, - }, - wallet: viemWallet, + it( + 'decodes TokenMaxCapacityExceeded custom error on pool-capacity revert', + { timeout: 60_000 }, + async () => { + assert.ok(sepoliaInstance, 'sepolia anvil should be running') + const anvilUrl = `http://${sepoliaInstance.host}:${sepoliaInstance.port}` + const walletAddress = await wallet.getAddress() + + // Amount = 10× pool's outbound capacity (see overCapacityAmount). Dynamic so the + // test survives rate-limit reconfig upstream. `sendMessage` handles approve internally. + const oversizedAmount = await overCapacityAmount() + const ethersProvider = wallet.provider as JsonRpcProvider + await setERC20Balance(ethersProvider, FTF_TOKEN_SEPOLIA, walletAddress, oversizedAmount) + + // Build a viem PublicClient pointed at the same Anvil fork, wrap with + // ViemTransportProvider, and bind a Wallet to it. Every RPC call + // (estimateGas, eth_call, eth_sendTransaction) now flows through the adapter. + const viemClient = createPublicClient({ + chain: { id: SEPOLIA_CHAIN_ID, name: 'Sepolia Fork' } as never, + transport: http(anvilUrl), }) - } catch (err) { - caught = err - } - - assert.ok(caught, 'sendMessage should throw on over-capacity amount') - - const parsed = EVMChain.parse(caught) - assert.ok(parsed, 'EVMChain.parse should return a decoded envelope') - - const flat = stringifyParsed(parsed) - assert.match( - flat, - /TokenMaxCapacityExceeded/, - `viem-adapter path should surface the decoded custom error name. Parsed: ${flat}`, - ) - - viemChain.provider.destroy() - }) + const viemProvider = new ViemTransportProvider(viemClient as never) + const viemWallet = new Wallet(ANVIL_PRIVATE_KEY, viemProvider) + const viemChain = await EVMChain.fromProvider(viemProvider, { + apiClient: null, + logger: testLogger, + }) + // Bypass SDK preflight so the on-chain TokenMaxCapacityExceeded revert reaches EVMChain.parse. + viemChain.checkSendMessage = async () => true as const + + let caught: unknown + try { + await viemChain.sendMessage({ + router: SEPOLIA_V2_0_ROUTER, + destChainSelector: FUJI_SELECTOR, + message: { + receiver: walletAddress, + tokenAmounts: [{ token: FTF_TOKEN_SEPOLIA, amount: oversizedAmount }], + extraArgs: { gasLimit: 0n }, + }, + wallet: viemWallet, + }) + } catch (err) { + caught = err + } + + assert.ok(caught, 'sendMessage should throw on over-capacity amount') + + const parsed = EVMChain.parse(caught) + assert.ok(parsed, 'EVMChain.parse should return a decoded envelope') + + const flat = stringifyParsed(parsed) + assert.match( + flat, + /TokenMaxCapacityExceeded/, + `viem-adapter path should surface the decoded custom error name. Parsed: ${flat}`, + ) + + viemChain.provider.destroy() + }, + ) // Cross-check: same over-capacity send via the ethers-direct chain (sepoliaChain) // must produce an equivalent decoded output. Proves the viem adapter achieves // functional parity with the ethers-direct baseline. - it('produces equivalent decoded output on ethers-direct path', async () => { - assert.ok(sepoliaInstance, 'sepolia anvil should be running') - const anvilUrl = `http://${sepoliaInstance.host}:${sepoliaInstance.port}` - const walletAddress = await wallet.getAddress() - - const oversizedAmount = await overCapacityAmount() - const ethersProvider = wallet.provider as JsonRpcProvider - await setERC20Balance(ethersProvider, FTF_TOKEN_SEPOLIA, walletAddress, oversizedAmount) - - // Dedicated EVMChain for this test (see describe-block preamble). - const ethersChainLocal = await EVMChain.fromProvider(new JsonRpcProvider(anvilUrl), { - apiClient: null, - logger: testLogger, - }) - // Bypass SDK preflight so the on-chain TokenMaxCapacityExceeded revert reaches EVMChain.parse. - ethersChainLocal.checkSendMessage = async () => true as const - const ethersWalletLocal = new Wallet(ANVIL_PRIVATE_KEY, ethersChainLocal.provider) - - let caught: unknown - try { - await ethersChainLocal.sendMessage({ - router: SEPOLIA_V2_0_ROUTER, - destChainSelector: FUJI_SELECTOR, - message: { - receiver: walletAddress, - tokenAmounts: [{ token: FTF_TOKEN_SEPOLIA, amount: oversizedAmount }], - extraArgs: { gasLimit: 0n }, - }, - wallet: ethersWalletLocal, + it( + 'produces equivalent decoded output on ethers-direct path', + { timeout: 60_000 }, + async () => { + assert.ok(sepoliaInstance, 'sepolia anvil should be running') + const anvilUrl = `http://${sepoliaInstance.host}:${sepoliaInstance.port}` + const walletAddress = await wallet.getAddress() + + const oversizedAmount = await overCapacityAmount() + const ethersProvider = wallet.provider as JsonRpcProvider + await setERC20Balance(ethersProvider, FTF_TOKEN_SEPOLIA, walletAddress, oversizedAmount) + + // Dedicated EVMChain for this test (see describe-block preamble). + const ethersChainLocal = await EVMChain.fromProvider(new JsonRpcProvider(anvilUrl), { + apiClient: null, + logger: testLogger, }) - } catch (err) { - caught = err - } - - assert.ok(caught, 'sendMessage should throw on oversized amount (ethers-direct)') - const parsed = EVMChain.parse(caught) - assert.ok(parsed, 'EVMChain.parse should decode the revert on ethers-direct path') - - const flat = stringifyParsed(parsed) - assert.match( - flat, - /TokenMaxCapacityExceeded/, - `ethers-direct path should surface the decoded custom error name. Parsed: ${flat}`, - ) - - ethersChainLocal.provider.destroy() - }) + // Bypass SDK preflight so the on-chain TokenMaxCapacityExceeded revert reaches EVMChain.parse. + ethersChainLocal.checkSendMessage = async () => true as const + const ethersWalletLocal = new Wallet(ANVIL_PRIVATE_KEY, ethersChainLocal.provider) + + let caught: unknown + try { + await ethersChainLocal.sendMessage({ + router: SEPOLIA_V2_0_ROUTER, + destChainSelector: FUJI_SELECTOR, + message: { + receiver: walletAddress, + tokenAmounts: [{ token: FTF_TOKEN_SEPOLIA, amount: oversizedAmount }], + extraArgs: { gasLimit: 0n }, + }, + wallet: ethersWalletLocal, + }) + } catch (err) { + caught = err + } + + assert.ok(caught, 'sendMessage should throw on oversized amount (ethers-direct)') + const parsed = EVMChain.parse(caught) + assert.ok(parsed, 'EVMChain.parse should decode the revert on ethers-direct path') + + const flat = stringifyParsed(parsed) + assert.match( + flat, + /TokenMaxCapacityExceeded/, + `ethers-direct path should surface the decoded custom error name. Parsed: ${flat}`, + ) + + ethersChainLocal.provider.destroy() + }, + ) }) }) diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 50820dcb..a3da8c1f 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/evm/integration.test.ts b/ccip-sdk/src/evm/integration.test.ts index e84ccb90..ad48b839 100644 --- a/ccip-sdk/src/evm/integration.test.ts +++ b/ccip-sdk/src/evm/integration.test.ts @@ -55,14 +55,22 @@ const BASE_SEP_V2_0_ROUTER = '0x0Ec6D443B425982f1F2862Dd0ffBFD431FCb6b8b' // ── Destination selectors (no RPC needed: every test below is a source-side eth_call) ── // -// Live OnRamp generations, as reported by `typeAndVersion` on the resolved OnRamp: -// Base Sepolia → Chiado EVM2EVMOnRamp 1.5.0 -// Base Sepolia → Unichain Sep. OnRamp 1.6.0 -// Base Sepolia → OP Sepolia OnRamp 2.0.0 -// OP Sepolia → Chiado EVM2EVMOnRamp 1.5.0 -// OP Sepolia → WEMIX testnet OnRamp 1.6.0 -// OP Sepolia → Base Sepolia OnRamp 2.0.0 +// Live OnRamp generations, as reported by `typeAndVersion` on the resolved OnRamp +// (verified 2026-09-10). NOTE: lanes migrate to newer OnRamp deployments over time +// (Base Sepolia → Chiado went EVM2EVMOnRamp 1.5.0 → OnRamp 2.0.0 in Sep 2026), so +// tests that exercise a generation-specific surface must resolve a live lane of that +// generation at runtime via findLegacyV1_5Lane() instead of pinning a destination: +// Base Sepolia → Fuji EVM2EVMOnRamp 1.5.0 +// Base Sepolia → BSC testnet EVM2EVMOnRamp 1.5.0 +// Base Sepolia → Chiado OnRamp 2.0.0 (migrated from 1.5.0) +// Base Sepolia → Unichain Sep. OnRamp 2.0.0 (migrated from 1.6.0) +// Base Sepolia → OP Sepolia OnRamp 2.0.0 +// OP Sepolia → Chiado EVM2EVMOnRamp 1.5.0 +// OP Sepolia → WEMIX testnet OnRamp 1.6.0 +// OP Sepolia → Base Sepolia OnRamp 2.0.0 const CHIADO_SELECTOR = 8871595565390010547n +const FUJI_SELECTOR = 14767482510784806043n +const BSC_TESTNET_SELECTOR = 13264668187771770619n const UNICHAIN_SEP_SELECTOR = 14135854469784514356n const WEMIX_SELECTOR = 9284632837123596123n // Destinations of the CCIP 2.0 deployment reachable from BASE_SEP_V2_0_ROUTER. @@ -71,8 +79,8 @@ const AMOY_SELECTOR = 16281711391670634445n // ── Token / pool constants ── -// CCIP-BnM on Base Sepolia — transferable on the v1.5 Base Sepolia→Chiado lane, served -// by a legacy (BurnMintTokenPool 1.5.1) pool. +// CCIP-BnM on Base Sepolia — served by a legacy (BurnMintTokenPool 1.5.1) pool on +// base-sepolia (the lane's OnRamp itself migrated to 2.0.0, but the pool did not). const CCIP_BNM_TOKEN_BASE_SEP = '0x88A2d74F47a237a62e7A51cdDa67270CE381555e' // v2.0 pool (BurnMintTokenPool 2.0.0, supportsInterface(IPoolV2) == true) with FTF @@ -97,6 +105,66 @@ if (!process.env.VERBOSE) testLogger.debug = () => {} describe('EVM Integration Tests', { skip, timeout: 180_000 }, () => { let baseSepChain: EVMChain | undefined let opSepChain: EVMChain | undefined + + // ── Legacy v1.5 lane discovery ── + // + // The v1.5-specific surfaces below (getFeeTokens addressed by OnRamp, pre-v2.0 fee + // short-circuit) need a lane whose OnRamp is still EVM2EVMOnRamp 1.5.x. Lanes get + // migrated to newer OnRamp deployments over time, so resolve one at runtime from a + // candidate list instead of pinning a destination (candidates verified against the + // live routers when last touched — see the lane table above). Destination selectors + // are source-side eth_call constants, so this adds no destination-RPC traffic and no + // cross-suite contention. + const LEGACY_V1_5_CANDIDATES = [ + { + chain: () => baseSepChain, + router: BASE_SEP_ROUTER, + dest: FUJI_SELECTOR, + label: 'base-sepolia → fuji', + }, + { + chain: () => baseSepChain, + router: BASE_SEP_ROUTER, + dest: BSC_TESTNET_SELECTOR, + label: 'base-sepolia → bsc-testnet', + }, + { + chain: () => opSepChain, + router: OP_SEP_ROUTER, + dest: CHIADO_SELECTOR, + label: 'op-sepolia → chiado', + }, + ] as const + + let legacyV1_5Lane: + | { + chain: EVMChain + router: string + dest: bigint + label: string + onRamp: string + } + | undefined + + async function findLegacyV1_5Lane() { + if (legacyV1_5Lane) return legacyV1_5Lane + for (const candidate of LEGACY_V1_5_CANDIDATES) { + const chain = candidate.chain() + if (!chain) continue + const onRamp = await chain.getOnRampForRouter(candidate.router, candidate.dest) + const [type, version] = await chain.typeAndVersion(onRamp) + if (type === 'EVM2EVMOnRamp' && version.startsWith('1.5')) { + const found = { ...candidate, chain, onRamp } + legacyV1_5Lane = found + return found + } + testLogger.debug(` legacy-lane candidate ${candidate.label} migrated: ${type} ${version}`) + } + assert.fail( + 'no EVM2EVMOnRamp 1.5.x lane remains on the base/op-sepolia routers; ' + + 'update LEGACY_V1_5_CANDIDATES (query each router getOnRamp + typeAndVersion)', + ) + } let wallet: Wallet before(async () => { @@ -360,27 +428,29 @@ describe('EVM Integration Tests', { skip, timeout: 180_000 }, () => { // v1.5 resolves the PriceRegistry from the OnRamp's dynamic config and calls // getFeeTokens() directly — a single state read, no block-range event scan. // Addressed by OnRamp (not Router) so the v1.5 path is exercised regardless of - // which lane the Router's resolver happens to pick. - it('should return fee tokens for a v1.5 OnRamp on base-sepolia', async () => { - assert.ok(baseSepChain, 'base-sepolia chain should be initialized') + // which lane the Router's resolver happens to pick. The lane is discovered at + // runtime because v1.5 lanes keep migrating (see LEGACY_V1_5_CANDIDATES). + it('should return fee tokens for a v1.5 OnRamp', async () => { + const lane = await findLegacyV1_5Lane() - // EVM2EVMOnRamp 1.5.0 of the Base Sepolia → Chiado lane - const v1_5OnRamp = await baseSepChain.getOnRampForRouter(BASE_SEP_ROUTER, CHIADO_SELECTOR) - const [type, version] = await baseSepChain.typeAndVersion(v1_5OnRamp) - assert.equal(type, 'EVM2EVMOnRamp', 'base-sepolia → chiado should be a legacy OnRamp') - assert.ok(version.startsWith('1.5'), `expected a v1.5 OnRamp, got ${version}`) - - const feeTokens = await baseSepChain.getFeeTokens(v1_5OnRamp) + const feeTokens = await lane.chain.getFeeTokens(lane.onRamp) const entries = Object.entries(feeTokens) - assert.ok(entries.length > 0, 'base-sepolia v1.5: should have at least one fee token') + assert.ok(entries.length > 0, `${lane.label} v1.5: should have at least one fee token`) console.log( - ` base-sepolia v1.5: ${entries.map(([a, i]) => `${i.symbol}(${a.slice(0, 8)}…)`).join(', ')}`, + ` ${lane.label} v1.5: ${entries.map(([a, i]) => `${i.symbol}(${a.slice(0, 8)}…)`).join(', ')}`, ) for (const [address, info] of entries) { - assert.match(address, /^0x[0-9a-fA-F]{40}$/, `v1.5: token address should be valid`) - assert.ok(info.symbol.length > 0, `v1.5: ${address} should have a symbol`) - assert.ok(info.decimals >= 0, `v1.5: ${address} should have non-negative decimals`) + assert.match( + address, + /^0x[0-9a-fA-F]{40}$/, + `${lane.label} v1.5: token address should be valid`, + ) + assert.ok(info.symbol.length > 0, `${lane.label} v1.5: ${address} should have a symbol`) + assert.ok( + info.decimals >= 0, + `${lane.label} v1.5: ${address} should have non-negative decimals`, + ) } }) }) @@ -479,20 +549,23 @@ describe('EVM Integration Tests', { skip, timeout: 180_000 }, () => { extraArgs: { gasLimit: 200_000n, allowOutOfOrderExecution: true }, } + // v1.5 lane discovered at runtime — the pinned chiado lanes migrated to 2.0.0 + const legacy = await findLegacyV1_5Lane() + const cases = [ { - chain: baseSepChain, - router: BASE_SEP_ROUTER, - dest: CHIADO_SELECTOR, + chain: legacy.chain, + router: legacy.router, + dest: legacy.dest, message: manualMessage, - label: 'base-sepolia v1.5', + label: `${legacy.label} v1.5`, }, { chain: baseSepChain, router: BASE_SEP_ROUTER, dest: UNICHAIN_SEP_SELECTOR, message: builtMessage, - label: 'base-sepolia v1.6', + label: 'base-sepolia → unichain (v2.0)', }, { chain: baseSepChain, @@ -508,13 +581,6 @@ describe('EVM Integration Tests', { skip, timeout: 180_000 }, () => { message: builtMessage, label: 'base-sepolia v2.0 (2.0 router)', }, - { - chain: opSepChain, - router: OP_SEP_ROUTER, - dest: CHIADO_SELECTOR, - message: manualMessage, - label: 'op-sepolia v1.5', - }, { chain: opSepChain, router: OP_SEP_ROUTER, @@ -707,7 +773,10 @@ describe('EVM Integration Tests', { skip, timeout: 180_000 }, () => { ) }) - it('should return RATE_LIMITS for v1.5 lane with token (legacy pool)', async () => { + // The chiado lane's OnRamp migrated to OnRamp 2.0.0, but its CCIP-BnM pool is still + // a legacy (pre-v2, FTF-less) BurnMintTokenPool — which is what this exercises: a + // lane whose POLE predates FTF must surface RATE_LIMITS and neither FTF feature. + it('should return RATE_LIMITS and no FTF features for a legacy (pre-v2) token pool', async () => { assert.ok(baseSepChain, 'base-sepolia chain should be initialized') const features = await baseSepChain.getLaneFeatures({ @@ -719,11 +788,14 @@ describe('EVM Integration Tests', { skip, timeout: 180_000 }, () => { assert.equal( features[LaneFeature.FINALITY_FAST], undefined, - 'v1.5 lane should not include FINALITY_FAST (FTF does not exist pre-v2.0)', + 'legacy pool should not include FINALITY_FAST (FTF does not exist pre-v2 pools)', ) // Legacy pool should expose RATE_LIMITS via getCurrentOutboundRateLimiterState - assert.ok(LaneFeature.RATE_LIMITS in features, 'v1.5 lane with token should have RATE_LIMITS') + assert.ok( + LaneFeature.RATE_LIMITS in features, + 'lane with legacy pool should have RATE_LIMITS', + ) const rateLimits = features[LaneFeature.RATE_LIMITS] if (rateLimits != null) { assert.equal(typeof rateLimits.tokens, 'bigint', 'tokens should be bigint') @@ -731,11 +803,11 @@ describe('EVM Integration Tests', { skip, timeout: 180_000 }, () => { assert.equal(typeof rateLimits.rate, 'bigint', 'rate should be bigint') } - // FTF doesn't exist on legacy lanes → no FAST_RATE_LIMITS + // FTF doesn't exist on legacy pools → no FAST_RATE_LIMITS assert.equal( LaneFeature.FAST_RATE_LIMITS in features, false, - 'legacy lane should not have FAST_RATE_LIMITS', + 'legacy pool should not have FAST_RATE_LIMITS', ) }) @@ -946,13 +1018,20 @@ describe('EVM Integration Tests', { skip, timeout: 180_000 }, () => { console.log(` value = ${tf.feeDeducted} (${tf.bps} bps)`) }) - it('should return ccipFee only for pre-v2.0 lane with token transfer', async () => { + it('should return ccipFee only for pre-v2.0 lanes with token transfer', async () => { assert.ok(baseSepChain, 'base-sepolia chain should be initialized') + // Discovered at runtime: the chiado lanes migrated to 2.0.0, where this + // assertion now holds only because the CCIP-BnM POOL is still legacy — pin to a + // true pre-v2.0 lane so the SDK's version short-circuit itself is exercised. + // NOTE: CCIP_BNM_TOKEN_BASE_SEP is a base-sepolia token address; if the + // discovery ever lands on an op-sepolia candidate, this test must be repointed + // to a token supported on that chain. + const legacy = await findLegacyV1_5Lane() const amount = 1_000_000n - const estimate = await baseSepChain.getTotalFeesEstimate({ - router: BASE_SEP_ROUTER, - destChainSelector: CHIADO_SELECTOR, + const estimate = await legacy.chain.getTotalFeesEstimate({ + router: legacy.router, + destChainSelector: legacy.dest, message: { receiver: '0x0000000000000000000000000000000000000001', tokenAmounts: [{ token: CCIP_BNM_TOKEN_BASE_SEP, amount }], diff --git a/ccip-sdk/src/fetch.test.ts b/ccip-sdk/src/fetch.test.ts index 33c3301c..031f5d40 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 885c8b02..442dd7d2 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/index.ts b/ccip-sdk/src/index.ts index 349b8519..00be7e50 100644 --- a/ccip-sdk/src/index.ts +++ b/ccip-sdk/src/index.ts @@ -109,6 +109,7 @@ export { isSupportedTxHash, jsonParse, jsonStringify, + linkAbortSignals, signalToPromise, withRetry, } from './utils.ts' diff --git a/ccip-sdk/src/selectors.ts b/ccip-sdk/src/selectors.ts index 3a5f6e64..f9cc7f30 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': { @@ -1752,6 +1757,12 @@ const SELECTORS: Selectors = { network_type: 'TESTNET', family: 'EVM', }, + '4103882950': { + selector: 18362000170840307455n, + name: 'private-testnet-basalt', + network_type: 'TESTNET', + family: 'EVM', + }, '7052886157': { selector: 410896468069059699n, name: 'glamsterdam-devnet-6', @@ -1765,6 +1776,12 @@ const SELECTORS: Selectors = { deprecated: true, family: 'EVM', }, + '7091047534': { + selector: 12540949017250913122n, + name: 'ethereum-testnet-plataberget', + network_type: 'TESTNET', + family: 'EVM', + }, // end:generate // generate: diff --git a/ccip-sdk/src/solana/fork.test.ts b/ccip-sdk/src/solana/fork.test.ts index ad201ebf..b5598156 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/index.ts b/ccip-sdk/src/solana/index.ts index e631441b..dbeb52df 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 00000000..feee9892 --- /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/ccip-sdk/src/solana/utils.ts b/ccip-sdk/src/solana/utils.ts index 5c5eb5ae..78d44755 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 00000000..18d53209 --- /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 00000000..20f9f1f6 --- /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 } diff --git a/ccip-sdk/src/utils.test.ts b/ccip-sdk/src/utils.test.ts index c0d4820c..dea6ec3b 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 8e2dca9f..72c815d5 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. * diff --git a/package-lock.json b/package-lock.json index 3435d0ad..7322436a 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,8 +93,8 @@ "@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", - "@solana/web3.js": "^1.98.4", + "@mysten/sui": "^2.26.2", + "@solana/web3.js": "^1.99.0", "@ton-community/ton-ledger": "^7.3.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^16.3.0", @@ -157,11 +157,11 @@ "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", + "@solana/web3.js": "^1.99.0", "@ton/core": "0.63.1", "@ton/ton": "^16.3.0", "abitype": "1.3.0", @@ -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" @@ -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"