From da5ff0f7dc2f40998795e0cf657cebc5016a1e6f Mon Sep 17 00:00:00 2001 From: Nikolas Haimerl Date: Mon, 1 Dec 2025 13:13:45 +0100 Subject: [PATCH 1/2] feat: add hyperEVM OFT event indexing support - Add startHyperEvmIndexer function for OFT event tracking - Implement OFTSent event transformer and storage logic - Add OFT_SENT_ABI and OftSentArgs type definitions - Refactor indexer functions to use generic StartIndexerRequest interface - Update integration tests to cover hyperEVM OFT event ingestion - Improve test setup with per-test mock server initialization --- .../indexer/src/data-indexing/model/abis.ts | 8 ++ .../src/data-indexing/model/eventTypes.ts | 11 ++ .../src/data-indexing/service/constants.ts | 6 + .../src/data-indexing/service/indexers.ts | 70 ++++++++--- .../src/data-indexing/service/storer.ts | 17 +++ .../src/data-indexing/service/transformers.ts | 46 +++++++- .../tests/Indexers.integration.test.ts | 110 ++++++++++++++---- packages/indexer/src/main.ts | 4 +- 8 files changed, 230 insertions(+), 42 deletions(-) diff --git a/packages/indexer/src/data-indexing/model/abis.ts b/packages/indexer/src/data-indexing/model/abis.ts index bb2c951b..449bdf22 100644 --- a/packages/indexer/src/data-indexing/model/abis.ts +++ b/packages/indexer/src/data-indexing/model/abis.ts @@ -7,3 +7,11 @@ export const CCTP_DEPOSIT_FOR_BURN_ABI = [ ]; export const MESSAGE_SENT_ABI = ["event MessageSent(bytes message)"]; + +/* ================================================================================== + * OFT DOMAIN LOGIC & CONFIGURATION + * * Specific ABIs for the Omni-chain Fungible Token (OFT) protocol. + * ================================================================================== */ +export const OFT_SENT_ABI = [ + "event OFTSent(bytes32 indexed guid, uint32 dstEid, address indexed fromAddress, uint256 amountSentLD, uint256 amountReceivedLD)", +]; diff --git a/packages/indexer/src/data-indexing/model/eventTypes.ts b/packages/indexer/src/data-indexing/model/eventTypes.ts index 22278057..88841f3f 100644 --- a/packages/indexer/src/data-indexing/model/eventTypes.ts +++ b/packages/indexer/src/data-indexing/model/eventTypes.ts @@ -20,3 +20,14 @@ export interface DepositForBurnArgs { export interface MessageSentArgs { message: `0x${string}`; } +/* ================================================================================== + * OFT DOMAIN LOGIC & CONFIGURATION + * * Specific event types for the Omni-chain Fungible Token (OFT) protocol. + * ================================================================================== */ +export interface OftSentArgs { + guid: `0x${string}`; + fromAddress: `0x${string}`; + dstEid: number; + amountSentLD: number; + amountReceivedLD: number; +} diff --git a/packages/indexer/src/data-indexing/service/constants.ts b/packages/indexer/src/data-indexing/service/constants.ts index dce6a902..1f99fa26 100644 --- a/packages/indexer/src/data-indexing/service/constants.ts +++ b/packages/indexer/src/data-indexing/service/constants.ts @@ -132,3 +132,9 @@ export const WHITELISTED_FINALIZERS: Array<`0x${string}`> = [ ]; export const DEPOSIT_FOR_BURN_EVENT_NAME = "DepositForBurn"; export const MESSAGE_SENT_EVENT_NAME = "MessageSent"; + +/* ================================================================================== + * OFT DOMAIN LOGIC & CONFIGURATION + * * Specific implementations for the Omni-chain Fungible Token (OFT) protocol. + * ================================================================================== */ +export const OFTSENT_EVENT_NAME = "OFTSent"; diff --git a/packages/indexer/src/data-indexing/service/indexers.ts b/packages/indexer/src/data-indexing/service/indexers.ts index d24d6191..b8176528 100644 --- a/packages/indexer/src/data-indexing/service/indexers.ts +++ b/packages/indexer/src/data-indexing/service/indexers.ts @@ -1,34 +1,39 @@ import { IndexerConfig, startIndexerSubsystem } from "./genericIndexer"; -import { CHAIN_IDs, MAINNET_CHAIN_IDs } from "@across-protocol/constants"; +import { CHAIN_IDs } from "@across-protocol/constants"; import { IndexerEventPayload } from "./genericEventListener"; import { Entity } from "typeorm"; import { TOKEN_MESSENGER_ADDRESS_MAINNET, DEPOSIT_FOR_BURN_EVENT_NAME, - MESSAGE_SENT_EVENT_NAME, // New import + MESSAGE_SENT_EVENT_NAME, + OFTSENT_EVENT_NAME, MESSAGE_TRANSMITTER_ADDRESS_MAINNET, TOKEN_MESSENGER_ADDRESS_TESTNET, - MESSAGE_TRANSMITTER_ADDRESS_TESTNET, // New import + MESSAGE_TRANSMITTER_ADDRESS_TESTNET, } from "./constants"; import { CCTP_DEPOSIT_FOR_BURN_ABI, - MESSAGE_SENT_ABI, // New import + MESSAGE_SENT_ABI, + OFT_SENT_ABI, } from "../model/abis"; import { depositForBurnTransformer, - messageSentTransformer, // New import + messageSentTransformer, + oftSentTransformer, } from "./transformers"; import { storeDepositForBurnEvent, - storeMessageSentEvent, // New import + storeMessageSentEvent, + storeOftSentEvent, } from "./storer"; import { utils as dbUtils } from "@repo/indexer-database"; import { Logger } from "winston"; +import { getOftChainConfiguration } from "../adapter/oft/service"; /** - * Definition of the request object for starting the Arbitrum Mainnet Indexer. + * Definition of the request object for starting an indexer. */ -export interface StartArbitrumMainnetIndexerRequest { +export interface StartIndexerRequest { repo: dbUtils.BlockchainEventRepository; rpcUrl: string; logger: Logger; @@ -45,19 +50,17 @@ export interface StartArbitrumMainnetIndexerRequest { * own configuration, transformation, and storage logic. * * @param request The configuration object containing repo, rpcUrl, logger, and shutdown signal. */ -export async function startArbitrumMainnetIndexer( - request: StartArbitrumMainnetIndexerRequest, -) { +export async function startArbitrumIndexer(request: StartIndexerRequest) { // Destructure the request object const { repo, rpcUrl, logger, sigterm } = request; // Concrete Configuration // Define the specific parameters for the Arbitrum Mainnet indexer. - const ethConfig: IndexerConfig< + const indexerConfig: IndexerConfig< Partial, dbUtils.BlockchainEventRepository, IndexerEventPayload > = { - chainId: request.testNet ? CHAIN_IDs.ARBITRUM : CHAIN_IDs.ARBITRUM_SEPOLIA, + chainId: request.testNet ? CHAIN_IDs.ARBITRUM_SEPOLIA : CHAIN_IDs.ARBITRUM, rpcUrl, events: [ { @@ -89,7 +92,46 @@ export async function startArbitrumMainnetIndexer( // Start the generic indexer subsystem with our concrete configuration and functions. await startIndexerSubsystem({ db: repo, - indexerConfig: ethConfig, + indexerConfig: indexerConfig, + logger, + sigterm, + }); +} + +/** + * Sets up and starts the indexer for OFT events on hyperEVM. + * @param request The configuration object containing repo, rpcUrl, logger, and shutdown signal. + */ +export async function startHyperEvmIndexer(request: StartIndexerRequest) { + const { repo, rpcUrl, logger, sigterm, testNet } = request; + const chainId = testNet ? CHAIN_IDs.HYPEREVM_TESTNET : CHAIN_IDs.HYPEREVM; + const oftChainConfig = getOftChainConfiguration(chainId); + if (!oftChainConfig) { + throw new Error(`OFT configuration not found for chainId: ${chainId}`); + } + + const indexerConfig: IndexerConfig< + Partial, + dbUtils.BlockchainEventRepository, + IndexerEventPayload + > = { + chainId, + rpcUrl, + events: oftChainConfig.tokens.map((token) => ({ + config: { + address: token.address as `0x${string}`, + abi: OFT_SENT_ABI, + eventName: OFTSENT_EVENT_NAME, + fromBlock: token.startBlockNumber, + }, + transform: oftSentTransformer, + store: storeOftSentEvent, + })), + }; + + await startIndexerSubsystem({ + db: repo, + indexerConfig: indexerConfig, logger, sigterm, }); diff --git a/packages/indexer/src/data-indexing/service/storer.ts b/packages/indexer/src/data-indexing/service/storer.ts index 09b64d67..36a0f9f9 100644 --- a/packages/indexer/src/data-indexing/service/storer.ts +++ b/packages/indexer/src/data-indexing/service/storer.ts @@ -9,6 +9,8 @@ const PK_CHAIN_BLOCK_TX_LOG = [ "logIndex", ]; +const PK_CHAIN_BLOCK_HASH_LOG = ["chainId", "blockHash", "logIndex"]; + /** * Stores a DepositForBurn event in the database. * @@ -45,3 +47,18 @@ export const storeMessageSentEvent: Storer< [], ); }; + +export const storeOftSentEvent: Storer< + Partial, + dbUtils.BlockchainEventRepository +> = async ( + event: Partial, + repository: dbUtils.BlockchainEventRepository, +) => { + return repository.saveAndHandleFinalisationBatch( + entities.OFTSent, + [event], + PK_CHAIN_BLOCK_HASH_LOG as (keyof entities.OFTSent)[], + [], + ); +}; diff --git a/packages/indexer/src/data-indexing/service/transformers.ts b/packages/indexer/src/data-indexing/service/transformers.ts index e4174ba3..15992830 100644 --- a/packages/indexer/src/data-indexing/service/transformers.ts +++ b/packages/indexer/src/data-indexing/service/transformers.ts @@ -8,9 +8,14 @@ import { import { formatFromAddressToChainFormat } from "../../utils"; import { Transformer } from "../model/eventProcessor"; import { getFinalisedBlockBufferDistance } from "./constants"; -import { DepositForBurnArgs, MessageSentArgs } from "../model/eventTypes"; +import { + DepositForBurnArgs, + MessageSentArgs, + OftSentArgs, +} from "../model/eventTypes"; import { Logger } from "winston"; import { arrayify } from "ethers/lib/utils"; // New import +import { getOftChainConfiguration } from "../adapter/oft/service"; /** * A generic transformer for addresses. @@ -35,13 +40,24 @@ function baseTransformer( logger: Logger = console as unknown as Logger, ) { const { log: logItem, chainId, blockTimestamp, currentBlockHeight } = payload; - const { transactionHash, logIndex, transactionIndex, blockNumber } = logItem; + const { + transactionHash, + logIndex, + transactionIndex, + blockNumber, + blockHash, + } = logItem; // Guard against missing essential fields - if (!transactionHash || logIndex === null || transactionIndex === null) { + if ( + !transactionHash || + logIndex === null || + transactionIndex === null || + blockHash === null + ) { logger.error({ at: "transformers#baseTransformer", - message: `Log incomplete. TxHash: ${transactionHash}, Index: ${logIndex}, TxIndex: ${transactionIndex}, Payload: ${JSON.stringify(payload)}`, + message: `Log incomplete. TxHash: ${transactionHash}, Index: ${logIndex}, TxIndex: ${transactionIndex}, BlockHash: ${blockHash} Payload: ${JSON.stringify(payload)}`, notificationPath: "across-indexer-error", }); throw new Error( @@ -52,6 +68,7 @@ function baseTransformer( return { chainId: chainId.toString(), blockNumber: Number(blockNumber), + blockHash, blockTimestamp: new Date(Number(blockTimestamp) * 1000), transactionHash, transactionIndex, @@ -145,6 +162,27 @@ export const messageSentTransformer: Transformer< }; }; +export const oftSentTransformer: Transformer< + IndexerEventPayload, + Partial +> = (payload, logger: Logger = console as unknown as Logger) => { + const rawArgs = getRawArgs(payload, logger); + const args = rawArgs as unknown as OftSentArgs; + const base = baseTransformer(payload, logger); + const chainId = parseInt(base.chainId); + const fromAddress = transformAddress(args.fromAddress, chainId); + + return { + ...base, + guid: args.guid, + dstEid: args.dstEid, + fromAddress, + amountSentLD: args.amountSentLD.toString(), + amountReceivedLD: args.amountReceivedLD.toString(), + token: getOftChainConfiguration(payload.chainId).tokens[0]!.address, + }; +}; + const getRawArgs = (payload: IndexerEventPayload, logger: Logger) => { const rawArgs = (payload.log as any).args; diff --git a/packages/indexer/src/data-indexing/tests/Indexers.integration.test.ts b/packages/indexer/src/data-indexing/tests/Indexers.integration.test.ts index 60f78809..2064f500 100644 --- a/packages/indexer/src/data-indexing/tests/Indexers.integration.test.ts +++ b/packages/indexer/src/data-indexing/tests/Indexers.integration.test.ts @@ -1,7 +1,10 @@ import { expect } from "chai"; import { DataSource } from "typeorm"; import { getTestDataSource } from "../../tests/setup"; -import { startArbitrumMainnetIndexer } from "../service/indexers"; +import { + startArbitrumIndexer, + startHyperEvmIndexer, +} from "../service/indexers"; import { MockWebSocketRPCServer } from "../../tests/testProvider"; import { utils as dbUtils } from "@repo/indexer-database"; import { entities } from "@repo/indexer-database"; @@ -14,6 +17,7 @@ import sinon from "sinon"; import { Logger } from "winston"; import { CHAIN_IDs } from "@across-protocol/constants"; import { decodeMessage } from "../adapter/cctp-v2/service"; +import { getOftChainConfiguration } from "../adapter/oft/service"; describe("Indexer Integration (Real Transaction Data)", () => { let dataSource: DataSource; @@ -22,21 +26,6 @@ describe("Indexer Integration (Real Transaction Data)", () => { let rpcUrl: string; let logger: Logger; let abortController: AbortController; - /** - * Sets up the mock Ethereum server before all tests. - */ - before(async () => { - server = new MockWebSocketRPCServer(); - rpcUrl = await server.start(); - console.info(`Mock RPC Server started at: ${rpcUrl}`); - }); - - /** - * Stops the mock Ethereum server after all tests. - */ - after(() => { - server.stop(); - }); /** * Sets up the data source and blockchain repository before each test. @@ -51,9 +40,11 @@ describe("Indexer Integration (Real Transaction Data)", () => { } as unknown as Logger; blockchainRepository = new dbUtils.BlockchainEventRepository( dataSource, - console as unknown as Logger, + logger, ); abortController = new AbortController(); + server = new MockWebSocketRPCServer(); + rpcUrl = await server.start(); }); /** @@ -64,13 +55,14 @@ describe("Indexer Integration (Real Transaction Data)", () => { await dataSource.destroy(); } abortController.abort(); + server.stop(); sinon.restore(); }); /** * Tests ingesting a DepositForBurn event from a specific Arbitrum Sepolia transaction. */ - it("should ingest the DepositForBurn event from Arbitrum Sepolia tx 0xabb...69f7", async () => { + it("should ingest the DepositForBurn event from Arbitrum tx 0xabb...69f7", async () => { // Real Transaction Data taken from: // https://arbiscan.io/tx/0xf38daaf5d34c3363cd8843c47643ca9583fc04a17f8a93d153e7549ad3509cc0#eventlog const txHash = @@ -88,10 +80,10 @@ describe("Indexer Integration (Real Transaction Data)", () => { }); // Start the Indexer with the real repository - startArbitrumMainnetIndexer({ + startArbitrumIndexer({ repo: blockchainRepository, rpcUrl, - logger: console as any as Logger, + logger, sigterm: abortController.signal, }); await server.waitForSubscription(); @@ -245,7 +237,7 @@ describe("Indexer Integration (Real Transaction Data)", () => { // Start the Indexer with the real repository // Since `startArbitrumMainnetIndexer` is now configured to listen for both // DepositForBurn and MessageSent, we can call it directly. - startArbitrumMainnetIndexer({ + startArbitrumIndexer({ repo: blockchainRepository, rpcUrl, logger, @@ -286,7 +278,7 @@ describe("Indexer Integration (Real Transaction Data)", () => { expect(savedEvent).to.exist; // Detailed Field Verification expect(savedEvent).to.deep.include({ - chainId: CHAIN_IDs.ARBITRUM, + chainId: CHAIN_IDs.ARBITRUM_SEPOLIA, blockNumber: blockNumber, transactionHash: txHash, transactionIndex: 1, @@ -316,4 +308,78 @@ describe("Indexer Integration (Real Transaction Data)", () => { // Null checks expect(savedEvent!.deletedAt).to.be.null; }).timeout(20000); + + it("should ingest the OFTSent event from hyperEVM tx 0x94d7...cd9", async () => { + // Real transaction taken from: + // https://hyperevmscan.io/tx/0x94d7feace76e29767cbdb1c1ff83430a846e933040de9caaf167290d22315cd9#eventlog#18 + const txHash = + "0x94d7feace76e29767cbdb1c1ff83430a846e933040de9caaf167290d22315cd9"; + const blockNumber = 20670509; + const blockHash = + "0xa2ae33b41e3f1d1896ab84e625da2416f0dc9ee1fcd9c91b975eb118331364e9"; + const blockTimestamp = "0x6564b1f3"; // An arbitrary timestamp + + server.mockBlockResponse({ + number: "0x" + blockNumber.toString(16), + hash: blockHash, + timestamp: blockTimestamp, + transactions: [], + }); + + startHyperEvmIndexer({ + repo: blockchainRepository, + rpcUrl, + logger, + sigterm: abortController.signal, + }); + await server.waitForSubscription(); + + const oftChainConfig = getOftChainConfiguration(CHAIN_IDs.HYPEREVM); + const token = oftChainConfig.tokens.find( + (t) => t.address === "0x904861a24F30EC96ea7CFC3bE9EA4B476d237e98", + )!; + + server.pushEvent({ + address: token.address, + blockNumber: "0x" + blockNumber.toString(16), + transactionHash: txHash, + logIndex: "0x12", // 18 + blockHash, + transactionIndex: "0x1", + topics: [ + "0x85496b760a4b7f8d66384b9df21b381f5d1b1e79f229a47aaf4c232edc2fe59a", + "0xfd74b08cc4da0b6cea03a2731ce3bf9c5fa6912cc6241557c39ce2f2dd20b002", + "0x00000000000000000000000034f9c0b11e67d72ad65c41ff90a6989846f28c22", + ], + data: + "0x" + + "0000000000000000000000000000000000000000000000000000000000007670" + // dstEid: 30320 + "0000000000000000000000000000000000000000000000000000000005f5e100" + // amountSentLD: 100000000 + "0000000000000000000000000000000000000000000000000000000005f5e100", // amountReceivedLD: 100000000 + }); + + await new Promise((r) => setTimeout(r, 500)); + + const oftSentRepo = dataSource.getRepository(entities.OFTSent); + const savedEvent = await oftSentRepo.findOne({ + where: { transactionHash: txHash, logIndex: 18 }, + }); + + expect(savedEvent).to.exist; + + expect(savedEvent).to.deep.include({ + chainId: CHAIN_IDs.HYPEREVM, + blockNumber: blockNumber, + blockHash, + transactionHash: txHash, + logIndex: 18, + finalised: false, + guid: "0xfd74b08cc4da0b6cea03a2731ce3bf9c5fa6912cc6241557c39ce2f2dd20b002", + dstEid: 30320, + fromAddress: "0x34F9C0B11e67d72AD65C41Ff90A6989846f28c22", + amountSentLD: 100000000, + amountReceivedLD: 100000000, + token: token.address, + }); + }).timeout(20000); }); diff --git a/packages/indexer/src/main.ts b/packages/indexer/src/main.ts index f602a483..5cb89362 100644 --- a/packages/indexer/src/main.ts +++ b/packages/indexer/src/main.ts @@ -36,7 +36,7 @@ import { OftRepository } from "./database/OftRepository"; import { CCTPIndexerManager } from "./data-indexing/service/CCTPIndexerManager"; import { OFTIndexerManager } from "./data-indexing/service/OFTIndexerManager"; import { CctpFinalizerServiceManager } from "./data-indexing/service/CctpFinalizerService"; -import { startArbitrumMainnetIndexer } from "./data-indexing/service/indexers"; +import { startArbitrumIndexer } from "./data-indexing/service/indexers"; async function initializeRedis( config: parseEnv.RedisConfig, @@ -112,7 +112,7 @@ export async function MainSandbox( // This promise will resolve only when abortController.abort() is called // and the indexer has finished its cleanup routine. - await startArbitrumMainnetIndexer({ + await startArbitrumIndexer({ repo, rpcUrl, logger, From 3284aea6c68c456c63eabc78766037ef4b973857 Mon Sep 17 00:00:00 2001 From: Nikolas Haimerl Date: Tue, 2 Dec 2025 09:41:30 +0100 Subject: [PATCH 2/2] refactor: rename oftSentTransformer to transformOftSentEvent --- packages/indexer/src/data-indexing/service/tranforming.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/indexer/src/data-indexing/service/tranforming.ts b/packages/indexer/src/data-indexing/service/tranforming.ts index cc95f42b..555d6d9a 100644 --- a/packages/indexer/src/data-indexing/service/tranforming.ts +++ b/packages/indexer/src/data-indexing/service/tranforming.ts @@ -162,7 +162,7 @@ export const transformMessageSentEvent: Transformer< }; }; -export const oftSentTransformer: Transformer< +export const transformOftSentEvent: Transformer< IndexerEventPayload, Partial > = (payload, logger: Logger = console as unknown as Logger) => {