UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

360 lines 18 kB
import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { ethers } from 'ethers'; import hre from 'hardhat'; import sinon from 'sinon'; import { ERC20Test__factory } from '@hyperlane-xyz/core'; import { assert } from '@hyperlane-xyz/utils'; import { KNOWN_ETHEREUM_TIMELOCK_CONTRACT, TestChainName, ethereumTestChain, } from '../../consts/testChains.js'; import { MultiProvider } from '../../providers/MultiProvider.js'; import { randomAddress, randomInt } from '../../test/testUtils.js'; import { EvmEtherscanLikeEventLogsReader, EvmEventLogsReader, EvmRpcEventLogsReader, } from './EvmEventLogsReader.js'; chai.use(chaiAsPromised); describe('EvmEventLogsReader', () => { let contractOwner; let tokenRecipient1; let tokenRecipient2; let providerChainTest1; let multiProvider; let testContract; let erc20Factory; let deploymentBlockNumber; const transferTopic = ethers.utils.id('Transfer(address,address,uint256)'); beforeEach(async () => { [contractOwner, tokenRecipient1, tokenRecipient2] = await hre.ethers.getSigners(); assert(contractOwner.provider, 'Provider should be available'); // Initialize MultiProvider with test chain multiProvider = MultiProvider.createTestMultiProvider({ signer: contractOwner, provider: contractOwner.provider, }); providerChainTest1 = contractOwner.provider; // Get contract factory for ERC20Test erc20Factory = new ERC20Test__factory(contractOwner); }); async function deployTestErc20() { testContract = await erc20Factory.deploy('TestToken', 'TST', ethers.utils.parseEther('1000000'), 18); await testContract.deployed(); assert(testContract.deployTransaction.blockNumber, 'Expected the Contract deployment block number to be defined'); deploymentBlockNumber = testContract.deployTransaction.blockNumber; } async function mineRandomNumberOfBlocks() { const blocksToMine = randomInt(5, 20); for (let blockNum = 0; blockNum < blocksToMine; blockNum++) { await providerChainTest1.send('evm_mine', []); } } describe('constructor', () => { it('should initialize with BlockExplorer strategy when useRPC is false', async () => { const readerWithRpc = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, }, multiProvider); // Access the private property indirectly expect(readerWithRpc['logReaderStrategy']).to.be.instanceOf(EvmEtherscanLikeEventLogsReader); }); it('should initialize with RPC strategy when useRPC is true', async () => { const readerWithRpc = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, useRPC: true, }, multiProvider); // Access the private property indirectly expect(readerWithRpc['logReaderStrategy']).to.be.instanceOf(EvmRpcEventLogsReader); }); it('should initialize with RPC strategy when no explorer is available', async () => { const multiProviderNoExplorer = new MultiProvider({}); multiProviderNoExplorer.setSharedSigner(contractOwner); const reader = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, // Even when false, should use RPC if no explorer useRPC: false, }, multiProviderNoExplorer); // Access the private property indirectly expect(reader['logReaderStrategy']).to.be.instanceOf(EvmRpcEventLogsReader); }); }); describe(`${EvmEventLogsReader.prototype.getLogsByTopic.name} (rpc)`, () => { let reader; beforeEach(async () => { await deployTestErc20(); reader = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, useRPC: true, paginationBlockRange: 1000, }, multiProvider); }); it('should retrieve logs for Transfer events emitted by the contract', async () => { // Emit some Transfer events const tx1 = await testContract.transfer(tokenRecipient1.address, ethers.utils.parseEther('100')); await tx1.wait(); const tx2 = await testContract.transfer(tokenRecipient2.address, ethers.utils.parseEther('200')); await tx2.wait(); const logs = await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, }); // Should have 3 transfer events: 1 from constructor mint + 2 from transfers expect(logs).to.have.length(3); logs.forEach((log) => { expect(log.address).to.equal(testContract.address); expect(log.topics[0]).to.equal(transferTopic); }); }); it('should work when fromBlock is not specified (uses deployment block)', async () => { // Emit Transfer event const tx = await testContract.transfer(tokenRecipient1.address, ethers.utils.parseEther('100')); await tx.wait(); const logs = await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, }); // Should have 2 transfer events: 1 from constructor mint + 1 from transfer expect(logs).to.have.length(2); logs.forEach((log) => { expect(log.address).to.equal(testContract.address); expect(log.topics[0]).to.equal(transferTopic); }); }); it('should work when toBlock is not specified (uses current block)', async () => { // Emit Transfer event const tx = await testContract.transfer(tokenRecipient1.address, ethers.utils.parseEther('100')); await tx.wait(); const logs = await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, // No toBlock specified }); // Should have 2 transfer events: 1 from constructor mint + 1 from transfer expect(logs).to.have.length(2); logs.forEach((log) => { expect(log.address).to.equal(testContract.address); expect(log.topics[0]).to.equal(transferTopic); }); }); it('should work when both fromBlock and toBlock are specified', async () => { await mineRandomNumberOfBlocks(); const startBlock = await providerChainTest1.getBlockNumber(); // Emit event in first block const tx1 = await testContract.transfer(tokenRecipient1.address, ethers.utils.parseEther('100')); await tx1.wait(); const firstEventBlock = await providerChainTest1.getBlockNumber(); await mineRandomNumberOfBlocks(); // Emit event in later block const tx2 = await testContract.transfer(tokenRecipient1.address, ethers.utils.parseEther('200')); await tx2.wait(); // Query only the first event's block range const logs = await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: startBlock, toBlock: firstEventBlock, }); expect(logs).to.have.length(1); expect(logs[0].blockNumber).to.equal(firstEventBlock); expect(logs[0].address).to.equal(testContract.address); expect(logs[0].topics[0]).to.equal(transferTopic); }); it('should return empty array when no logs match the filter', async () => { // Emit an event just to be sure that filtering works as expected const tx = await testContract.transfer(tokenRecipient1.address, ethers.utils.parseEther('100')); await tx.wait(); const nonExistentTopic = ethers.utils.id('NonExistentEvent(uint256)'); const logs = await reader.getLogsByTopic({ eventTopic: nonExistentTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, }); expect(logs).to.have.length(0); }); it('should handle multiple events across different blocks', async () => { const numberOfEventsToEmit = randomInt(3, 10); const eventBlocks = []; for (let i = 0; i < numberOfEventsToEmit; i++) { const tx = await testContract.mint(ethers.utils.parseEther(`${(i + 1) * 50}`)); await tx.wait(); eventBlocks.push(await providerChainTest1.getBlockNumber()); // Mine a few blocks between events await mineRandomNumberOfBlocks(); } // +1 because of the transfer event emitted on contract deployment const expectedNumberOfEvents = numberOfEventsToEmit + 1; const logs = await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, }); expect(logs).to.have.length(expectedNumberOfEvents); // Verify all logs are from the correct contract and have the right topic logs.forEach((log) => { expect(log.address).to.equal(testContract.address); expect(log.topics[0]).to.equal(transferTopic); }); }); it('should work with small logPageSize (testing chunking)', async () => { const readerWithSmallPageSize = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, useRPC: true, paginationBlockRange: 2, }, multiProvider); const numberOfEventsToEmit = 5; for (let i = 0; i < numberOfEventsToEmit; i++) { const tx = await testContract.mint(ethers.utils.parseEther(`${(i + 1) * 100}`)); await tx.wait(); } // +1 because of the transfer event emitted on contract deployment const expectedNumberOfEvents = numberOfEventsToEmit + 1; const logs = await readerWithSmallPageSize.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, }); expect(logs).to.have.length(expectedNumberOfEvents); }); it('should throw an error for non-existing contract address', async () => { const nonExistentAddress = randomAddress(); await expect(reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: nonExistentAddress, fromBlock: deploymentBlockNumber, })).to.be.rejected; }); it('should handle edge case where fromBlock equals toBlock', async () => { // Emit event const tx = await testContract.transfer(tokenRecipient1.address, ethers.utils.parseEther('100')); await tx.wait(); const eventBlock = await providerChainTest1.getBlockNumber(); const logs = await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: eventBlock, toBlock: eventBlock, }); expect(logs).to.have.length(1); expect(logs[0].blockNumber).to.equal(eventBlock); }); }); describe(`${EvmEventLogsReader.prototype.getLogsByTopic.name} (block explorer)`, () => { let reader; let multiProvider; beforeEach(async () => { await deployTestErc20(); multiProvider = new MultiProvider({ ethereum: ethereumTestChain, }); reader = EvmEventLogsReader.fromConfig({ chain: ethereumTestChain.name, }, multiProvider); }); it('should get the expected number of events when fromBlock is not provided', async () => { const res = await reader.getLogsByTopic({ contractAddress: KNOWN_ETHEREUM_TIMELOCK_CONTRACT, // CallExecuted signature eventTopic: '0xc2617efa69bab66782fa219543714338489c4e9e178271560a91b82c3f612b58', // Omitting from block to test getting contract deployment block from explorer toBlock: 15_000_000, }); expect(res.length).to.equal(17); }); }); describe('retry before fallback', () => { it('should succeed on retry without hitting fallback', async () => { await deployTestErc20(); const reader = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, useRPC: true }, multiProvider); // Manually add a fallback so we can verify it's not called const fallback = new EvmRpcEventLogsReader(TestChainName.test1, {}, multiProvider); reader['fallbackLogReaderStrategy'] = fallback; const fallbackSpy = sinon.spy(fallback, 'getContractLogs'); // Make primary strategy fail once then succeed const primaryStrategy = reader['logReaderStrategy']; const originalGetLogs = primaryStrategy.getContractLogs.bind(primaryStrategy); let callCount = 0; primaryStrategy.getContractLogs = async (opts) => { if (callCount++ === 0) throw new Error('rate limit'); return originalGetLogs(opts); }; const logs = await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, }); // Primary succeeded on retry, fallback never called expect(logs.length).to.be.greaterThan(0); expect(callCount).to.equal(2); expect(fallbackSpy.callCount).to.equal(0); }); it('should fall back after all retries exhausted', async () => { await deployTestErc20(); const reader = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, useRPC: true }, multiProvider); // Make primary strategy always fail reader['logReaderStrategy'] = { getContractDeploymentBlockNumber: async () => deploymentBlockNumber, getContractLogs: async () => { throw new Error('permanent failure'); }, }; // Set fallback to a working RPC reader const fallback = new EvmRpcEventLogsReader(TestChainName.test1, {}, multiProvider); reader['fallbackLogReaderStrategy'] = fallback; const fallbackSpy = sinon.spy(fallback, 'getContractLogs'); const logs = await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, }); expect(logs.length).to.be.greaterThan(0); expect(fallbackSpy.callCount).to.equal(1); }); }); describe('deployment block cache', () => { it('should only look up deployment block once for the same contract', async () => { await deployTestErc20(); const reader = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, useRPC: true }, multiProvider); const primaryStrategy = reader['logReaderStrategy']; const deploymentSpy = sinon.spy(primaryStrategy, 'getContractDeploymentBlockNumber'); // Two calls without fromBlock — both need the deployment block await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, }); await reader.getLogsByTopic({ eventTopic: ethers.utils.id('Approval(address,address,uint256)'), contractAddress: testContract.address, }); expect(deploymentSpy.callCount).to.equal(1); }); it('should not look up deployment block when fromBlock is provided', async () => { await deployTestErc20(); const reader = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, useRPC: true }, multiProvider); const primaryStrategy = reader['logReaderStrategy']; const deploymentSpy = sinon.spy(primaryStrategy, 'getContractDeploymentBlockNumber'); await reader.getLogsByTopic({ eventTopic: transferTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, }); expect(deploymentSpy.callCount).to.equal(0); }); }); describe('error handling', () => { let reader; beforeEach(async () => { await deployTestErc20(); reader = EvmEventLogsReader.fromConfig({ chain: TestChainName.test1, useRPC: true, }, multiProvider); }); it('should not allow invalid topic signatures', async () => { const invalidTopic = 'invalid-topic'; expect(reader.getLogsByTopic({ eventTopic: invalidTopic, contractAddress: testContract.address, fromBlock: deploymentBlockNumber, })).to.be.rejectedWith(); }); }); }); //# sourceMappingURL=EvmEventLogsReader.hardhat-test.js.map