Bridge Indexing: Multichain support L1<>L2 sync data#98
Conversation
- Added BridgeL2 ABI definition for deposit and withdrawal events. - Introduced new GraphQL schema types for bridge deposits and withdrawals. - Implemented event handlers for DepositFinalized and WithdrawalInitiated in the bridge mapping. - Updated mainnet-complete.ts to include the new BridgeL2 datasource. - Exported bridge mapping functions in the index file.
- Changed startBlock to 65260492 for the L2Bridge deployment. - Updated L2Bridge address to 0x2c1B65dA72d5Cf19b41dE6eDcCFB7DD83d1B529E.
- Added support for Ethereum L1 and zkSync L2 in a new multi-chain project. - Created Docker configuration for PostgreSQL and services. - Implemented event handling for bridge deposits and withdrawals across both chains. - Introduced GraphQL schema for querying bridge activities. - Updated .gitignore to include new project files and directories. - Added README with setup instructions and project overview.
- Updated Docker Compose to ensure services are healthy before starting. - Introduced Jest configuration for unit testing with TypeScript support. - Added unit tests for bridge mapping handlers to validate deposit and withdrawal processes. - Implemented utility functions for zkSync RPC interactions with comprehensive test coverage. - Updated package dependencies to include Jest and TypeScript testing utilities.
- Removed redundant cache update functions for block numbers in the bridge mapping. - Simplified the retrieval of initiated withdrawals by querying the database directly. - Improved error handling for missing withdrawals during finalization. - Updated tests to reflect changes in withdrawal handling and added comments for clarity.
- Modified Docker Compose to ensure "subquery-node-zksync" service is healthy before starting. - Updated GraphQL schema to simplify bridge withdrawal and deposit fields, improving clarity and consistency. - Refactored bridge mapping functions to use unified sender and receiver fields for deposits and withdrawals. - Adjusted transaction handling to align with new schema definitions, ensuring accurate data mapping. - Enhanced unit tests to reflect schema changes and validate new mapping logic.
…nused options - Replaced all instances of 'docker-compose' with 'docker compose' for consistency with the latest Docker CLI. - Removed the '--allow-schema-migration' option from the Docker Compose configuration to streamline service startup.
… for improved reliability
- Removed the Event entity from the GraphQL schema to streamline data representation. - Updated BridgeWithdrawal, BridgeDeposit, and BridgeFailedDepositClaim entities to eliminate unnecessary fields. - Refactored bridge mapping functions to remove dependencies on the Transaction entity, enhancing clarity and performance. - Improved error handling and logging in the bridge mapping logic for better traceability.
…URL fallback - Introduced a new function to check for rate limit errors in RPC responses. - Refactored the RPC request logic to include automatic URL switching to a fallback URL upon detecting rate limit errors. - Updated the `rpcCall` and `rpcBatchCall` functions to utilize the new request handling logic, improving error resilience and reliability. - Enhanced logging for better traceability during retries and URL switches.
- Updated the GraphQL schema to make l1TransactionHash and status fields mandatory, and added finalizedAt for better tracking. - Refactored bridge mapping functions to improve error handling and streamline deposit creation and updates, ensuring clarity in deposit status management. - Simplified the logic for handling deposits and failed deposit claims, enhancing overall code readability and maintainability.
Conflict resolutions keep main's newer state: full cspell word list (+BMNFT from this PR), main's foundry.toml (optimizer/lint/zksync profile) and remappings.txt, and main's openzeppelin-contracts v5.3.0 submodule pointer (PR had pinned v5.1.0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LCOV of commit
|
aliXsed
left a comment
There was a problem hiding this comment.
Reviewed after merging latest main into the branch (conflicts in .cspell.json, foundry.toml, remappings.txt resolved in favor of main's newer state, and main's OpenZeppelin v5.3.0 submodule pointer restored — the branch had pinned it back to v5.1.0).
Summary
The PR adds a multichain SubQuery project indexing L1↔L2 bridge sync state, L2 bridge handlers for the existing single-chain subquery/ project, and an unrelated BatchMintNFT UUPS contract with deploy/upgrade scripts and tests. Every finding below was independently verified against the code (the build break was reproduced end-to-end).
Findings (most severe first)
| # | Where | Issue |
|---|---|---|
| 1 | subquery/src/mappings/bridge.ts:86 |
Package no longer builds: TS2554 (missing required finalized constructor arg) + TS2344 (hand-rolled event-arg interfaces vs generated BridgeL2Abi types) |
| 2 | subquery-multichain/src/mappings/bridge.ts:31 |
Missing-entity throw = permanent poison block; L2-first startup (the documented workflow) guarantees it fires. Mirror throw at :208 |
| 3 | subquery-multichain/src/mappings/bridge.ts:109 |
L1 handler unconditionally overwrites the deposit row, wiping L2 finalization data |
| 4 | subquery-multichain/src/mappings/bridge.ts:147 |
Dead getByFields query (results unused) whose empty-result throw halts the indexer on legitimate inputs; type-unsafe receiver_id cast misses the store cache |
| 5 | subquery-multichain/docker-compose.yml:31 |
ZKSYNC_MAINNET_RPC never reaches the container; export:env npm script is a no-op → always falls back to the rate-limited public RPC |
| 6 | subquery-multichain/project-ethereum.yaml:23 |
startBlock drifted from project-ethereum.ts (23635689 vs 23635846) — regeneration silently skips 157 blocks |
| 7 | subquery-multichain/src/test/bridge.test.ts:17 |
Test expectations (deposit- ID prefix) can never match the handlers; subqlTest cases pass vacuously under plain jest |
| 8 | src/contentsign/BatchMintNFT.sol:32 |
Dead MINTER_ROLE, no-op burn() override; sole contract importing OZ v4 via era-contracts' nested vendored copy |
Suggestions (not blocking)
- The same L2 bridge events (contract
0x2c1B...529E, same topics, same startBlock) are now indexed by bothsubquery/andsubquery-multichain/with incompatible ID schemes (hash-logIndexvs raw hash) and different schemas. If intentional (per-network dashboard vs cross-chain tracker), a README note would help; otherwise one of the two should be dropped. subquery-multichain/docker/(pg.conf, pg-Dockerfile, load-extensions.sh) is a byte-for-byte copy ofsubquery/docker/— a shared dir would avoid drift.zksync-rpc.tsduplicates rate-limit/fallback logic betweenmakeRpcRequestandgetBlockTransactionCountsByNumbers; per-batch results (block range, tx counts) could be cached and chunks early-exited once the cumulative count coverstxIndex— currently everyWithdrawalFinalizedfrom the same batch repeats the full RPC sweep.subquery/mainnet-complete.ts:500shipsstartBlock: 65260492with aTODO: Replace with actual L2Bridge deployment block— if the real deployment block is earlier, those events are permanently missing.- BatchMintNFT + its scripts/tests are unrelated to bridge indexing and would be cleaner as a separate PR.
I'll push fixes for findings 1-8 (except the OZ v4→v5 port, which isn't mechanical) to this branch shortly.
| const withdrawalId = `${event.transaction.hash}-${event.logIndex}`; | ||
|
|
||
| const withdrawal = new BridgeWithdrawal( | ||
| withdrawalId, | ||
| emitter.id, | ||
| transaction.id, | ||
| timestamp, | ||
| l2SenderAccount.id, | ||
| l1ReceiverAccount.id, | ||
| amount.toBigInt() | ||
| ); |
There was a problem hiding this comment.
Build break: this package no longer compiles. subql codegen && subql build fails with:
src/mappings/bridge.ts(86,22): error TS2554: Expected 8 arguments, but got 7.
schema.graphql declares finalized: Boolean! on BridgeWithdrawal, so the generated constructor requires it as the 8th argument — the trailing withdrawal.finalized = false assignment doesn't satisfy it.
There are two more build errors at lines 18-19 (TS2344): the hand-rolled DepositFinalizedEventArgs/WithdrawalInitiatedEventArgs interfaces don't satisfy the EthereumResult constraint. Every other mapping in this package imports the generated log types (e.g. rewards.ts uses ../types/abi-interfaces/RewardsAbi), and BridgeL2.abi.json is already registered in mainnet-complete.ts, so importing DepositFinalizedLog/WithdrawalInitiatedLog from ../types/abi-interfaces/BridgeL2Abi (as subquery-multichain already does) fixes both issues at once.
Related design note: no handler in this single-chain project ever sets finalized = true (finalization is an L1 event this project doesn't index), so finalized/finalizedAt/finalizedTransaction will be permanently false/null — any frontend gating on this flag will show every withdrawal as pending forever.
| if (!deposit) { | ||
| throw new Error(`Deposit ${depositId} not found`); | ||
| } else { |
There was a problem hiding this comment.
Crash-loop / poison block: the two chain indexers run independently — SubQuery's --multi-chain mode only shares a Postgres schema and gives no cross-chain ordering guarantee — yet this handler throws when the L1-created deposit doesn't exist. An unhandled handler error is fatal for the block: the node exits, restart: unless-stopped re-attempts the same block, and a deterministic error becomes a permanent crash-loop.
The ordering that triggers this is the normal one: docker-compose.yml starts subquery-node-zksync first (subquery-node-ethereum has depends_on: subquery-node-zksync: service_healthy), and start-ethereum-services.sh explicitly documents starting ethereum "after zkSync has finished indexing" — which guarantees every historical DepositFinalized is processed before its L1 DepositInitiated entity exists.
Since L2-first is the expected order, this should upsert: create the BridgeDeposit with status finalized from the L2 event data when missing (requires l1TransactionHash to become nullable in the schema, since it's unknown on the L2 side), and let handleDepositInitiated fill in the L1 fields when it catches up.
The mirror-image throw exists at lines 208-211 (Withdrawal ${withdrawalId} not found) — a withdrawal initiated before the L2 startBlock will never exist, making that a permanent poison block too; it should log and skip instead.
| // Try to find existing deposit from L2, or create new one | ||
| let deposit = await BridgeDeposit.get(depositId); | ||
|
|
||
| // Create new deposit if not found from L2 | ||
| deposit = new BridgeDeposit( | ||
| depositId, | ||
| timestamp, | ||
| senderAccount.id, | ||
| receiverAccount.id, | ||
| amount.toBigInt(), | ||
| event.transaction.hash, | ||
| "initiated" | ||
| ); |
There was a problem hiding this comment.
Finalization data silently wiped: the BridgeDeposit.get(depositId) result is never used — deposit is unconditionally reassigned to a new BridgeDeposit(..., "initiated") and saved. save() is an upsert keyed on id that replaces the whole row, and both handlers use the same key (l2DepositTxHash here, event.transaction.hash in the L2 handler), so if the L2 side already recorded status: "finalized" / finalizedAt / l2TransactionHash, this overwrite regresses the deposit to "initiated" and nulls the finalization fields — permanently, since the L2 event won't be processed again.
This should branch on the fetched entity: if it exists, update only the L1-side fields (l1TransactionHash, sender/receiver, amount, timestamp) and preserve status/finalizedAt/l2TransactionHash.
Same dead-get-then-overwrite pattern in handleClaimedFailedDeposit (lines 244-246) — harmless there since the constructor repopulates every field identically, but the get is dead code either way.
| // Get initiated withdrawals for this receiver | ||
| // Query database directly to avoid SubQuery index validation issues | ||
| const entities = (await store.getByFields( | ||
| "BridgeWithdrawal", | ||
| [ | ||
| ["receiver_id" as keyof BridgeWithdrawal, "=", receiverAccount.id], | ||
| ["status", "=", "initiated"], | ||
| ], | ||
| { | ||
| limit: 100, | ||
| } | ||
| )) as BridgeWithdrawal[]; | ||
|
|
||
| if (entities.length === 0) { | ||
| logger.error( | ||
| `No initiated withdrawals found for receiver ${receiverAccount.id}` | ||
| ); | ||
| throw new Error( | ||
| `No initiated withdrawals found for receiver ${receiverAccount.id}` | ||
| ); | ||
| } |
There was a problem hiding this comment.
Dead query that can halt the indexer: this store.getByFields result is only used for entities.length (a log line and the throw below) — the actual withdrawal is located later via the RPC-derived l2TransactionHash. Meanwhile the throw at lines 162-164 poisons the L1 indexer for legitimate cases: a receiver whose withdrawal was initiated before the L2 startBlock, or already finalized, has zero "initiated" rows — and that never changes on retry, so the block crash-loops forever.
The "receiver_id" as keyof BridgeWithdrawal cast is also a red flag: the entity field is receiverId; receiver_id is the underlying DB column name. It happens to work against Postgres only because Sequelize passes the unknown key through to the underscored column, but it silently misses SubQuery's in-memory store cache (matchesField reads data["receiver_id"] → undefined), so unflushed same-block entities are never matched.
Since the results aren't used, the whole query + throw can simply be deleted.
Minor, same function: the ternary at lines 170-173 has identical branches (Number(batchNumber) both ways) — it's just Number(batchNumber).
| environment: | ||
| DB_USER: postgres | ||
| DB_PASS: postgres | ||
| DB_DATABASE: postgres | ||
| DB_HOST: postgres | ||
| DB_PORT: 5432 |
There was a problem hiding this comment.
ZKSYNC_MAINNET_RPC never reaches the indexer: this environment: block only passes DB_* vars, there's no env_file:, and no ${...} interpolation anywhere in the compose file — so process.env.ZKSYNC_MAINNET_RPC in handleWithdrawalFinalized is always undefined inside the container and every batch→tx-hash lookup falls back to the hardcoded public https://mainnet.era.zksync.io (whose "fallback" RETRY_URL in zksync-rpc.ts:3 is the same endpoint). Combined with the throw-on-null-hash path, indexing correctness ends up depending on a rate-limited public RPC.
The "export:env": "export $(grep -v '^#' .env | xargs)" script in package.json:14 doesn't help — each npm run spawns its own sh -c child shell, so the exports die before the next command in the && chain runs. It's a no-op everywhere it's used (codegen, build, start:docker, dev).
Fix: add ZKSYNC_MAINNET_RPC: ${ZKSYNC_MAINNET_RPC:-https://mainnet.era.zksync.io} to this block (compose reads .env automatically for interpolation) and drop the export:env script.
| - 'https://ethereum-rpc.publicnode.com' | ||
| dataSources: | ||
| - kind: ethereum/Runtime | ||
| startBlock: 23635689 |
There was a problem hiding this comment.
Config drift: this file's header says "Auto-generated, DO NOT EDIT" from project-ethereum.ts, but the two disagree: startBlock: 23635689 here vs 23635846 in project-ethereum.ts:36. Docker runs the yaml (-f=/app/project-ethereum.yaml), so today the earlier block wins — but the next subql codegen regeneration silently moves the L1 start forward 157 blocks, and any DepositInitiated events in 23635689-23635845 would never be indexed (their DepositFinalized counterparts on L2 would then hit the missing-entity throw). The two values should be aligned (the earlier one is the safe choice). project-zksync.yaml/.ts have no drift.
| describe("handleDepositFinalized (L2)", () => { | ||
| it("should create a new BridgeDeposit when deposit is finalized on L2", async () => { | ||
| const expectedDeposit = { | ||
| id: "deposit-0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", |
There was a problem hiding this comment.
Test expectations can never match: the expected entity IDs use a deposit- prefix (here and at line 98), but the handlers use the raw tx hash as the ID (mappings/bridge.ts:25 and :103) — no deposit- prefix exists anywhere in the mappings. The first test also expects handleDepositFinalized to create a BridgeDeposit, but the handler currently throws when the deposit doesn't exist.
This goes unnoticed because these subqlTest cases never actually run: npm test invokes plain jest, and @subql/testing's subqlTest() only pushes specs onto global.subqlTests — no handler execution, no assertions — so the file passes vacuously. Running them for real requires subql-node-ethereum test, which no npm script wires up.
Also worth noting: zksync-rpc.test.ts intentionally hits the live zkSync mainnet RPC (no fetch mock, 30s timeouts), so npm test is network-dependent and flaky.
| /// @notice Role identifier for minters (reserved for future use if minting restrictions are needed) | ||
| bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); |
There was a problem hiding this comment.
Dead code: MINTER_ROLE is declared but never granted or checked anywhere — contract, deploy/upgrade scripts, or tests. Minting is intentionally public (per the contract NatSpec), so this constant only misleads auditors into looking for role-gated minting that doesn't exist. The burn() override at lines 188-190 is likewise a pure super.burn() pass-through the compiler doesn't require (only _burn has a diamond collision, and that override exists separately) — verified it compiles fine without it. Both can be deleted.
Separate note: this contract is the only file in the repo importing OZ upgradeable v4 via era-contracts' nested vendored copy (openzeppelin-contracts-upgradeable-v4/...), while every other upgradeable contract uses @openzeppelin/contracts-upgradeable/ → the repo's v5 submodule. If era-contracts is ever updated or its nested lib layout changes, this contract's build breaks. A v5 port isn't mechanical (hook/override patterns changed), so flagging rather than asking for it here — but worth a follow-up or at least a comment documenting the v4 pin.
- subquery: use generated BridgeL2Abi log types and pass required finalized constructor arg (package failed to build with TS2554/TS2344) - subquery-multichain: upsert deposits instead of throwing when the other chain's indexer hasn't caught up (no cross-chain ordering guarantee), preserve finalization state in handleDepositInitiated, drop the dead getByFields query and poison-block throws, skip finalization for withdrawals initiated before the L2 startBlock - make BridgeDeposit.l1TransactionHash nullable (unknown when L2 indexes first) - wire ZKSYNC_MAINNET_RPC into the ethereum indexer container and drop the no-op export:env npm script - align L1 startBlock (23635689) between project-ethereum.ts and the yaml - fix test entity IDs that could never match the handlers' raw-hash IDs - BatchMintNFT: remove unused MINTER_ROLE and no-op burn() override Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed fixes for the review findings in 38a8776 (all except the suggestion-level items and the OZ v4→v5 port):
Also merged latest |
No description provided.