Develop integration tests in the lido-core repository that verify correctness of the Graph indexer logic when processing events from various operations after the V3 upgrade.
The Graph indexer reconstructs on-chain state and computes derived values (entities) based solely on events emitted within transactions. These tests validate that a TypeScript simulator produces identical results to the actual Graph indexer.
The actual Graph works since the Lido contracts genesis, but this requires long indexing of prior state. For these tests, we:
- Skip historical sync
- Initialize simulator state from current chain state at test start
- Only test V3 (post-V2) code paths
The legacy OracleCompleted entity tracking is skipped since V3 uses TokenRebased.timeElapsed directly.
Standalone module in test/graph/ importable by integration tests.
- TypeScript implementation mimicking Graph handler logic
- Native
bigintfor all numeric values (no precision loss, exact matching) - Custom entity type definitions matching Graph schema
test/graph/
├── graph-tests-spec.md # This specification
├── simulator/
│ ├── index.ts # Main entry point, processTransaction()
│ ├── entities.ts # Entity type definitions (TotalReward, etc.)
│ ├── store.ts # In-memory entity store
│ ├── handlers/
│ │ ├── lido.ts # handleETHDistributed, _processTokenRebase
│ │ ├── accountingOracle.ts # handleProcessingStarted, handleExtraDataSubmitted
│ │ └── index.ts # Handler registry
│ └── helpers.ts # APR calculation, utilities
├── utils/
│ ├── state-capture.ts # Capture chain state before/after tx
│ └── event-extraction.ts # Wrapper around lib/event.ts
└── total-reward.integration.ts # Test file for TotalReward entity
The simulator structure should mirror lido-subgraph/src/ where practical.
The simulator requires initial state captured from on-chain before processing events:
interface SimulatorInitialState {
// Pool state (from Totals entity equivalent)
totalPooledEther: bigint;
totalShares: bigint;
// Address configuration for fee categorization
treasuryAddress: string;
stakingModuleAddresses: string[]; // From StakingRouter.getStakingModules()
}State is captured via contract calls at test start (or test suite start for Scenario tests).
In-memory store mimicking Graph's database:
interface EntityStore {
// Keyed by entity ID (transaction hash for TotalReward)
totalRewards: Map<string, TotalRewardEntity>;
// Future: other entities
}interface ProcessTransactionResult {
// Mapping of entity type to entities created/updated
totalRewards?: Map<string, TotalRewardEntity>;
// Future: other entity types
}
function processTransaction(
logs: LogDescriptionExtended[],
state: SimulatorInitialState,
store: EntityStore,
): ProcessTransactionResult;- Accepts batch of logs from a single transaction
- Logs are processed in
logIndexorder - Handlers can "look ahead" in the logs array (matches Graph behavior)
- Returns mapping of all entities computed in the transaction
Use existing helpers from lib/event.ts:
findEventsWithInterfaces()for parsing logs with contract interfacesfindEvents()for simple event extraction
For Scenario tests (state persists across it blocks), initialize simulator at suite level:
describe("Scenario: Graph TotalReward Validation", () => {
let ctx: ProtocolContext;
let simulator: GraphSimulator;
let store: EntityStore;
before(async () => {
ctx = await getProtocolContext();
const initialState = await captureChainState(ctx);
store = createEntityStore();
simulator = new GraphSimulator(initialState, store);
});
it("Should compute TotalReward correctly for first oracle report", async () => {
// 1. Capture state before
const stateBefore = await capturePoolState(ctx);
// 2. Execute oracle report
const { reportTx } = await report(ctx, reportData);
const receipt = await reportTx!.wait();
// 3. Feed events to simulator
const logs = extractAllLogs(receipt, ctx);
const result = simulator.processTransaction(logs);
// 4. Capture state after
const stateAfter = await capturePoolState(ctx);
// 5. Derive expected values from chain state
const expected = deriveExpectedTotalReward(stateBefore, stateAfter, logs);
// 6. Compare
const computed = result.totalRewards?.get(receipt.hash);
expect(computed).to.deep.equal(expected);
});
it("Should compute TotalReward correctly for second oracle report", async () => {
// Simulator state persists from first report
// ... similar structure ...
});
});For Integration tests (independent it blocks), initialize per-test:
describe("Integration: Graph TotalReward", () => {
it("Should compute TotalReward correctly", async () => {
const ctx = await getProtocolContext();
const initialState = await captureChainState(ctx);
const store = createEntityStore();
const simulator = new GraphSimulator(initialState, store);
// ... rest of test ...
});
});| Field | Source | Verification |
|---|---|---|
id |
tx.hash |
Direct from receipt |
block |
event.block.number |
Direct from receipt |
blockTime |
event.block.timestamp |
Direct from receipt |
transactionHash |
event.transaction.hash |
Direct from receipt |
transactionIndex |
event.transaction.index |
Direct from receipt |
logIndex |
event.logIndex |
Direct from receipt |
| Field | Source | Verification |
|---|---|---|
totalPooledEtherBefore |
TokenRebased.preTotalEther |
lido.getTotalPooledEther() before tx |
totalPooledEtherAfter |
TokenRebased.postTotalEther |
lido.getTotalPooledEther() after tx |
totalSharesBefore |
TokenRebased.preTotalShares |
lido.getTotalShares() before tx |
totalSharesAfter |
TokenRebased.postTotalShares |
lido.getTotalShares() after tx |
shares2mint |
TokenRebased.sharesMintedAsFees |
Event param |
timeElapsed |
TokenRebased.timeElapsed |
Event param |
mevFee |
ETHDistributed.executionLayerRewardsWithdrawn |
Event param |
| Field | Calculation | Verification |
|---|---|---|
totalRewardsWithFees |
(postCL - preCL + withdrawals) + mevFee |
Derived from events |
totalRewards |
totalRewardsWithFees - totalFee |
Calculated |
totalFee |
treasuryFee + operatorsFee |
Sum of fee transfers |
treasuryFee |
Sum of mints to treasury | lido.balanceOf(treasury) delta |
operatorsFee |
Sum of mints to staking modules | Module balance deltas |
sharesToTreasury |
From TransferShares to treasury | Event params |
sharesToOperators |
From TransferShares to modules | Event params |
feeBasis |
totalFee × 10000 / totalRewardsWithFees |
Calculated |
treasuryFeeBasisPoints |
treasuryFee × 10000 / totalFee |
Calculated |
operatorsFeeBasisPoints |
operatorsFee × 10000 / totalFee |
Calculated |
apr |
Share rate annualized change | Recalculated from state |
aprRaw |
Same as apr in V2+ |
Calculated |
aprBeforeFees |
Same as apr in V2+ |
Calculated |
The Graph indexer processes events in the order they appear in the transaction receipt:
1. ProcessingStarted ← AccountingOracle (creates OracleReport link)
2. ETHDistributed ← Lido contract (main handler, creates TotalReward)
3. Transfer (fee mints) ← Lido contract (multiple, from 0x0)
4. TransferShares ← Lido contract (multiple, paired with Transfer)
5. TokenRebased ← Lido contract (pool state, accessed via look-ahead)
6. ExtraDataSubmitted ← AccountingOracle (links NodeOperator entities)
The handleETHDistributed handler uses "look-ahead" to access TokenRebased event data before it's formally processed.
Tests run on Hoodi testnet via forking (see .github/workflows/tests-integration-hoodi.yml).
Configuration:
RPC_URL: ${{ secrets.HOODI_RPC_URL }}
NETWORK_STATE_FILE: deployed-hoodi.jsonyarn test:integration # Runs on Hoodi forkUses existing test infrastructure:
lib/protocol/- Protocol context, oracle reporting helperslib/event.ts- Event extraction utilitiestest/suite/- Test utilities (Snapshot, etc.)
Exact match of all implemented fields between:
- Simulator-computed entity values
- Expected values derived from on-chain state
No tolerance for rounding differences (all values are bigint).
Scope:
TotalRewardentity only- Tier 1 + Tier 2 fields
- Two consecutive oracle reports scenario
- State persistence validation across reports
Deliverables:
- Simulator module with basic handlers
- Entity store implementation
- State capture utilities
- Integration test with two oracle reports
Scope:
- Tier 3 fields (fee calculations, APR)
- Fee distribution to treasury and staking modules
Scope:
- Related entities:
NodeOperatorFees,NodeOperatorsShares,OracleReport
The following edge cases should be addressed in future iterations:
- When
postCLBalance + withdrawalsWithdrawn <= preCLBalance - No
TotalRewardentity should be created - Test that simulator correctly skips entity creation
WithdrawalsFinalizedevent in same transaction- Shares burnt via
SharesBurntevent - Affects
totalSharesAftercalculation
- Negative rewards scenario
- Validator exit edge cases
- CSM (Community Staking Module) integration
- Fee distribution across NOR, SDVT, CSM
dustSharesToTreasuryfield- Rounding in fee distribution
- Manual TypeScript port of relevant handler logic
- Comments referencing original
lido-subgraph/src/file locations - Focus on correctness over exact code mirroring
- When Graph code changes, tests serve as validation
- Discrepancies indicate either bug in Graph or test update needed
- Consider shared test vectors in future
Key Graph source files to mirror:
lido-subgraph/src/Lido.ts-handleETHDistributed,_processTokenRebaselido-subgraph/src/helpers.ts-_calcAPR_v2, entity loaderslido-subgraph/src/AccountingOracle.ts-handleProcessingStartedlido-subgraph/src/constants.ts- Calculation units, addresses