From 29e3958b3afaf661a0830cd4024e9502226567f0 Mon Sep 17 00:00:00 2001 From: Zubin Pratap Date: Wed, 19 Mar 2025 18:56:57 +1100 Subject: [PATCH 1/7] add helper to test helpers that prefixes 0x to Private Keys that dont have the prefix --- packages/ccip-js/test/helpers/constants.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ccip-js/test/helpers/constants.ts b/packages/ccip-js/test/helpers/constants.ts index 8cb0b4a..f405852 100644 --- a/packages/ccip-js/test/helpers/constants.ts +++ b/packages/ccip-js/test/helpers/constants.ts @@ -11,9 +11,16 @@ import priceRegistryJson from '../../artifacts-compile/PriceRegistry.json' // replace with your own private key (optional) dotenv.config() -// default anvil PK +if (!process.env.PRIVATE_KEY) { + console.warn('No PRIVATE_KEY found in .env file, using default anvil PK') +} + +if (!process.env.PRIVATE_KEY?.startsWith('0x')) { + process.env.PRIVATE_KEY = '0x' + process.env.PRIVATE_KEY +} + export const privateKey = - (process.env.PRIVATE_KEY as Hex) || '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + (process.env.PRIVATE_KEY as Hex) || '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' // default anvil PK export const account = privateKeyToAccount(privateKey) // bridge token contract From 752beee53471a5f612298c338997b550c4c22f05 Mon Sep 17 00:00:00 2001 From: Zubin Pratap Date: Mon, 31 Mar 2025 21:40:37 +1100 Subject: [PATCH 2/7] The following changes for the packages/ccip-js 1. Fix Issue#8 using suggestions from PR#7. Viem Account type used instead of type Address. 2. Add testnet integration tests of critical operations from the ccip client in the js sdk. Tests Fuji -> Sepolia 3. update README 4. add functionality relating to the PRIVATE_KEY to prefix with '0x' if needed for better devX 5. minor fix the the js docs for getTokenAdminRegistry(). Corrected description of return data. --- packages/ccip-js/README.md | 15 +- packages/ccip-js/package.json | 2 +- packages/ccip-js/src/api.ts | 9 +- packages/ccip-js/test/helpers/constants.ts | 24 +- .../ccip-js/test/integration-testnet.test.ts | 296 ++++++++++++++++++ 5 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 packages/ccip-js/test/integration-testnet.test.ts diff --git a/packages/ccip-js/README.md b/packages/ccip-js/README.md index 3cc7892..5960bee 100644 --- a/packages/ccip-js/README.md +++ b/packages/ccip-js/README.md @@ -1,6 +1,5 @@ # CCIP-JS - CCIP-JS is a TypeScript library that provides a client for managing cross-chain token transfers that use Chainlink's [Cross-Chain Interoperability Protocol (CCIP)](https://docs.chain.link/ccip) routers. The library utilizes types and helper functions from [Viem](https://viem.sh/). To learn more about CCIP, refer to the [CCIP documentation](https://docs.chain.link/ccip). @@ -41,7 +40,6 @@ To learn more about CCIP, refer to the [CCIP documentation](https://docs.chain.l - [Contributing](#contributing) - [License](#license) - ## Why CCIP-JS? CCIP-JS provides ready-to-use typesafe methods for every step of the token transfer process. @@ -87,6 +85,7 @@ pnpm add @chainlink/ccip-js viem ## Usage This example code covers the following steps: + - Initialize CCIP-JS Client for mainnet - Approve tokens for transfer - Get fee for the transfer @@ -593,11 +592,13 @@ pnpm build-ccip-js #### Running tests -```sh -pnpm i -w -anvil -pnpm test -``` +1. cd into `packages/ccip-js` and then run `pnpm install` OR from the project root you can run `pnpm i -w` + +2. open a new terminal window and run `anvil` - requires that you've [installed Foundry Anvil](https://book.getfoundry.sh/anvil/). + +3. cd into `packages/ccip-js` and then run `pnpm test` + +Note: that Anvil is only needed for the tests inside `integration-mocked.test.ts` which uses the [Chainlink Local](https://github.com/smartcontractkit/chainlink-local) simulator. Actual testnet and mainnet behavior may differ from time to time and passing these tests does not guarantee testnet or mainnet behavior. ### Contributing diff --git a/packages/ccip-js/package.json b/packages/ccip-js/package.json index bebda8e..9e9855e 100644 --- a/packages/ccip-js/package.json +++ b/packages/ccip-js/package.json @@ -15,7 +15,7 @@ "lint": "eslint 'src/**/*.{ts,js}'", "format": "prettier --write 'src/**/*.{ts,js,json,md}'", "pretest": "anvil --block-time 2", - "t:int": "jest --coverage -u -t=\"Integration\"", + "t:int": "jest --coverage -u --testMatch=\"**/integration-*.test.ts\"", "t:unit": "jest --coverage -u -t=\"Unit\"", "test": "jest --coverage", "test:hh": "hardhat test" diff --git a/packages/ccip-js/src/api.ts b/packages/ccip-js/src/api.ts index 4d22722..e18d3c2 100644 --- a/packages/ccip-js/src/api.ts +++ b/packages/ccip-js/src/api.ts @@ -281,8 +281,7 @@ export interface Client { * @param {Viem.Address} options.routerAddress - The address of the router contract on the source blockchain. * @param {string} options.destinationChainSelector - The selector for the destination chain. * @param {Viem.Address} options.tokenAddress - The address of the token contract on the source blockchain. - * @returns {Promise} A promise that resolves to a boolean value indicating whether the token - * is supported on the destination chain. + * @returns {Promise} A promise that resolves to the Token Admin Registry Contract address on the source chain. * @example * import { createPublicClient, http } from 'viem' * import { mainnet } from 'viem/chains' @@ -617,7 +616,7 @@ export const createClient = (): Client => { const approveTxHash = await writeContract(options.client, { chain: options.client.chain, - account: options.client.account!.address, + account: options.client.account!, abi: IERC20ABI, address: options.tokenAddress, functionName: 'approve', @@ -852,7 +851,7 @@ export const createClient = (): Client => { address: options.routerAddress, functionName: 'ccipSend', args: buildArgs(options), - account: options.client.account!.address, + account: options.client.account!, ...(!options.feeTokenAddress && { value: await getFee(options), }), @@ -905,7 +904,7 @@ export const createClient = (): Client => { address: options.routerAddress, functionName: 'ccipSend', args: buildArgs(options), - account: options.client.account!.address, + account: options.client.account!, ...(!options.feeTokenAddress && { value: await getFee(options), }), diff --git a/packages/ccip-js/test/helpers/constants.ts b/packages/ccip-js/test/helpers/constants.ts index f405852..938dff4 100644 --- a/packages/ccip-js/test/helpers/constants.ts +++ b/packages/ccip-js/test/helpers/constants.ts @@ -7,20 +7,20 @@ import routerJson from '../../artifacts-compile/Router.json' import simulatorJson from '../../artifacts-compile/CCIPLocalSimulator.json' import priceRegistryJson from '../../artifacts-compile/PriceRegistry.json' -// load.env file for private key +// load.env file for private key // replace with your own private key (optional) dotenv.config() -if (!process.env.PRIVATE_KEY) { - console.warn('No PRIVATE_KEY found in .env file, using default anvil PK') +if (process.env.PRIVATE_KEY?.slice(0, 2) !== '0x') { + process.env.PRIVATE_KEY = `0x${process.env.PRIVATE_KEY}` } -if (!process.env.PRIVATE_KEY?.startsWith('0x')) { - process.env.PRIVATE_KEY = '0x' + process.env.PRIVATE_KEY -} +export const DEFAULT_ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +export const privateKey = ( + process.env.PRIVATE_KEY === '0x' ? DEFAULT_ANVIL_PRIVATE_KEY : process.env.PRIVATE_KEY +) as Hex -export const privateKey = - (process.env.PRIVATE_KEY as Hex) || '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' // default anvil PK export const account = privateKeyToAccount(privateKey) // bridge token contract @@ -28,8 +28,10 @@ export const { bridgeTokenAbi, bridgeTokenBin } = bridgeJson['contracts']['src/c // note: no need to deploy export const { onRampAbi, onRampBin } = onRampJson['contracts']['src/contracts/EVM2EVMOnRamp.sol:EVM2EVMOnRamp'] export const { routerAbi, routerBin } = routerJson['contracts']['src/contracts/Router.sol:Router'] -export const { simulatorAbi, simulatorBin } = simulatorJson['contracts']['src/contracts/CCIPLocalSimulator.sol:CCIPLocalSimulator'] -export const { priceRegistryAbi, priceRegistryBin } = priceRegistryJson['contracts']['src/contracts/PriceRegistry.sol:PriceRegistry'] +export const { simulatorAbi, simulatorBin } = + simulatorJson['contracts']['src/contracts/CCIPLocalSimulator.sol:CCIPLocalSimulator'] +export const { priceRegistryAbi, priceRegistryBin } = + priceRegistryJson['contracts']['src/contracts/PriceRegistry.sol:PriceRegistry'] // CCIP testing data for simulations export const ccipTxHash = '0xc55d92b1212dd24db843e1cbbcaebb1fffe3cd1751313e0fd02cf26bf72b359e' @@ -126,4 +128,4 @@ export const ccipTxReceipt: TransactionReceipt = { transactionHash: ccipTxHash, transactionIndex: 0, type: 'eip1559', -} \ No newline at end of file +} diff --git a/packages/ccip-js/test/integration-testnet.test.ts b/packages/ccip-js/test/integration-testnet.test.ts new file mode 100644 index 0000000..95d56fb --- /dev/null +++ b/packages/ccip-js/test/integration-testnet.test.ts @@ -0,0 +1,296 @@ +import { jest, expect, it, beforeAll, describe, afterAll } from '@jest/globals' +import * as CCIP from '../src/api' +import * as Viem from 'viem' +import { sepolia, avalancheFuji } from 'viem/chains' +import { privateKeyToAccount } from 'viem/accounts' +import bridgeTokenAbi from '@chainlink/contracts/abi/v0.8/BurnMintERC677.json' +import { privateKey, DEFAULT_ANVIL_PRIVATE_KEY } from './helpers/constants' +import { parseEther } from 'viem' + +const ccipSdkClient = CCIP.createClient() + +const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL +const AVALANCHE_FUJI_RPC_URL = process.env.AVALANCHE_FUJI_RPC_URL +const SEPOLIA_CHAIN_SELECTOR = '16015286601757825753' +const WRAPPED_NATIVE_AVAX = '0xd00ae08403B9bbb9124bB305C09058E32C39A48c' +const LINK_TOKEN_FUJI = '0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846' + +// 6m to match https://viem.sh/docs/actions/public/waitForTransactionReceipt.html#timeout-optional, +// which is called in approveRouter() +const TIMEOUT = 180 * 1000 // 3m + +if (!SEPOLIA_RPC_URL) { + throw new Error('SEPOLIA_RPC_URL must be set') +} +if (!AVALANCHE_FUJI_RPC_URL) { + throw new Error('AVALANCHE_FUJI_RPC_URL must be set') +} +if (privateKey === DEFAULT_ANVIL_PRIVATE_KEY) { + throw new Error( + "Developer's PRIVATE_KEY for Ethereum Sepolia and Avalanche Fuji must be set for integration testing on", + ) +} + +jest.setTimeout(TIMEOUT) +describe('Integration: Fuji -> Sepolia', () => { + let avalancheFujiClient: Viem.WalletClient + let sepoliaClient: Viem.WalletClient + let bnmToken_fuji: any + let _messageId: `0x${string}` + let ccipSend_txHash: `0x${string}` + + const AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS = '0xF694E193200268f9a4868e4Aa017A0118C9a8177' + const approvedAmount = parseEther('0.000000005') + + beforeAll(async () => { + avalancheFujiClient = Viem.createWalletClient({ + chain: avalancheFuji, + transport: Viem.http(AVALANCHE_FUJI_RPC_URL), + account: privateKeyToAccount(privateKey), + }) + + sepoliaClient = Viem.createWalletClient({ + chain: sepolia, + transport: Viem.http(SEPOLIA_RPC_URL), + account: privateKeyToAccount(privateKey), + }) + + bnmToken_fuji = Viem.getContract({ + address: '0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4', // CCIP BnM on Avalanche Fuji + abi: bridgeTokenAbi, + client: avalancheFujiClient, + }) + + expect(bnmToken_fuji.address).toEqual('0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4') + + const bnmBalance = await bnmToken_fuji.read.balanceOf([privateKeyToAccount(privateKey).address]) + if (parseInt(bnmBalance) < approvedAmount) { + await bnmToken_fuji.write.drip([privateKeyToAccount(privateKey)]) + console.log(' ℹ️ | Dripped 1 CCIP BnM token to account: ', privateKeyToAccount(privateKey)) + } + }) + + afterAll((done) => { + done() + // Wait for 1 second to eliminate error: 'Jest did not exit one second after the test run has completed.' + setTimeout(() => process.exit(0), 1000) + }) + + describe('√ all critical functionality in CCIP Client', () => { + it('✅ should approve BnM spend, given valid input', async () => { + const ccipApprove = await ccipSdkClient.approveRouter({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + amount: approvedAmount, + tokenAddress: bnmToken_fuji.address, + waitForReceipt: true, + }) + + ccipApprove.txReceipt!.status == 'success' && console.log(' ✅ | Approved CCIP BnM token on Avalanche Fuji') + expect(ccipApprove.txReceipt!.status).toEqual('success') + }) + + it('✅ fetches token allowance', async function () { + const allowance = await ccipSdkClient.getAllowance({ + client: avalancheFujiClient, + account: avalancheFujiClient.account!.address, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + }) + expect(allowance).toEqual(approvedAmount) + }) + + it('✅ returns on-ramp address', async function () { + const avalancheFujiOnRampAddress = await ccipSdkClient.getOnRampAddress({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + }) + expect(avalancheFujiOnRampAddress).toEqual('0x75b9a75Ee1fFef6BE7c4F842a041De7c6153CF4E') + }) + + it('✅ lists supported fee tokens', async function () { + const result = await ccipSdkClient.getSupportedFeeTokens({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + }) + expect(result.length).toEqual(2) + expect(result[1].toLocaleLowerCase()).toBe(WRAPPED_NATIVE_AVAX.toLowerCase()) + expect(result[0].toLocaleLowerCase()).toBe(LINK_TOKEN_FUJI.toLowerCase()) + }) + + it('✅ fetched lane rate refill limits are defined', async function () { + const { tokens, lastUpdated, isEnabled, capacity, rate } = await ccipSdkClient.getLaneRateRefillLimits({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + }) + + // this implicitly asserts that the values are defined as well. + expect(typeof tokens).toBe('bigint') + expect(typeof lastUpdated).toBe('number') + expect(typeof isEnabled).toBe('boolean') + expect(typeof capacity).toBe('bigint') + expect(typeof rate).toBe('bigint') + }) + + it('✅ returns token rate limit by lane', async function () { + const { tokens, lastUpdated, isEnabled, capacity, rate } = await ccipSdkClient.getTokenRateLimitByLane({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + supportedTokenAddress: bnmToken_fuji.address, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + }) + + // this implicitly asserts that the values are defined as well. + expect(typeof tokens).toBe('bigint') + expect(typeof lastUpdated).toBe('number') + expect(typeof isEnabled).toBe('boolean') + expect(typeof capacity).toBe('bigint') + expect(typeof rate).toBe('bigint') + }) + + it('✅ returns fee estimate', async function () { + const fee_link = await ccipSdkClient.getFee({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + amount: approvedAmount, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + destinationAccount: sepoliaClient.account!.address, + feeTokenAddress: LINK_TOKEN_FUJI, + }) + const fee_native = await ccipSdkClient.getFee({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + amount: approvedAmount, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + destinationAccount: sepoliaClient.account!.address, + feeTokenAddress: WRAPPED_NATIVE_AVAX, + }) + + expect(fee_link).toBeGreaterThan(1000n) + expect(fee_native).toBeGreaterThan(1000n) + }) + it('✅ returns token admin registry', async function () { + const result = await ccipSdkClient.getTokenAdminRegistry({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + }) + + const CCIP_ADMIN_REGISTRY_ADDRESS = '0xA92053a4a3922084d992fD2835bdBa4caC6877e6' + expect(result).toEqual(CCIP_ADMIN_REGISTRY_ADDRESS) + }) + + it('✅ checks if BnM token is supported for transfer', async function () { + const result = await ccipSdkClient.isTokenSupported({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + }) + expect(result).toBe(true) + }) + + it('✅ transfers tokens | pay in LINK', async function () { + await ccipSdkClient.approveRouter({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + amount: approvedAmount, + tokenAddress: bnmToken_fuji.address, + waitForReceipt: true, + }) + + // approve LINK spend + const fee_link = await ccipSdkClient.getFee({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + amount: approvedAmount, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + destinationAccount: sepoliaClient.account!.address, + feeTokenAddress: LINK_TOKEN_FUJI, + }) + await ccipSdkClient.approveRouter({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + amount: fee_link, + tokenAddress: LINK_TOKEN_FUJI, + waitForReceipt: true, + }) + const allowance = await ccipSdkClient.getAllowance({ + client: avalancheFujiClient, + account: avalancheFujiClient.account!.address, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + }) + + expect(allowance).toBeGreaterThanOrEqual(approvedAmount) + + const result = await ccipSdkClient.transferTokens({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + destinationAccount: sepoliaClient.account!.address, + amount: approvedAmount, + feeTokenAddress: LINK_TOKEN_FUJI, + }) + + _messageId = result.messageId + ccipSend_txHash = result.txHash + + expect(result.txReceipt!.status).toEqual('success') + }) + + it('✅ transfers tokens | pays in native token', async function () { + await ccipSdkClient.approveRouter({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + amount: approvedAmount, + tokenAddress: bnmToken_fuji.address, + waitForReceipt: true, + }) + + const result = await ccipSdkClient.transferTokens({ + client: avalancheFujiClient, + routerAddress: AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS, + tokenAddress: bnmToken_fuji.address, + destinationChainSelector: SEPOLIA_CHAIN_SELECTOR, + destinationAccount: sepoliaClient.account!.address, + amount: approvedAmount, + }) + + expect(result.txReceipt!.status).toEqual('success') + }) + + it('✅ gets transfer status & gets transaction receipt', async function () { + const ccipSend_txReceipt = await ccipSdkClient.getTransactionReceipt({ + client: avalancheFujiClient, + hash: ccipSend_txHash, + }) + + const FUJI_CHAIN_SELECTOR = '14767482510784806043' + const SEPOLIA_ROUTER_ADDRESS = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' + + const transferStatus = await ccipSdkClient.getTransferStatus({ + client: sepoliaClient, // from the destination chain + sourceChainSelector: FUJI_CHAIN_SELECTOR, + destinationRouterAddress: SEPOLIA_ROUTER_ADDRESS, + fromBlockNumber: ccipSend_txReceipt.blockNumber, + messageId: _messageId, + }) + + expect(transferStatus).toBeDefined() + + expect(ccipSend_txReceipt).toBeDefined() + expect(ccipSend_txReceipt.status).toEqual('success') + expect(ccipSend_txReceipt.from.toLowerCase()).toEqual(avalancheFujiClient.account!.address.toLowerCase()) + expect(ccipSend_txReceipt.to!.toLowerCase()).toEqual(AVALANCHE_FUJI_CCIP_ROUTER_ADDRESS.toLowerCase()) + }) + }) +}) From 0e2257710847d2540a22b2fe03fc0e824bfe8df6 Mon Sep 17 00:00:00 2001 From: Zubin Pratap Date: Wed, 2 Apr 2025 15:23:45 +1100 Subject: [PATCH 3/7] Changes re integration testing and documentation for ccip-js 1. renamed integration test name to -mocked. Updated package.json script for this. 2. Modified package.json to include --detectOpenHandles as viem appears to not shut resources so without this the tests exit with the jest open handles error. 3. Improved README instructions for running tests and clarified the use of Anvil. 4. Refactored constants to ensure the right private keys are used in the right tests. Default anvil key is used in integration-mocked tests. Dev must supply pk for integration-testnet tets/ 5. in clients.ts the mode was replaced to anvil from hardhat for the test clients. Test clients do not throw the error identified in Issue#8 hence the need to have two different types of integration tests. --- packages/ccip-js/README.md | 4 +- packages/ccip-js/package.json | 2 +- packages/ccip-js/test/helpers/clients.ts | 4 +- packages/ccip-js/test/helpers/constants.ts | 7 +- ...ion.test.ts => integration-mocked.test.ts} | 66 +++++++------------ .../ccip-js/test/integration-testnet.test.ts | 25 +++---- 6 files changed, 43 insertions(+), 65 deletions(-) rename packages/ccip-js/test/{integration.test.ts => integration-mocked.test.ts} (92%) diff --git a/packages/ccip-js/README.md b/packages/ccip-js/README.md index 5960bee..2cd64c9 100644 --- a/packages/ccip-js/README.md +++ b/packages/ccip-js/README.md @@ -596,9 +596,9 @@ pnpm build-ccip-js 2. open a new terminal window and run `anvil` - requires that you've [installed Foundry Anvil](https://book.getfoundry.sh/anvil/). -3. cd into `packages/ccip-js` and then run `pnpm test` +3. Back in the first terminal, inside, `packages/ccip-js` run `pnpm test` -Note: that Anvil is only needed for the tests inside `integration-mocked.test.ts` which uses the [Chainlink Local](https://github.com/smartcontractkit/chainlink-local) simulator. Actual testnet and mainnet behavior may differ from time to time and passing these tests does not guarantee testnet or mainnet behavior. +Note: that Anvil is only needed for the tests inside `./test/integration-mocked.test.ts` which uses the [Chainlink Local](https://github.com/smartcontractkit/chainlink-local) simulator. Actual testnet and mainnet behavior may differ from time to time and passing these tests does not guarantee testnet or mainnet behavior. ### Contributing diff --git a/packages/ccip-js/package.json b/packages/ccip-js/package.json index 9e9855e..3fcc6e5 100644 --- a/packages/ccip-js/package.json +++ b/packages/ccip-js/package.json @@ -15,7 +15,7 @@ "lint": "eslint 'src/**/*.{ts,js}'", "format": "prettier --write 'src/**/*.{ts,js,json,md}'", "pretest": "anvil --block-time 2", - "t:int": "jest --coverage -u --testMatch=\"**/integration-*.test.ts\"", + "t:int": "jest --coverage -u --testMatch=\"**/integration-*.test.ts\" --detectOpenHandles", "t:unit": "jest --coverage -u -t=\"Unit\"", "test": "jest --coverage", "test:hh": "hardhat test" diff --git a/packages/ccip-js/test/helpers/clients.ts b/packages/ccip-js/test/helpers/clients.ts index d39fbd6..cac9184 100644 --- a/packages/ccip-js/test/helpers/clients.ts +++ b/packages/ccip-js/test/helpers/clients.ts @@ -1,9 +1,9 @@ import { account } from './constants' import { createTestClient, http, publicActions, walletActions } from 'viem' -import { hardhat, sepolia } from 'viem/chains' +import { sepolia, anvil } from 'viem/chains' export const testClient = createTestClient({ - chain: hardhat, + chain: anvil, transport: http(), mode: 'anvil', account, diff --git a/packages/ccip-js/test/helpers/constants.ts b/packages/ccip-js/test/helpers/constants.ts index 938dff4..384ff18 100644 --- a/packages/ccip-js/test/helpers/constants.ts +++ b/packages/ccip-js/test/helpers/constants.ts @@ -16,12 +16,7 @@ if (process.env.PRIVATE_KEY?.slice(0, 2) !== '0x') { } export const DEFAULT_ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' - -export const privateKey = ( - process.env.PRIVATE_KEY === '0x' ? DEFAULT_ANVIL_PRIVATE_KEY : process.env.PRIVATE_KEY -) as Hex - -export const account = privateKeyToAccount(privateKey) +export const account = privateKeyToAccount(DEFAULT_ANVIL_PRIVATE_KEY) // bridge token contract export const { bridgeTokenAbi, bridgeTokenBin } = bridgeJson['contracts']['src/contracts/BridgeToken.sol:BridgeToken'] diff --git a/packages/ccip-js/test/integration.test.ts b/packages/ccip-js/test/integration-mocked.test.ts similarity index 92% rename from packages/ccip-js/test/integration.test.ts rename to packages/ccip-js/test/integration-mocked.test.ts index 78a542f..87ee84f 100644 --- a/packages/ccip-js/test/integration.test.ts +++ b/packages/ccip-js/test/integration-mocked.test.ts @@ -1,32 +1,17 @@ -import { jest, expect, it, describe, afterEach } from '@jest/globals' +import { jest, expect, it, describe, afterEach, beforeAll } from '@jest/globals' import * as CCIP from '../src/api' import * as Viem from 'viem' import * as viemActions from 'viem/actions' -import { - Address, - encodeAbiParameters, - encodeFunctionData, - getContract, - parseEther, - zeroAddress, -} from 'viem' +import { parseEther, zeroAddress } from 'viem' import { testClient } from './helpers/clients' -import { - account, - ccipLog, - ccipTxHash, - ccipTxReceipt, - onRampAbi, - routerAbi, -} from './helpers/constants' +import { account, ccipLog, ccipTxHash, ccipTxReceipt, onRampAbi, routerAbi } from './helpers/constants' import { getContracts, setOnRampAddress } from './helpers/contracts' // getSupportedFeeTokens import { mineBlock } from './helpers/utils' import { expect as expectChai } from 'chai' import { getSupportedFeeTokens } from './helpers/config' -// import { readContract } from 'viem/actions' // import { getTokenAdminRegistry } from './helpers/config' const ccipClient = CCIP.createClient() @@ -37,31 +22,29 @@ const writeContractMock = jest.spyOn(viemActions, 'writeContract') const waitForTransactionReceiptMock = jest.spyOn(viemActions, 'waitForTransactionReceipt') const parseEventLogsMock = jest.spyOn(Viem, 'parseEventLogs') -describe('Integration', () => { - +describe('Integration- Using Mocks', () => { afterEach(() => { jest.clearAllMocks() }) - describe('√ deploy on HH', () => { - it("Should Deploy Router.sol", async function () { + describe('√ deploy on Anvil', () => { + it('Should Deploy Router.sol', async function () { const { router } = await getContracts() - expectChai(router.address).to.not.equal(0); - }); - it("Should Deploy BridgeToken.sol", async function () { + expectChai(router.address).to.not.equal(0) + }) + it('Should Deploy BridgeToken.sol', async function () { const { bridgeToken } = await getContracts() - expectChai(bridgeToken.address).to.not.equal(0); - }); - it("Should Deploy CCIPLocalSimulator.sol", async function () { + expectChai(bridgeToken.address).to.not.equal(0) + }) + it('Should Deploy CCIPLocalSimulator.sol', async function () { const { localSimulator } = await getContracts() - expectChai(localSimulator.address).to.not.equal(0); + expectChai(localSimulator.address).to.not.equal(0) }) - console.log('\u2705 | Deployed Smart Contracts on local Hardhat') + console.log('\u2705 | Deployed Smart Contracts on local Anvil') }) describe('√ approve', () => { - it('√ should succeed with valid input', async () => { const { bridgeToken, localSimulator, router } = await getContracts() @@ -71,8 +54,8 @@ describe('Integration', () => { // HH: Approval Transaction await bridgeToken.write.approve([ - router.address, // spender - approvedAmount // amount + router.address, // spender + approvedAmount, // amount ]) // CCIP: Approval Transaction @@ -135,15 +118,15 @@ describe('Integration', () => { // HH: Approval Transaction await bridgeToken.write.approve([ - router.address, // spender - approvedAmount // amount + router.address, // spender + approvedAmount, // amount ]) mineBlock(isFork) const hhApprovedAmount = await bridgeToken.read.allowance([ - account.address, // owner - router.address // spender + account.address, // owner + router.address, // spender ]) await ccipClient.approveRouter({ @@ -155,8 +138,8 @@ describe('Integration', () => { }) const ccipApprovedAmount = await bridgeToken.read.allowance([ - account.address, // owner - router.address // spender + account.address, // owner + router.address, // spender ]) expect(hhApprovedAmount).toBe(approvedAmount) @@ -168,7 +151,6 @@ describe('Integration', () => { }) describe('√ getOnRampAddress', () => { - it('√ should return the address of the onRamp contract', async () => { const { router } = await getContracts() const expectedOnRampAddress = '0x8F35B097022135E0F46831f798a240Cc8c4b0B01' @@ -194,7 +176,6 @@ describe('Integration', () => { }) describe('√ getSupportedFeeTokens', () => { - it('√ should return supported fee tokens for valid chains', async () => { const { router } = await getContracts() const supportedFeeTokens = [ @@ -214,7 +195,7 @@ describe('Integration', () => { const ccipSupportedFeeTokens = await ccipClient.getSupportedFeeTokens({ client: testClient, routerAddress: router.address, - destinationChainSelector: "16015286601757825753", + destinationChainSelector: '16015286601757825753', }) expect(hhSupportedFeeTokens).toStrictEqual(supportedFeeTokens) @@ -358,7 +339,6 @@ describe('Integration', () => { }) }) describe('sendCCIPMessage', () => { - it('should successfully send message', async () => { const { router } = await getContracts() diff --git a/packages/ccip-js/test/integration-testnet.test.ts b/packages/ccip-js/test/integration-testnet.test.ts index 95d56fb..62e639c 100644 --- a/packages/ccip-js/test/integration-testnet.test.ts +++ b/packages/ccip-js/test/integration-testnet.test.ts @@ -4,7 +4,7 @@ import * as Viem from 'viem' import { sepolia, avalancheFuji } from 'viem/chains' import { privateKeyToAccount } from 'viem/accounts' import bridgeTokenAbi from '@chainlink/contracts/abi/v0.8/BurnMintERC677.json' -import { privateKey, DEFAULT_ANVIL_PRIVATE_KEY } from './helpers/constants' +import { DEFAULT_ANVIL_PRIVATE_KEY } from './helpers/constants' import { parseEther } from 'viem' const ccipSdkClient = CCIP.createClient() @@ -17,6 +17,7 @@ const LINK_TOKEN_FUJI = '0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846' // 6m to match https://viem.sh/docs/actions/public/waitForTransactionReceipt.html#timeout-optional, // which is called in approveRouter() +// TODO @zeuslawyer: https://prajjwaldimri.medium.com/why-is-my-jest-runner-not-closing-bc4f6632c959 - tests are passing but jest is not closing. Viem transport issue? why? const TIMEOUT = 180 * 1000 // 3m if (!SEPOLIA_RPC_URL) { @@ -25,6 +26,8 @@ if (!SEPOLIA_RPC_URL) { if (!AVALANCHE_FUJI_RPC_URL) { throw new Error('AVALANCHE_FUJI_RPC_URL must be set') } +const privateKey = process.env.PRIVATE_KEY as `0x${string}` + if (privateKey === DEFAULT_ANVIL_PRIVATE_KEY) { throw new Error( "Developer's PRIVATE_KEY for Ethereum Sepolia and Avalanche Fuji must be set for integration testing on", @@ -63,18 +66,18 @@ describe('Integration: Fuji -> Sepolia', () => { expect(bnmToken_fuji.address).toEqual('0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4') - const bnmBalance = await bnmToken_fuji.read.balanceOf([privateKeyToAccount(privateKey).address]) + const bnmBalance = await bnmToken_fuji.read.balanceOf([privateKeyToAccount(privateKey!).address]) if (parseInt(bnmBalance) < approvedAmount) { - await bnmToken_fuji.write.drip([privateKeyToAccount(privateKey)]) - console.log(' ℹ️ | Dripped 1 CCIP BnM token to account: ', privateKeyToAccount(privateKey)) + await bnmToken_fuji.write.drip([privateKeyToAccount(privateKey!).address]) + console.log(' ℹ️ | Dripped 1 CCIP BnM token to account: ', privateKeyToAccount(privateKey!).address) } }) - afterAll((done) => { - done() - // Wait for 1 second to eliminate error: 'Jest did not exit one second after the test run has completed.' - setTimeout(() => process.exit(0), 1000) - }) + // afterAll((done) => { + // // Wait for 1 second to eliminate error: 'Jest did not exit one second after the test run has completed.' + // done() + // setTimeout(() => process.exit(0), 1000) + // }) describe('√ all critical functionality in CCIP Client', () => { it('✅ should approve BnM spend, given valid input', async () => { @@ -86,8 +89,8 @@ describe('Integration: Fuji -> Sepolia', () => { waitForReceipt: true, }) - ccipApprove.txReceipt!.status == 'success' && console.log(' ✅ | Approved CCIP BnM token on Avalanche Fuji') - expect(ccipApprove.txReceipt!.status).toEqual('success') + // ccipApprove.txReceipt!.status == 'success' && console.log(' ✅ | Approved CCIP BnM token on Avalanche Fuji' + await expect(ccipApprove.txReceipt!.status).toEqual('success') }) it('✅ fetches token allowance', async function () { From 29f4938d29ca3ce1525f098866a8a8a3f46aecd3 Mon Sep 17 00:00:00 2001 From: Zubin Pratap Date: Wed, 2 Apr 2025 15:49:11 +1100 Subject: [PATCH 4/7] Add helpful error if anvil is not running when running integration-mocked test --- .../ccip-js/test/integration-mocked.test.ts | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/ccip-js/test/integration-mocked.test.ts b/packages/ccip-js/test/integration-mocked.test.ts index 87ee84f..fcb2110 100644 --- a/packages/ccip-js/test/integration-mocked.test.ts +++ b/packages/ccip-js/test/integration-mocked.test.ts @@ -2,7 +2,6 @@ import { jest, expect, it, describe, afterEach, beforeAll } from '@jest/globals' import * as CCIP from '../src/api' import * as Viem from 'viem' import * as viemActions from 'viem/actions' -import { parseEther, zeroAddress } from 'viem' import { testClient } from './helpers/clients' import { account, ccipLog, ccipTxHash, ccipTxReceipt, onRampAbi, routerAbi } from './helpers/constants' @@ -27,6 +26,32 @@ describe('Integration- Using Mocks', () => { jest.clearAllMocks() }) + beforeAll(async () => { + // Create a temporary public client to check if Anvil is running + const tempClient = Viem.createPublicClient({ + transport: Viem.http('http://127.0.0.1:8545'), + }) + + // Try to get the chain ID and verify it's Anvil + try { + const chainId = await tempClient.getChainId() + if (chainId.toString() !== '31337') { + throw new Error(`Wrong chain ID ('${chainId}') detected on port 8545. Expected Anvil's '31337'`) + } + } catch (error) { + if (error instanceof Error && error.message.includes('Wrong chain ID')) { + throw error + } + + throw new Error( + '❌ Anvil is not running on port 8545. Please start Anvil first:\n' + + '1. Open a new terminal\n' + + '2. Run: anvil --port 8545\n' + + '3. Then run the tests again', + ) + } + }) + describe('√ deploy on Anvil', () => { it('Should Deploy Router.sol', async function () { const { router } = await getContracts() @@ -50,7 +75,7 @@ describe('Integration- Using Mocks', () => { writeContractMock.mockResolvedValueOnce(ccipTxHash) waitForTransactionReceiptMock.mockResolvedValue(ccipTxReceipt) - const approvedAmount = parseEther('10') + const approvedAmount = Viem.parseEther('10') // HH: Approval Transaction await bridgeToken.write.approve([ @@ -76,7 +101,7 @@ describe('Integration- Using Mocks', () => { // writeContractMock.mockResolvedValueOnce(ccipTxHash) // waitForTransactionReceiptMock.mockResolvedValue(ccipTxReceipt) const { bridgeToken, localSimulator, router } = await getContracts() - const approvedAmount = parseEther('0') + const approvedAmount = Viem.parseEther('0') const { txReceipt } = await ccipClient.approveRouter({ client: testClient, @@ -114,7 +139,7 @@ describe('Integration- Using Mocks', () => { writeContractMock.mockResolvedValueOnce(ccipTxHash) waitForTransactionReceiptMock.mockResolvedValue(ccipTxReceipt) const { bridgeToken, router } = await getContracts() - const approvedAmount = parseEther('10') + const approvedAmount = Viem.parseEther('10') // HH: Approval Transaction await bridgeToken.write.approve([ @@ -214,7 +239,7 @@ describe('Integration- Using Mocks', () => { // const data = encodeFunctionData({ // abi: CCIP.IERC20ABI, // functionName: 'transfer', - // args: [Viem.zeroAddress, Viem.parseEther('0.12')], + // args: [Viem.Viem.zeroAddress, Viem.parseEther('0.12')], // }) // const hhFee = await router.read.getFee([ // '14767482510784806043', // destinationChainSelector: '14767482510784806043', @@ -226,7 +251,7 @@ describe('Integration- Using Mocks', () => { // client: testClient, // routerAddress: router.address, // destinationChainSelector: '14767482510784806043', - // destinationAccount: zeroAddress, + // destinationAccount: Viem.zeroAddress, // amount: 1000000000000000000n, // tokenAddress: '0x94095e6514411C65E7809761F21eF0febe69A977', // }) @@ -299,7 +324,7 @@ describe('Integration- Using Mocks', () => { // const hhTransfer = await router.write.ccipSend([ // 14767482510784806043n, // destinationChainSelector - // zeroAddress // destinationAccount + // Viem.zeroAddress // destinationAccount // ]) // mineBlock(isFork) // console.log({ hhTransfer }) @@ -308,7 +333,7 @@ describe('Integration- Using Mocks', () => { client: testClient, routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', destinationChainSelector: '14767482510784806043', - destinationAccount: zeroAddress, + destinationAccount: Viem.zeroAddress, tokenAddress: '0x94095e6514411C65E7809761F21eF0febe69A977', amount: 1000000000000000000n, }) @@ -351,7 +376,7 @@ describe('Integration- Using Mocks', () => { client: testClient, routerAddress: router.address, destinationChainSelector: '14767482510784806043', - destinationAccount: zeroAddress, + destinationAccount: Viem.zeroAddress, data: Viem.encodeAbiParameters([{ type: 'string', name: 'data' }], ['Hello']), }) expect(transfer.txHash).toEqual(ccipTxHash) @@ -371,7 +396,7 @@ describe('Integration- Using Mocks', () => { client: testClient, routerAddress: router.address, destinationChainSelector: '14767482510784806043', - destinationAccount: zeroAddress, + destinationAccount: Viem.zeroAddress, feeTokenAddress: '0x94095e6514411C65E7809761F21eF0febe69A977', data: Viem.encodeAbiParameters([{ type: 'string', name: 'data' }], ['Hello']), }) From 354ad3414eb7377bbf779982bac851cb45073d25 Mon Sep 17 00:00:00 2001 From: Zubin Pratap Date: Wed, 2 Apr 2025 15:52:36 +1100 Subject: [PATCH 5/7] Remove commented-out afterAll block in testnet integration test. --- packages/ccip-js/test/integration-testnet.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/ccip-js/test/integration-testnet.test.ts b/packages/ccip-js/test/integration-testnet.test.ts index 62e639c..f1605ca 100644 --- a/packages/ccip-js/test/integration-testnet.test.ts +++ b/packages/ccip-js/test/integration-testnet.test.ts @@ -73,12 +73,6 @@ describe('Integration: Fuji -> Sepolia', () => { } }) - // afterAll((done) => { - // // Wait for 1 second to eliminate error: 'Jest did not exit one second after the test run has completed.' - // done() - // setTimeout(() => process.exit(0), 1000) - // }) - describe('√ all critical functionality in CCIP Client', () => { it('✅ should approve BnM spend, given valid input', async () => { const ccipApprove = await ccipSdkClient.approveRouter({ From 9b9ddf6ea3741937a08c589f4232c20d34fd4535 Mon Sep 17 00:00:00 2001 From: Zubin Pratap Date: Mon, 7 Apr 2025 11:45:54 +1000 Subject: [PATCH 6/7] Update CCIP-JS README to remove viem in install step. Rely on installing from package.json This is same as https://github.com/smartcontractkit/ccip-javascript-sdk/pull/9 which will be closed. --- packages/ccip-js/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ccip-js/README.md b/packages/ccip-js/README.md index 2cd64c9..6f6bd94 100644 --- a/packages/ccip-js/README.md +++ b/packages/ccip-js/README.md @@ -67,19 +67,19 @@ Additionally, after the transfer, you may need to check the transfer status. To install the package, use the following command: ```sh -npm install @chainlink/ccip-js viem +npm install @chainlink/ccip-js ``` Or with Yarn: ```sh -yarn add @chainlink/ccip-js viem +yarn add @chainlink/ccip-js ``` Or with PNPM: ```sh -pnpm add @chainlink/ccip-js viem +pnpm add @chainlink/ccip-js ``` ## Usage From d961509a052c040b582756ac71436219c0f4dd90 Mon Sep 17 00:00:00 2001 From: Zubin Pratap Date: Tue, 8 Apr 2025 16:29:39 +1000 Subject: [PATCH 7/7] bump patch version for package ccip-js --- packages/ccip-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ccip-js/package.json b/packages/ccip-js/package.json index 3fcc6e5..25abd6a 100644 --- a/packages/ccip-js/package.json +++ b/packages/ccip-js/package.json @@ -1,6 +1,6 @@ { "name": "@chainlink/ccip-js", - "version": "0.2.2", + "version": "0.2.3", "private": false, "main": "dist/api.js", "types": "dist/api.d.ts",