From 70ea809e6cd5dcfddcd8c226bb946ede0cd7ea4b Mon Sep 17 00:00:00 2001 From: Artyom Veremeenko Date: Sun, 14 Dec 2025 17:30:32 +0300 Subject: [PATCH 01/10] test: the first of graph simulating integration tests --- test/graph/graph-tests-spec.md | 382 ++++++++++++++ test/graph/index.ts | 11 + test/graph/simulator/entities.ts | 153 ++++++ test/graph/simulator/handlers/index.ts | 74 +++ test/graph/simulator/handlers/lido.ts | 244 +++++++++ test/graph/simulator/helpers.ts | 97 ++++ test/graph/simulator/index.ts | 362 +++++++++++++ test/graph/simulator/query.ts | 237 +++++++++ test/graph/simulator/store.ts | 80 +++ test/graph/total-reward.integration.ts | 674 +++++++++++++++++++++++++ test/graph/utils/event-extraction.ts | 249 +++++++++ test/graph/utils/index.ts | 6 + test/graph/utils/state-capture.ts | 133 +++++ 13 files changed, 2702 insertions(+) create mode 100644 test/graph/graph-tests-spec.md create mode 100644 test/graph/index.ts create mode 100644 test/graph/simulator/entities.ts create mode 100644 test/graph/simulator/handlers/index.ts create mode 100644 test/graph/simulator/handlers/lido.ts create mode 100644 test/graph/simulator/helpers.ts create mode 100644 test/graph/simulator/index.ts create mode 100644 test/graph/simulator/query.ts create mode 100644 test/graph/simulator/store.ts create mode 100644 test/graph/total-reward.integration.ts create mode 100644 test/graph/utils/event-extraction.ts create mode 100644 test/graph/utils/index.ts create mode 100644 test/graph/utils/state-capture.ts diff --git a/test/graph/graph-tests-spec.md b/test/graph/graph-tests-spec.md new file mode 100644 index 0000000000..daf6caa1e4 --- /dev/null +++ b/test/graph/graph-tests-spec.md @@ -0,0 +1,382 @@ +# Graph Indexer Integration Tests Specification + +## Purpose + +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. + +## Limitations + +### Historical Data + +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 + +### OracleCompleted History + +The legacy `OracleCompleted` entity tracking is skipped since V3 uses `TokenRebased.timeElapsed` directly. + +--- + +## Architecture + +### Location + +Standalone module in `test/graph/` importable by integration tests. + +### Language & Types + +- TypeScript implementation mimicking Graph handler logic +- Native `bigint` for all numeric values (no precision loss, exact matching) +- Custom entity type definitions matching Graph schema + +### File Structure + +``` +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. + +--- + +## Simulator Design + +### Initial State + +The simulator requires initial state captured from on-chain before processing events: + +```typescript +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). + +### Entity Store + +In-memory store mimicking Graph's database: + +```typescript +interface EntityStore { + // Keyed by entity ID (transaction hash for TotalReward) + totalRewards: Map; + // Future: other entities +} +``` + +### Transaction Processing + +```typescript +interface ProcessTransactionResult { + // Mapping of entity type to entities created/updated + totalRewards?: Map; + // 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 `logIndex` order +- Handlers can "look ahead" in the logs array (matches Graph behavior) +- Returns mapping of all entities computed in the transaction + +### Event Extraction + +Use existing helpers from `lib/event.ts`: + +- `findEventsWithInterfaces()` for parsing logs with contract interfaces +- `findEvents()` for simple event extraction + +--- + +## Test Structure + +### Scenario Tests + +For Scenario tests (state persists across `it` blocks), initialize simulator at suite level: + +```typescript +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 ... + }); +}); +``` + +### Integration Tests + +For Integration tests (independent `it` blocks), initialize per-test: + +```typescript +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 ... + }); +}); +``` + +--- + +## TotalReward Entity Fields + +### Implementation Tiers + +#### Tier 1 - Direct Event Metadata (Iteration 1) + +| 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 | + +#### Tier 2 - Pool State (Iteration 1) + +| 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 | + +#### Tier 3 - Calculated Fields (Iteration 2+) + +| 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 | + +--- + +## Event Processing Order + +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. + +--- + +## Test Environment + +### Network + +Tests run on **Hoodi testnet** via forking (see `.github/workflows/tests-integration-hoodi.yml`). + +Configuration: + +```bash +RPC_URL: ${{ secrets.HOODI_RPC_URL }} +NETWORK_STATE_FILE: deployed-hoodi.json +``` + +### Test Command + +```bash +yarn test:integration # Runs on Hoodi fork +``` + +### Dependencies + +Uses existing test infrastructure: + +- `lib/protocol/` - Protocol context, oracle reporting helpers +- `lib/event.ts` - Event extraction utilities +- `test/suite/` - Test utilities (Snapshot, etc.) + +--- + +## Success Criteria + +**Exact match** of all implemented fields between: + +1. Simulator-computed entity values +2. Expected values derived from on-chain state + +No tolerance for rounding differences (all values are `bigint`). + +--- + +## Iteration Plan + +### Iteration 1 (Current) + +**Scope:** + +- `TotalReward` entity 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 + +### Iteration 2 + +**Scope:** + +- Tier 3 fields (fee calculations, APR) +- Fee distribution to treasury and staking modules + +### Iteration 3 + +**Scope:** + +- Related entities: `NodeOperatorFees`, `NodeOperatorsShares`, `OracleReport` + +--- + +## Future Iterations - Edge Cases + +The following edge cases should be addressed in future iterations: + +### Non-Profitable Oracle Report + +- When `postCLBalance + withdrawalsWithdrawn <= preCLBalance` +- No `TotalReward` entity should be created +- Test that simulator correctly skips entity creation + +### Report with Withdrawal Finalization + +- `WithdrawalsFinalized` event in same transaction +- Shares burnt via `SharesBurnt` event +- Affects `totalSharesAfter` calculation + +### Report with Slashing Penalties + +- Negative rewards scenario +- Validator exit edge cases + +### Multiple Staking Modules + +- CSM (Community Staking Module) integration +- Fee distribution across NOR, SDVT, CSM + +### Dust and Rounding + +- `dustSharesToTreasury` field +- Rounding in fee distribution + +--- + +## Relationship to Actual Graph Code + +### Current Approach + +- Manual TypeScript port of relevant handler logic +- Comments referencing original `lido-subgraph/src/` file locations +- Focus on correctness over exact code mirroring + +### Maintenance + +- When Graph code changes, tests serve as validation +- Discrepancies indicate either bug in Graph or test update needed +- Consider shared test vectors in future + +### Reference Files + +Key Graph source files to mirror: + +- `lido-subgraph/src/Lido.ts` - `handleETHDistributed`, `_processTokenRebase` +- `lido-subgraph/src/helpers.ts` - `_calcAPR_v2`, entity loaders +- `lido-subgraph/src/AccountingOracle.ts` - `handleProcessingStarted` +- `lido-subgraph/src/constants.ts` - Calculation units, addresses diff --git a/test/graph/index.ts b/test/graph/index.ts new file mode 100644 index 0000000000..118b4dfd95 --- /dev/null +++ b/test/graph/index.ts @@ -0,0 +1,11 @@ +/** + * Graph Indexer Integration Tests + * + * This module provides a TypeScript simulator for the Lido Graph indexer + * that can be used to validate entity computation in integration tests. + * + * Reference: test/graph/graph-tests-spec.md + */ + +export * from "./simulator"; +export * from "./utils"; diff --git a/test/graph/simulator/entities.ts b/test/graph/simulator/entities.ts new file mode 100644 index 0000000000..51c93a3755 --- /dev/null +++ b/test/graph/simulator/entities.ts @@ -0,0 +1,153 @@ +/** + * Entity type definitions for Graph Simulator + * + * These types mirror the Graph schema entities but use native TypeScript types. + * All numeric values use bigint to ensure exact matching without precision loss. + * APR values use number (BigDecimal equivalent). + * + * Reference: lido-subgraph/schema.graphql - TotalReward entity + * Reference: lido-subgraph/src/helpers.ts - _loadTotalRewardEntity() + */ + +/** + * TotalReward entity representing rewards data from an oracle report + * + * This entity is created by handleETHDistributed when processing a profitable oracle report. + */ +export interface TotalRewardEntity { + // ========== Tier 1 - Direct Event Metadata ========== + // These fields come directly from the transaction receipt + + /** Transaction hash - serves as entity ID */ + id: string; + + /** Block number where the oracle report was processed */ + block: bigint; + + /** Block timestamp (Unix seconds) */ + blockTime: bigint; + + /** Transaction hash (same as id) */ + transactionHash: string; + + /** Transaction index within the block */ + transactionIndex: bigint; + + /** Log index of the ETHDistributed event */ + logIndex: bigint; + + // ========== Tier 2 - Pool State ========== + // These fields come from TokenRebased event params + + /** Total pooled ether before the rebase (from TokenRebased.preTotalEther) */ + totalPooledEtherBefore: bigint; + + /** Total pooled ether after the rebase (from TokenRebased.postTotalEther) */ + totalPooledEtherAfter: bigint; + + /** Total shares before the rebase (from TokenRebased.preTotalShares) */ + totalSharesBefore: bigint; + + /** Total shares after the rebase (from TokenRebased.postTotalShares) */ + totalSharesAfter: bigint; + + /** Shares minted as fees (from TokenRebased.sharesMintedAsFees) */ + shares2mint: bigint; + + /** Time elapsed since last oracle report in seconds (from TokenRebased.timeElapsed) */ + timeElapsed: bigint; + + /** MEV/execution layer rewards withdrawn (from ETHDistributed.executionLayerRewardsWithdrawn) */ + mevFee: bigint; + + // ========== Tier 2 - Fee Distribution ========== + // These fields track fee distribution from Transfer/TransferShares events + + /** Total rewards including fees (CL balance delta + EL rewards) */ + totalRewardsWithFees: bigint; + + /** Total user rewards after fee deduction */ + totalRewards: bigint; + + /** Total protocol fee (treasuryFee + operatorsFee) */ + totalFee: bigint; + + /** ETH value minted to treasury */ + treasuryFee: bigint; + + /** ETH value minted to staking router modules (operators) */ + operatorsFee: bigint; + + /** Shares minted to treasury */ + sharesToTreasury: bigint; + + /** Shares minted to staking router modules (operators) */ + sharesToOperators: bigint; + + // ========== Tier 3 - Calculated Fields ========== + + /** + * User APR after fees and time correction (BigDecimal in Graph schema) + * Calculated from share rate change annualized + */ + apr: number; + + /** Raw APR (same as apr in v2) */ + aprRaw: number; + + /** APR before fees (same as apr in v2) */ + aprBeforeFees: number; + + /** Fee basis points: totalFee * 10000 / totalRewardsWithFees */ + feeBasis: bigint; + + /** Treasury fee as fraction of total fee: treasuryFee * 10000 / totalFee */ + treasuryFeeBasisPoints: bigint; + + /** Operators fee as fraction of total fee: operatorsFee * 10000 / totalFee */ + operatorsFeeBasisPoints: bigint; +} + +/** + * Create a new TotalReward entity with default values + * + * @param id - Transaction hash to use as entity ID + * @returns New TotalRewardEntity with zero/empty default values + */ +export function createTotalRewardEntity(id: string): TotalRewardEntity { + return { + // Tier 1 + id, + block: 0n, + blockTime: 0n, + transactionHash: id, + transactionIndex: 0n, + logIndex: 0n, + + // Tier 2 - Pool State + totalPooledEtherBefore: 0n, + totalPooledEtherAfter: 0n, + totalSharesBefore: 0n, + totalSharesAfter: 0n, + shares2mint: 0n, + timeElapsed: 0n, + mevFee: 0n, + + // Tier 2 - Fee Distribution + totalRewardsWithFees: 0n, + totalRewards: 0n, + totalFee: 0n, + treasuryFee: 0n, + operatorsFee: 0n, + sharesToTreasury: 0n, + sharesToOperators: 0n, + + // Tier 3 + apr: 0, + aprRaw: 0, + aprBeforeFees: 0, + feeBasis: 0n, + treasuryFeeBasisPoints: 0n, + operatorsFeeBasisPoints: 0n, + }; +} diff --git a/test/graph/simulator/handlers/index.ts b/test/graph/simulator/handlers/index.ts new file mode 100644 index 0000000000..be671f81fb --- /dev/null +++ b/test/graph/simulator/handlers/index.ts @@ -0,0 +1,74 @@ +/** + * Handler registry for Graph Simulator + * + * Maps event names to their handler functions and coordinates + * event processing across all handlers. + */ + +import { LogDescriptionWithMeta } from "../../utils/event-extraction"; +import { TotalRewardEntity } from "../entities"; +import { EntityStore } from "../store"; + +import { handleETHDistributed, HandlerContext, isETHDistributedEvent } from "./lido"; + +// Re-export for convenience +export { HandlerContext } from "./lido"; + +/** + * Result of processing a transaction's events + */ +export interface ProcessTransactionResult { + /** TotalReward entities created/updated (keyed by tx hash) */ + totalRewards: Map; + + /** Number of events processed */ + eventsProcessed: number; + + /** Whether any profitable oracle report was found */ + hadProfitableReport: boolean; +} + +/** + * Process all events from a transaction through the appropriate handlers + * + * Events are processed in logIndex order. Some handlers (like handleETHDistributed) + * use look-ahead to access later events in the same transaction. + * + * @param logs - All parsed logs from the transaction, sorted by logIndex + * @param store - Entity store for persisting entities + * @param ctx - Handler context with transaction metadata + * @returns Processing result with created entities + */ +export function processTransactionEvents( + logs: LogDescriptionWithMeta[], + store: EntityStore, + ctx: HandlerContext, +): ProcessTransactionResult { + const result: ProcessTransactionResult = { + totalRewards: new Map(), + eventsProcessed: 0, + hadProfitableReport: false, + }; + + // Process events in logIndex order + for (const log of logs) { + result.eventsProcessed++; + + // Route to appropriate handler based on event name + if (isETHDistributedEvent(log)) { + const ethDistributedResult = handleETHDistributed(log, logs, store, ctx); + + if (ethDistributedResult.isProfitable && ethDistributedResult.totalReward) { + result.totalRewards.set(ethDistributedResult.totalReward.id, ethDistributedResult.totalReward); + result.hadProfitableReport = true; + } + } + + // Future handlers can be added here: + // - handleProcessingStarted (AccountingOracle) + // - handleExtraDataSubmitted (AccountingOracle) + // - handleTransfer (Lido) - for fee distribution tracking + } + + return result; +} diff --git a/test/graph/simulator/handlers/lido.ts b/test/graph/simulator/handlers/lido.ts new file mode 100644 index 0000000000..4c7d10ad05 --- /dev/null +++ b/test/graph/simulator/handlers/lido.ts @@ -0,0 +1,244 @@ +/** + * Lido event handlers for Graph Simulator + * + * Ports the core logic from lido-subgraph/src/Lido.ts: + * - handleETHDistributed() - Main handler that creates TotalReward entity + * - _processTokenRebase() - Extracts pool state from TokenRebased event + * + * Reference: lido-subgraph/src/Lido.ts lines 477-690 + */ + +import { + findEventByName, + findTransferSharesPairs, + getEventArg, + LogDescriptionWithMeta, + ZERO_ADDRESS, +} from "../../utils/event-extraction"; +import { createTotalRewardEntity, TotalRewardEntity } from "../entities"; +import { calcAPR_v2, CALCULATION_UNIT } from "../helpers"; +import { EntityStore, saveTotalReward } from "../store"; + +/** + * Context passed to handlers containing transaction metadata + */ +export interface HandlerContext { + /** Block number */ + blockNumber: bigint; + + /** Block timestamp */ + blockTimestamp: bigint; + + /** Transaction hash */ + transactionHash: string; + + /** Transaction index */ + transactionIndex: number; + + /** Treasury address for fee categorization */ + treasuryAddress: string; +} + +/** + * Result of processing an ETHDistributed event + */ +export interface ETHDistributedResult { + /** The created TotalReward entity, or null if report was non-profitable */ + totalReward: TotalRewardEntity | null; + + /** Whether the report was profitable (entity was created) */ + isProfitable: boolean; +} + +/** + * Handle ETHDistributed event - creates TotalReward entity for profitable reports + * + * This is the main entry point for processing oracle reports. + * It looks ahead to find the TokenRebased event and extracts pool state. + * + * Reference: lido-subgraph/src/Lido.ts handleETHDistributed() lines 477-571 + * + * @param event - The ETHDistributed event + * @param allLogs - All parsed logs from the transaction (for look-ahead) + * @param store - Entity store + * @param ctx - Handler context with transaction metadata + * @returns Result containing the created entity or null for non-profitable reports + */ +export function handleETHDistributed( + event: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + store: EntityStore, + ctx: HandlerContext, +): ETHDistributedResult { + // Extract ETHDistributed event params + const preCLBalance = getEventArg(event, "preCLBalance"); + const postCLBalance = getEventArg(event, "postCLBalance"); + const withdrawalsWithdrawn = getEventArg(event, "withdrawalsWithdrawn"); + const executionLayerRewardsWithdrawn = getEventArg(event, "executionLayerRewardsWithdrawn"); + + // Find TokenRebased event (look-ahead) + const tokenRebasedEvent = findEventByName(allLogs, "TokenRebased", event.logIndex); + + if (!tokenRebasedEvent) { + throw new Error( + `TokenRebased event not found after ETHDistributed in tx ${ctx.transactionHash} at logIndex ${event.logIndex}`, + ); + } + + // Check for non-profitable report (LIP-12) + // Don't mint/distribute any protocol fee on non-profitable oracle report + // when consensus layer balance delta is zero or negative + const postCLTotalBalance = postCLBalance + withdrawalsWithdrawn; + if (postCLTotalBalance <= preCLBalance) { + return { + totalReward: null, + isProfitable: false, + }; + } + + // Calculate total rewards with fees (same as real graph lines 553-556) + // totalRewardsWithFees = (postCLBalance + withdrawalsWithdrawn - preCLBalance) + executionLayerRewardsWithdrawn + const totalRewardsWithFees = postCLTotalBalance - preCLBalance + executionLayerRewardsWithdrawn; + + // Create TotalReward entity + const entity = createTotalRewardEntity(ctx.transactionHash); + + // Tier 1 - Direct Event Metadata + entity.block = ctx.blockNumber; + entity.blockTime = ctx.blockTimestamp; + entity.transactionHash = ctx.transactionHash; + entity.transactionIndex = BigInt(ctx.transactionIndex); + entity.logIndex = BigInt(event.logIndex); + + // Tier 2 - MEV fee from ETHDistributed + entity.mevFee = executionLayerRewardsWithdrawn; + + // Tier 2 - Total rewards with fees + entity.totalRewardsWithFees = totalRewardsWithFees; + + // Process TokenRebased to fill in pool state and fee distribution + _processTokenRebase(entity, tokenRebasedEvent, allLogs, event.logIndex, ctx.treasuryAddress); + + // Save entity + saveTotalReward(store, entity); + + return { + totalReward: entity, + isProfitable: true, + }; +} + +/** + * Process TokenRebased event to extract pool state fields, fee distribution, and calculate APR + * + * This is called from handleETHDistributed after look-ahead finds the event. + * + * Reference: lido-subgraph/src/Lido.ts _processTokenRebase() lines 573-690 + * + * @param entity - TotalReward entity to populate + * @param tokenRebasedEvent - The TokenRebased event + * @param allLogs - All parsed logs from the transaction (for Transfer/TransferShares extraction) + * @param ethDistributedLogIndex - Log index of the ETHDistributed event + * @param treasuryAddress - Treasury address for fee categorization + */ +export function _processTokenRebase( + entity: TotalRewardEntity, + tokenRebasedEvent: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + ethDistributedLogIndex: number, + treasuryAddress: string, +): void { + // Extract TokenRebased event params + // event TokenRebased( + // uint256 indexed reportTimestamp, + // uint256 timeElapsed, + // uint256 preTotalShares, + // uint256 preTotalEther, + // uint256 postTotalShares, + // uint256 postTotalEther, + // uint256 sharesMintedAsFees + // ) + + const preTotalEther = getEventArg(tokenRebasedEvent, "preTotalEther"); + const postTotalEther = getEventArg(tokenRebasedEvent, "postTotalEther"); + const preTotalShares = getEventArg(tokenRebasedEvent, "preTotalShares"); + const postTotalShares = getEventArg(tokenRebasedEvent, "postTotalShares"); + const sharesMintedAsFees = getEventArg(tokenRebasedEvent, "sharesMintedAsFees"); + const timeElapsed = getEventArg(tokenRebasedEvent, "timeElapsed"); + + // Tier 2 - Pool State + entity.totalPooledEtherBefore = preTotalEther; + entity.totalPooledEtherAfter = postTotalEther; + entity.totalSharesBefore = preTotalShares; + entity.totalSharesAfter = postTotalShares; + entity.shares2mint = sharesMintedAsFees; + entity.timeElapsed = timeElapsed; + + // ========== Fee Distribution Tracking ========== + // Reference: lido-subgraph/src/Lido.ts lines 586-662 + + // Extract Transfer/TransferShares pairs between ETHDistributed and TokenRebased + const transferPairs = findTransferSharesPairs(allLogs, ethDistributedLogIndex, tokenRebasedEvent.logIndex); + + // Process mint events and categorize by destination + let sharesToTreasury = 0n; + let sharesToOperators = 0n; + let treasuryFee = 0n; + let operatorsFee = 0n; + + const treasuryAddressLower = treasuryAddress.toLowerCase(); + + for (const pair of transferPairs) { + // Only process mint events (from = ZERO_ADDRESS) + if (pair.transfer.from.toLowerCase() === ZERO_ADDRESS.toLowerCase()) { + if (pair.transfer.to.toLowerCase() === treasuryAddressLower) { + // Mint to treasury + sharesToTreasury += pair.transferShares.sharesValue; + treasuryFee += pair.transfer.value; + } else { + // Mint to staking router module (operators) + sharesToOperators += pair.transferShares.sharesValue; + operatorsFee += pair.transfer.value; + } + } + } + + // Set fee distribution fields + entity.sharesToTreasury = sharesToTreasury; + entity.sharesToOperators = sharesToOperators; + entity.treasuryFee = treasuryFee; + entity.operatorsFee = operatorsFee; + entity.totalFee = treasuryFee + operatorsFee; + entity.totalRewards = entity.totalRewardsWithFees - entity.totalFee; + + // ========== Calculate Basis Points ========== + // Reference: lido-subgraph/src/Lido.ts lines 669-677 + + // feeBasis = totalFee * 10000 / totalRewardsWithFees + entity.feeBasis = + entity.totalRewardsWithFees > 0n ? (entity.totalFee * CALCULATION_UNIT) / entity.totalRewardsWithFees : 0n; + + // treasuryFeeBasisPoints = treasuryFee * 10000 / totalFee + entity.treasuryFeeBasisPoints = entity.totalFee > 0n ? (treasuryFee * CALCULATION_UNIT) / entity.totalFee : 0n; + + // operatorsFeeBasisPoints = operatorsFee * 10000 / totalFee + entity.operatorsFeeBasisPoints = entity.totalFee > 0n ? (operatorsFee * CALCULATION_UNIT) / entity.totalFee : 0n; + + // ========== Calculate APR ========== + // Reference: lido-subgraph/src/helpers.ts _calcAPR_v2() + entity.apr = calcAPR_v2(preTotalEther, postTotalEther, preTotalShares, postTotalShares, timeElapsed); + + // In v2, aprRaw and aprBeforeFees are the same as apr + entity.aprRaw = entity.apr; + entity.aprBeforeFees = entity.apr; +} + +/** + * Check if an event is an ETHDistributed event + * + * @param event - The event to check + * @returns true if this is an ETHDistributed event + */ +export function isETHDistributedEvent(event: LogDescriptionWithMeta): boolean { + return event.name === "ETHDistributed"; +} diff --git a/test/graph/simulator/helpers.ts b/test/graph/simulator/helpers.ts new file mode 100644 index 0000000000..08f1955de5 --- /dev/null +++ b/test/graph/simulator/helpers.ts @@ -0,0 +1,97 @@ +/** + * Helper functions for Graph Simulator + * + * This module will contain APR calculations and other derived value + * computations in future iterations. + * + * Reference: lido-subgraph/src/helpers.ts - _calcAPR_v2() + */ + +/** + * Calculation unit for basis points (10000 = 100%) + */ +export const CALCULATION_UNIT = 10000n; + +/** + * Precision base for share rate calculations (1e27) + */ +export const E27_PRECISION_BASE = 10n ** 27n; + +/** + * Seconds per year for APR calculations + */ +export const SECONDS_PER_YEAR = BigInt(60 * 60 * 24 * 365); + +/** + * Placeholder for APR calculation (Iteration 2) + * + * This will implement the V2 APR calculation based on share rate changes. + * + * Reference: lido-subgraph/src/helpers.ts _calcAPR_v2() lines 318-348 + * + * @param preTotalEther - Total ether before rebase + * @param postTotalEther - Total ether after rebase + * @param preTotalShares - Total shares before rebase + * @param postTotalShares - Total shares after rebase + * @param timeElapsed - Time elapsed in seconds + * @returns APR as a percentage (e.g., 5.0 for 5%) + */ +export function calcAPR_v2( + preTotalEther: bigint, + postTotalEther: bigint, + preTotalShares: bigint, + postTotalShares: bigint, + timeElapsed: bigint, +): number { + // Will be implemented in Iteration 2 + // For now, return 0 + if (timeElapsed === 0n || preTotalShares === 0n || postTotalShares === 0n) { + return 0; + } + + // APR formula from lido-subgraph: + // preShareRate = preTotalEther * E27 / preTotalShares + // postShareRate = postTotalEther * E27 / postTotalShares + // apr = secondsInYear * (postShareRate - preShareRate) * 100 / preShareRate / timeElapsed + + const preShareRate = (preTotalEther * E27_PRECISION_BASE) / preTotalShares; + const postShareRate = (postTotalEther * E27_PRECISION_BASE) / postTotalShares; + + if (preShareRate === 0n) { + return 0; + } + + // Use BigInt arithmetic then convert to number at the end + // Multiply by 10000 for precision, then divide by 100 at the end + const aprScaled = (SECONDS_PER_YEAR * (postShareRate - preShareRate) * 10000n * 100n) / (preShareRate * timeElapsed); + + return Number(aprScaled) / 10000; +} + +/** + * Calculate fee basis points + * + * @param totalFee - Total fee amount + * @param totalRewardsWithFees - Total rewards including fees + * @returns Fee in basis points (0-10000) + */ +export function calcFeeBasis(totalFee: bigint, totalRewardsWithFees: bigint): bigint { + if (totalRewardsWithFees === 0n) { + return 0n; + } + return (totalFee * CALCULATION_UNIT) / totalRewardsWithFees; +} + +/** + * Calculate component fee basis points + * + * @param componentFee - Component fee amount (treasury or operators) + * @param totalFee - Total fee amount + * @returns Component fee as fraction of total in basis points + */ +export function calcComponentFeeBasisPoints(componentFee: bigint, totalFee: bigint): bigint { + if (totalFee === 0n) { + return 0n; + } + return (componentFee * CALCULATION_UNIT) / totalFee; +} diff --git a/test/graph/simulator/index.ts b/test/graph/simulator/index.ts new file mode 100644 index 0000000000..f004a51023 --- /dev/null +++ b/test/graph/simulator/index.ts @@ -0,0 +1,362 @@ +/** + * Graph Simulator - Main Entry Point + * + * This module provides the main interface for simulating Graph indexer behavior. + * It processes transaction events and produces entities that should match + * what the actual Graph indexer would produce. + * + * Usage: + * ```typescript + * const store = createEntityStore(); + * const result = processTransaction(receipt, ctx, store); + * const totalReward = result.totalRewards.get(receipt.hash); + * ``` + * + * Reference: graph-tests-spec.md + */ + +import { ContractTransactionReceipt } from "ethers"; + +import { ProtocolContext } from "lib/protocol"; + +import { extractAllLogs, findTransferSharesPairs, ZERO_ADDRESS } from "../utils/event-extraction"; + +import { TotalRewardEntity } from "./entities"; +import { HandlerContext, processTransactionEvents, ProcessTransactionResult } from "./handlers"; +import { calcAPR_v2, CALCULATION_UNIT } from "./helpers"; +import { + countTotalRewards, + getLatestTotalReward, + getTotalRewardById, + getTotalRewardsInBlockRange, + queryTotalRewards, + TotalRewardsQueryParamsExtended, + TotalRewardsQueryResult, +} from "./query"; +import { createEntityStore, EntityStore } from "./store"; + +// Re-export types and utilities +export { TotalRewardEntity, createTotalRewardEntity } from "./entities"; +export { EntityStore, createEntityStore, getTotalReward, saveTotalReward } from "./store"; +export { SimulatorInitialState, PoolState, captureChainState, capturePoolState } from "../utils/state-capture"; +export { ProcessTransactionResult } from "./handlers"; + +// Re-export query types and functions +export { + queryTotalRewards, + getTotalRewardById, + countTotalRewards, + getLatestTotalReward, + getTotalRewardsInBlockRange, + TotalRewardsQueryParams, + TotalRewardsQueryParamsExtended, + TotalRewardsQueryResult, +} from "./query"; + +/** + * Process a transaction's events through the Graph simulator + * + * This is the main entry point for the simulator. It extracts all events + * from the transaction receipt, processes them through the appropriate + * handlers, and returns the resulting entities. + * + * @param receipt - Transaction receipt containing events + * @param ctx - Protocol context with contract interfaces + * @param store - Entity store for persistence (entities are saved here) + * @param blockTimestamp - Block timestamp (optional, defaults to current time) + * @param treasuryAddress - Treasury address for fee categorization (required for fee tracking) + * @returns Processing result with created/updated entities + */ +export function processTransaction( + receipt: ContractTransactionReceipt, + ctx: ProtocolContext, + store: EntityStore, + blockTimestamp?: bigint, + treasuryAddress?: string, +): ProcessTransactionResult { + // Extract all parseable logs from the transaction + const logs = extractAllLogs(receipt, ctx); + + // Build handler context from receipt + const handlerCtx: HandlerContext = { + blockNumber: BigInt(receipt.blockNumber), + blockTimestamp: blockTimestamp ?? BigInt(Math.floor(Date.now() / 1000)), + transactionHash: receipt.hash, + transactionIndex: receipt.index, + treasuryAddress: treasuryAddress ?? "", + }; + + // Process events through handlers + return processTransactionEvents(logs, store, handlerCtx); +} + +/** + * GraphSimulator class for stateful simulation + * + * This class wraps the simulator functionality with persistent state, + * useful for scenario tests where state persists across multiple transactions. + */ +export class GraphSimulator { + private store: EntityStore; + private treasuryAddress: string; + + constructor(treasuryAddress: string = "") { + this.store = createEntityStore(); + this.treasuryAddress = treasuryAddress; + } + + /** + * Set the treasury address for fee categorization + * + * @param address - Treasury address + */ + setTreasuryAddress(address: string): void { + this.treasuryAddress = address; + } + + /** + * Get the treasury address + * + * @returns Treasury address + */ + getTreasuryAddress(): string { + return this.treasuryAddress; + } + + /** + * Process a transaction and return the result + * + * @param receipt - Transaction receipt + * @param ctx - Protocol context + * @param blockTimestamp - Optional block timestamp + * @returns Processing result + */ + processTransaction( + receipt: ContractTransactionReceipt, + ctx: ProtocolContext, + blockTimestamp?: bigint, + ): ProcessTransactionResult { + return processTransaction(receipt, ctx, this.store, blockTimestamp, this.treasuryAddress); + } + + /** + * Get a TotalReward entity by transaction hash + * + * @param txHash - Transaction hash + * @returns The entity if found + */ + getTotalReward(txHash: string): TotalRewardEntity | undefined { + return this.store.totalRewards.get(txHash.toLowerCase()); + } + + /** + * Get the underlying store for advanced operations + */ + getStore(): EntityStore { + return this.store; + } + + /** + * Clear all stored entities + */ + reset(): void { + this.store = createEntityStore(); + } + + // ========== Query Methods ========== + + /** + * Query TotalRewards with filtering, ordering, and pagination + * + * Mimics the GraphQL query: + * ```graphql + * query TotalRewards($skip: Int!, $limit: Int!, $block_from: BigInt!) { + * totalRewards( + * skip: $skip + * first: $limit + * where: { block_gt: $block_from } + * orderBy: blockTime + * orderDirection: asc + * ) { ... } + * } + * ``` + * + * @param params - Query parameters (skip, limit, blockFrom, orderBy, orderDirection) + * @returns Array of matching TotalReward results + */ + queryTotalRewards(params: TotalRewardsQueryParamsExtended): TotalRewardsQueryResult[] { + return queryTotalRewards(this.store, params); + } + + /** + * Get a TotalReward by ID + * + * @param id - Transaction hash + * @returns The entity if found, null otherwise + */ + getTotalRewardById(id: string): TotalRewardEntity | null { + return getTotalRewardById(this.store, id); + } + + /** + * Count TotalRewards matching filter criteria + * + * @param blockFrom - Only count entities where block > blockFrom + * @returns Count of matching entities + */ + countTotalRewards(blockFrom: bigint = 0n): number { + return countTotalRewards(this.store, blockFrom); + } + + /** + * Get the most recent TotalReward by block time + * + * @returns The latest entity or null if store is empty + */ + getLatestTotalReward(): TotalRewardEntity | null { + return getLatestTotalReward(this.store); + } + + /** + * Get TotalRewards within a block range + * + * @param fromBlock - Start block (inclusive) + * @param toBlock - End block (inclusive) + * @returns Array of entities within the range + */ + getTotalRewardsInBlockRange(fromBlock: bigint, toBlock: bigint): TotalRewardEntity[] { + return getTotalRewardsInBlockRange(this.store, fromBlock, toBlock); + } +} + +/** + * Derive expected TotalReward field values from on-chain data + * + * This helper computes what the TotalReward fields should be based on + * the events in the transaction. Used for test verification. + * + * @param receipt - Transaction receipt + * @param ctx - Protocol context + * @param treasuryAddress - Treasury address for fee categorization (optional) + * @returns Expected TotalReward entity or null if non-profitable + */ +export function deriveExpectedTotalReward( + receipt: ContractTransactionReceipt, + ctx: ProtocolContext, + treasuryAddress?: string, +): TotalRewardEntity | null { + const logs = extractAllLogs(receipt, ctx); + + // Find ETHDistributed event + const ethDistributedEvent = logs.find((log) => log.name === "ETHDistributed"); + if (!ethDistributedEvent) { + return null; + } + + // Find TokenRebased event + const tokenRebasedEvent = logs.find((log) => log.name === "TokenRebased"); + if (!tokenRebasedEvent) { + return null; + } + + // Check profitability + const preCLBalance = ethDistributedEvent.args["preCLBalance"] as bigint; + const postCLBalance = ethDistributedEvent.args["postCLBalance"] as bigint; + const withdrawalsWithdrawn = ethDistributedEvent.args["withdrawalsWithdrawn"] as bigint; + const executionLayerRewardsWithdrawn = ethDistributedEvent.args["executionLayerRewardsWithdrawn"] as bigint; + + const postCLTotalBalance = postCLBalance + withdrawalsWithdrawn; + if (postCLTotalBalance <= preCLBalance) { + return null; // Non-profitable + } + + // Calculate total rewards with fees + const totalRewardsWithFees = postCLTotalBalance - preCLBalance + executionLayerRewardsWithdrawn; + + // Extract TokenRebased params + const preTotalEther = tokenRebasedEvent.args["preTotalEther"] as bigint; + const postTotalEther = tokenRebasedEvent.args["postTotalEther"] as bigint; + const preTotalShares = tokenRebasedEvent.args["preTotalShares"] as bigint; + const postTotalShares = tokenRebasedEvent.args["postTotalShares"] as bigint; + const timeElapsed = tokenRebasedEvent.args["timeElapsed"] as bigint; + const sharesMintedAsFees = tokenRebasedEvent.args["sharesMintedAsFees"] as bigint; + + // Calculate APR + const apr = calcAPR_v2(preTotalEther, postTotalEther, preTotalShares, postTotalShares, timeElapsed); + + // ========== Fee Distribution Tracking ========== + // Extract Transfer/TransferShares pairs between ETHDistributed and TokenRebased + const transferPairs = findTransferSharesPairs(logs, ethDistributedEvent.logIndex, tokenRebasedEvent.logIndex); + + // Process mint events and categorize by destination + let sharesToTreasury = 0n; + let sharesToOperators = 0n; + let treasuryFee = 0n; + let operatorsFee = 0n; + + const treasuryAddressLower = (treasuryAddress ?? "").toLowerCase(); + + for (const pair of transferPairs) { + // Only process mint events (from = ZERO_ADDRESS) + if (pair.transfer.from.toLowerCase() === ZERO_ADDRESS.toLowerCase()) { + if (treasuryAddressLower && pair.transfer.to.toLowerCase() === treasuryAddressLower) { + // Mint to treasury + sharesToTreasury += pair.transferShares.sharesValue; + treasuryFee += pair.transfer.value; + } else { + // Mint to staking router module (operators) + sharesToOperators += pair.transferShares.sharesValue; + operatorsFee += pair.transfer.value; + } + } + } + + const totalFee = treasuryFee + operatorsFee; + const totalRewards = totalRewardsWithFees - totalFee; + + // Calculate basis points + const feeBasis = totalRewardsWithFees > 0n ? (totalFee * CALCULATION_UNIT) / totalRewardsWithFees : 0n; + const treasuryFeeBasisPoints = totalFee > 0n ? (treasuryFee * CALCULATION_UNIT) / totalFee : 0n; + const operatorsFeeBasisPoints = totalFee > 0n ? (operatorsFee * CALCULATION_UNIT) / totalFee : 0n; + + // Build expected entity from events + const expected: TotalRewardEntity = { + // Tier 1 - from receipt + id: receipt.hash, + block: BigInt(receipt.blockNumber), + blockTime: 0n, // Will be set from block + transactionHash: receipt.hash, + transactionIndex: BigInt(receipt.index), + logIndex: BigInt(ethDistributedEvent.logIndex), + + // Tier 2 - Pool State from TokenRebased + totalPooledEtherBefore: preTotalEther, + totalPooledEtherAfter: postTotalEther, + totalSharesBefore: preTotalShares, + totalSharesAfter: postTotalShares, + shares2mint: sharesMintedAsFees, + timeElapsed, + + // Tier 2 - from ETHDistributed + mevFee: executionLayerRewardsWithdrawn, + + // Tier 2 - Fee Distribution + totalRewardsWithFees, + totalRewards, + totalFee, + treasuryFee, + operatorsFee, + sharesToTreasury, + sharesToOperators, + + // Tier 3 - calculated + apr, + aprRaw: apr, + aprBeforeFees: apr, + feeBasis, + treasuryFeeBasisPoints, + operatorsFeeBasisPoints, + }; + + return expected; +} diff --git a/test/graph/simulator/query.ts b/test/graph/simulator/query.ts new file mode 100644 index 0000000000..70a4cb0918 --- /dev/null +++ b/test/graph/simulator/query.ts @@ -0,0 +1,237 @@ +/** + * Query functions for Graph Simulator + * + * These functions mimic GraphQL queries against the simulator's entity store. + * They accept parameters similar to the GraphQL query variables. + * + * Reference GraphQL query: + * ```graphql + * query TotalRewards($skip: Int!, $limit: Int!, $block_from: BigInt!, $block: Bytes!) { + * totalRewards( + * skip: $skip + * first: $limit + * block: { hash: $block } + * where: { block_gt: $block_from } + * orderBy: blockTime + * orderDirection: asc + * ) { + * id + * totalPooledEtherBefore + * totalPooledEtherAfter + * totalSharesBefore + * totalSharesAfter + * apr + * block + * blockTime + * logIndex + * } + * } + * ``` + */ + +import { TotalRewardEntity } from "./entities"; +import { EntityStore } from "./store"; + +/** + * Query parameters for TotalRewards query + */ +export interface TotalRewardsQueryParams { + /** Number of results to skip (pagination) */ + skip: number; + + /** Maximum number of results to return */ + limit: number; + + /** + * Filter: only include entities where block > block_from + * Maps to GraphQL: where: { block_gt: $block_from } + */ + blockFrom: bigint; + + /** + * Block hash for historical query (optional) + * Maps to GraphQL: block: { hash: $block } + * Note: In simulator, this is ignored since we don't have historical state + */ + blockHash?: string; +} + +/** + * Result item from TotalRewards query + * Contains only the fields requested in the GraphQL query + */ +export interface TotalRewardsQueryResult { + id: string; + totalPooledEtherBefore: bigint; + totalPooledEtherAfter: bigint; + totalSharesBefore: bigint; + totalSharesAfter: bigint; + apr: number; + block: bigint; + blockTime: bigint; + logIndex: bigint; +} + +/** + * Order direction for sorting + */ +export type OrderDirection = "asc" | "desc"; + +/** + * Order by field options for TotalRewards + */ +export type TotalRewardsOrderBy = "blockTime" | "block" | "logIndex" | "apr"; + +/** + * Extended query parameters with ordering options + */ +export interface TotalRewardsQueryParamsExtended extends TotalRewardsQueryParams { + /** Field to order by (default: blockTime) */ + orderBy?: TotalRewardsOrderBy; + + /** Order direction (default: asc) */ + orderDirection?: OrderDirection; +} + +/** + * Query TotalRewards entities from the store + * + * This function mimics the GraphQL query behavior: + * - Filters by block_gt (block greater than) + * - Orders by blockTime ascending (default) + * - Applies skip/limit pagination + * + * @param store - Entity store to query + * @param params - Query parameters + * @returns Array of matching TotalReward results + */ +export function queryTotalRewards( + store: EntityStore, + params: TotalRewardsQueryParamsExtended, +): TotalRewardsQueryResult[] { + const { skip, limit, blockFrom, orderBy = "blockTime", orderDirection = "asc" } = params; + + // Get all entities from store + const allEntities = Array.from(store.totalRewards.values()); + + // Filter: block > blockFrom + const filtered = allEntities.filter((entity) => entity.block > blockFrom); + + // Sort by orderBy field + const sorted = filtered.sort((a, b) => { + let comparison: number; + + switch (orderBy) { + case "blockTime": + comparison = Number(a.blockTime - b.blockTime); + break; + case "block": + comparison = Number(a.block - b.block); + break; + case "logIndex": + comparison = Number(a.logIndex - b.logIndex); + break; + case "apr": + comparison = a.apr - b.apr; + break; + default: + comparison = Number(a.blockTime - b.blockTime); + } + + return orderDirection === "asc" ? comparison : -comparison; + }); + + // Apply pagination + const paginated = sorted.slice(skip, skip + limit); + + // Map to result format (only requested fields) + return paginated.map(mapToQueryResult); +} + +/** + * Map a TotalRewardEntity to the query result format + */ +function mapToQueryResult(entity: TotalRewardEntity): TotalRewardsQueryResult { + return { + id: entity.id, + totalPooledEtherBefore: entity.totalPooledEtherBefore, + totalPooledEtherAfter: entity.totalPooledEtherAfter, + totalSharesBefore: entity.totalSharesBefore, + totalSharesAfter: entity.totalSharesAfter, + apr: entity.apr, + block: entity.block, + blockTime: entity.blockTime, + logIndex: entity.logIndex, + }; +} + +/** + * Get a single TotalReward by ID (transaction hash) + * + * @param store - Entity store + * @param id - Transaction hash + * @returns The entity if found, null otherwise + */ +export function getTotalRewardById(store: EntityStore, id: string): TotalRewardEntity | null { + return store.totalRewards.get(id.toLowerCase()) ?? null; +} + +/** + * Count TotalRewards matching the filter criteria + * + * @param store - Entity store + * @param blockFrom - Filter: only count entities where block > blockFrom + * @returns Count of matching entities + */ +export function countTotalRewards(store: EntityStore, blockFrom: bigint = 0n): number { + let count = 0; + for (const entity of store.totalRewards.values()) { + if (entity.block > blockFrom) { + count++; + } + } + return count; +} + +/** + * Get the latest TotalReward entity by block time + * + * @param store - Entity store + * @returns The most recent entity or null if store is empty + */ +export function getLatestTotalReward(store: EntityStore): TotalRewardEntity | null { + let latest: TotalRewardEntity | null = null; + + for (const entity of store.totalRewards.values()) { + if (!latest || entity.blockTime > latest.blockTime) { + latest = entity; + } + } + + return latest; +} + +/** + * Get TotalRewards within a block range + * + * @param store - Entity store + * @param fromBlock - Start block (inclusive) + * @param toBlock - End block (inclusive) + * @returns Array of entities within the range, ordered by blockTime asc + */ +export function getTotalRewardsInBlockRange( + store: EntityStore, + fromBlock: bigint, + toBlock: bigint, +): TotalRewardEntity[] { + const results: TotalRewardEntity[] = []; + + for (const entity of store.totalRewards.values()) { + if (entity.block >= fromBlock && entity.block <= toBlock) { + results.push(entity); + } + } + + // Sort by blockTime ascending + return results.sort((a, b) => Number(a.blockTime - b.blockTime)); +} diff --git a/test/graph/simulator/store.ts b/test/graph/simulator/store.ts new file mode 100644 index 0000000000..e0d89a2406 --- /dev/null +++ b/test/graph/simulator/store.ts @@ -0,0 +1,80 @@ +/** + * In-memory entity store for Graph Simulator + * + * This store mimics the Graph's database for storing entities during simulation. + * Entities are keyed by their ID (transaction hash for TotalReward). + * + * Reference: The Graph's store API provides load/save operations for entities + */ + +import { TotalRewardEntity } from "./entities"; + +/** + * Entity store interface containing all entity collections + * + * Each entity type has its own Map keyed by entity ID. + * Future iterations will add more entity types (NodeOperatorFees, etc.) + */ +export interface EntityStore { + /** TotalReward entities keyed by transaction hash */ + totalRewards: Map; + + // Future entity collections (Iteration 2+): + // nodeOperatorFees: Map; + // nodeOperatorsShares: Map; + // oracleReports: Map; +} + +/** + * Create a new empty entity store + * + * @returns Fresh EntityStore with empty collections + */ +export function createEntityStore(): EntityStore { + return { + totalRewards: new Map(), + }; +} + +/** + * Clear all entities from the store + * + * Useful for resetting state between test runs. + * + * @param store - The store to clear + */ +export function clearStore(store: EntityStore): void { + store.totalRewards.clear(); +} + +/** + * Get a TotalReward entity by ID (transaction hash) + * + * @param store - The entity store + * @param id - Transaction hash + * @returns The entity if found, undefined otherwise + */ +export function getTotalReward(store: EntityStore, id: string): TotalRewardEntity | undefined { + return store.totalRewards.get(id.toLowerCase()); +} + +/** + * Save a TotalReward entity to the store + * + * @param store - The entity store + * @param entity - The entity to save + */ +export function saveTotalReward(store: EntityStore, entity: TotalRewardEntity): void { + store.totalRewards.set(entity.id.toLowerCase(), entity); +} + +/** + * Check if a TotalReward entity exists + * + * @param store - The entity store + * @param id - Transaction hash + * @returns true if entity exists + */ +export function hasTotalReward(store: EntityStore, id: string): boolean { + return store.totalRewards.has(id.toLowerCase()); +} diff --git a/test/graph/total-reward.integration.ts b/test/graph/total-reward.integration.ts new file mode 100644 index 0000000000..57bf2ce3e3 --- /dev/null +++ b/test/graph/total-reward.integration.ts @@ -0,0 +1,674 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, formatEther, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { advanceChainTime, ether, log, updateBalance } from "lib"; +import { + finalizeWQViaElVault, + getProtocolContext, + norSdvtEnsureOperators, + OracleReportParams, + ProtocolContext, + removeStakingLimit, + report, + setStakingLimit, +} from "lib/protocol"; + +import { bailOnFailure, Snapshot } from "test/suite"; + +import { createEntityStore, deriveExpectedTotalReward, GraphSimulator, processTransaction } from "./simulator"; +import { captureChainState, capturePoolState, SimulatorInitialState } from "./utils"; +import { extractAllLogs } from "./utils/event-extraction"; + +/** + * Graph TotalReward Entity Integration Tests + * + * These tests validate that the Graph simulator correctly computes TotalReward + * entity fields when processing oracle report transactions. + * + * Test Strategy: + * 1. Execute an oracle report transaction + * 2. Process the transaction events through the simulator + * 3. Compare simulator output against expected values derived from events + * + * All comparisons use exact bigint matching (no tolerance). + * + * Reference: test/graph/graph-tests-spec.md + */ +describe("Scenario: Graph TotalReward Validation", () => { + let ctx: ProtocolContext; + let snapshot: string; + + let stEthHolder: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let simulator: GraphSimulator; + let initialState: SimulatorInitialState; + let depositCount: bigint; + + before(async () => { + ctx = await getProtocolContext(); + + [stEthHolder, stranger] = await ethers.getSigners(); + await updateBalance(stranger.address, ether("100000000")); + await updateBalance(stEthHolder.address, ether("100000000")); + + snapshot = await Snapshot.take(); + + // Capture initial chain state first + initialState = await captureChainState(ctx); + + // Initialize simulator with treasury address + simulator = new GraphSimulator(initialState.treasuryAddress); + + log.info("Graph Simulator initialized", { + "Total Pooled Ether": formatEther(initialState.totalPooledEther), + "Total Shares": initialState.totalShares.toString(), + "Treasury Address": initialState.treasuryAddress, + "Staking Modules": initialState.stakingModuleAddresses.length, + }); + + // Setup protocol state + await removeStakingLimit(ctx); + await setStakingLimit(ctx, ether("200000"), ether("20")); + }); + + after(async () => await Snapshot.restore(snapshot)); + + beforeEach(bailOnFailure); + + it("Should finalize withdrawal queue and prepare protocol", async () => { + const { lido } = ctx.contracts; + + // Deposit some ETH to have stETH for testing + const stEthHolderAmount = ether("1000"); + await lido.connect(stEthHolder).submit(ZeroAddress, { value: stEthHolderAmount }); + + const stEthHolderBalance = await lido.balanceOf(stEthHolder.address); + expect(stEthHolderBalance).to.approximately(stEthHolderAmount, 10n, "stETH balance increased"); + + await finalizeWQViaElVault(ctx); + }); + + it("Should have at least 3 node operators in every module", async () => { + await norSdvtEnsureOperators(ctx, ctx.contracts.nor, 3n, 5n); + expect(await ctx.contracts.nor.getNodeOperatorsCount()).to.be.at.least(3n); + + await norSdvtEnsureOperators(ctx, ctx.contracts.sdvt, 3n, 5n); + expect(await ctx.contracts.sdvt.getNodeOperatorsCount()).to.be.at.least(3n); + }); + + it("Should deposit ETH and stake to modules", async () => { + const { lido, stakingRouter, depositSecurityModule } = ctx.contracts; + + log.info("Submitting ETH for deposits", { + Amount: formatEther(ether("3200")), + }); + + // Submit more ETH for deposits + await lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("3200") }); + + const { impersonate, ether: etherFn } = await import("lib"); + + const dsmSigner = await impersonate(depositSecurityModule.address, etherFn("100")); + const stakingModules = (await stakingRouter.getStakingModules()).filter((m) => m.id === 1n); + depositCount = 0n; + + const MAX_DEPOSIT = 150n; + const ZERO_HASH = new Uint8Array(32).fill(0); + + for (const module of stakingModules) { + const depositTx = await lido.connect(dsmSigner).deposit(MAX_DEPOSIT, module.id, ZERO_HASH); + const depositReceipt = (await depositTx.wait()) as ContractTransactionReceipt; + const unbufferedEvent = ctx.getEvents(depositReceipt, "Unbuffered")[0]; + const unbufferedAmount = unbufferedEvent?.args[0] || 0n; + const deposits = unbufferedAmount / ether("32"); + + depositCount += deposits; + } + + log.info("Deposits completed", { + "Total Deposits": depositCount.toString(), + "ETH Staked": formatEther(depositCount * ether("32")), + }); + + expect(depositCount).to.be.gt(0n, "No deposits applied"); + }); + + it("Should compute TotalReward correctly for first oracle report", async () => { + log("=== First Oracle Report: TotalReward Validation ==="); + + // 1. Capture state before oracle report + const stateBefore = await capturePoolState(ctx); + + log.info("Pool state before report", { + "Total Pooled Ether": formatEther(stateBefore.totalPooledEther), + "Total Shares": stateBefore.totalShares.toString(), + }); + + // 2. Execute oracle report with rewards + const clDiff = ether("32") * depositCount + ether("0.001"); + const reportData: Partial = { + clDiff, + clAppearedValidators: depositCount, + }; + + log.info("Executing oracle report", { + "CL Diff": formatEther(clDiff), + "Appeared Validators": depositCount.toString(), + }); + + await advanceChainTime(12n * 60n * 60n); // 12 hours + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + // Get block timestamp + const block = await ethers.provider.getBlock(receipt.blockNumber); + const blockTimestamp = BigInt(block!.timestamp); + + log.info("Oracle report transaction", { + "Tx Hash": receipt.hash, + "Block Number": receipt.blockNumber, + "Block Timestamp": blockTimestamp.toString(), + "Log Count": receipt.logs.length, + }); + + // 3. Process events through simulator + const store = createEntityStore(); + const result = processTransaction(receipt, ctx, store, blockTimestamp, initialState.treasuryAddress); + + log.info("Simulator processing result", { + "Events Processed": result.eventsProcessed, + "Had Profitable Report": result.hadProfitableReport, + "TotalReward Entities Created": result.totalRewards.size, + }); + + // 4. Capture state after + const stateAfter = await capturePoolState(ctx); + + log.info("Pool state after report", { + "Total Pooled Ether": formatEther(stateAfter.totalPooledEther), + "Total Shares": stateAfter.totalShares.toString(), + "Ether Change": formatEther(stateAfter.totalPooledEther - stateBefore.totalPooledEther), + "Shares Change": (stateAfter.totalShares - stateBefore.totalShares).toString(), + }); + + // 5. Verify a TotalReward entity was created + expect(result.hadProfitableReport).to.be.true; + expect(result.totalRewards.size).to.equal(1); + + const computed = result.totalRewards.get(receipt.hash); + expect(computed).to.not.be.undefined; + + // 6. Derive expected values directly from events + const expected = deriveExpectedTotalReward(receipt, ctx, initialState.treasuryAddress); + expect(expected).to.not.be.null; + + // Log entity details + log.info("TotalReward Entity - Tier 1 (Metadata)", { + "ID": computed!.id, + "Block": computed!.block.toString(), + "Block Time": computed!.blockTime.toString(), + "Tx Index": computed!.transactionIndex.toString(), + "Log Index": computed!.logIndex.toString(), + }); + + log.info("TotalReward Entity - Tier 2 (Pool State)", { + "Total Pooled Ether Before": formatEther(computed!.totalPooledEtherBefore), + "Total Pooled Ether After": formatEther(computed!.totalPooledEtherAfter), + "Total Shares Before": computed!.totalSharesBefore.toString(), + "Total Shares After": computed!.totalSharesAfter.toString(), + "Shares Minted As Fees": computed!.shares2mint.toString(), + "Time Elapsed": computed!.timeElapsed.toString(), + "MEV Fee": formatEther(computed!.mevFee), + }); + + log.info("TotalReward Entity - Tier 2 (Fee Distribution)", { + "Total Rewards With Fees": formatEther(computed!.totalRewardsWithFees), + "Total Rewards": formatEther(computed!.totalRewards), + "Total Fee": formatEther(computed!.totalFee), + "Treasury Fee": formatEther(computed!.treasuryFee), + "Operators Fee": formatEther(computed!.operatorsFee), + "Shares To Treasury": computed!.sharesToTreasury.toString(), + "Shares To Operators": computed!.sharesToOperators.toString(), + }); + + log.info("TotalReward Entity - Tier 3 (Calculated)", { + "APR": `${computed!.apr.toFixed(4)}%`, + "APR Raw": `${computed!.aprRaw.toFixed(4)}%`, + "APR Before Fees": `${computed!.aprBeforeFees.toFixed(4)}%`, + "Fee Basis": computed!.feeBasis.toString(), + "Treasury Fee Basis Points": computed!.treasuryFeeBasisPoints.toString(), + "Operators Fee Basis Points": computed!.operatorsFeeBasisPoints.toString(), + }); + + // 7. Verify Tier 1 fields (Direct Event Metadata) + log.info("Verifying Tier 1 fields (Direct Event Metadata)..."); + expect(computed!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "id mismatch"); + expect(computed!.block).to.equal(BigInt(receipt.blockNumber), "block mismatch"); + expect(computed!.blockTime).to.equal(blockTimestamp, "blockTime mismatch"); + expect(computed!.transactionHash.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "transactionHash mismatch"); + expect(computed!.transactionIndex).to.equal(BigInt(receipt.index), "transactionIndex mismatch"); + expect(computed!.logIndex).to.equal(expected!.logIndex, "logIndex mismatch"); + + // 8. Verify Tier 2 fields (Pool State from TokenRebased) + log.info("Verifying Tier 2 fields (Pool State from TokenRebased)..."); + expect(computed!.totalPooledEtherBefore).to.equal( + expected!.totalPooledEtherBefore, + "totalPooledEtherBefore mismatch", + ); + expect(computed!.totalPooledEtherAfter).to.equal(expected!.totalPooledEtherAfter, "totalPooledEtherAfter mismatch"); + expect(computed!.totalSharesBefore).to.equal(expected!.totalSharesBefore, "totalSharesBefore mismatch"); + expect(computed!.totalSharesAfter).to.equal(expected!.totalSharesAfter, "totalSharesAfter mismatch"); + expect(computed!.shares2mint).to.equal(expected!.shares2mint, "shares2mint mismatch"); + expect(computed!.timeElapsed).to.equal(expected!.timeElapsed, "timeElapsed mismatch"); + expect(computed!.mevFee).to.equal(expected!.mevFee, "mevFee mismatch"); + + // 8b. Verify Tier 2 fields (Fee Distribution) + log.info("Verifying Tier 2 fields (Fee Distribution)..."); + expect(computed!.totalRewardsWithFees).to.equal(expected!.totalRewardsWithFees, "totalRewardsWithFees mismatch"); + expect(computed!.totalRewards).to.equal(expected!.totalRewards, "totalRewards mismatch"); + expect(computed!.totalFee).to.equal(expected!.totalFee, "totalFee mismatch"); + expect(computed!.treasuryFee).to.equal(expected!.treasuryFee, "treasuryFee mismatch"); + expect(computed!.operatorsFee).to.equal(expected!.operatorsFee, "operatorsFee mismatch"); + expect(computed!.sharesToTreasury).to.equal(expected!.sharesToTreasury, "sharesToTreasury mismatch"); + expect(computed!.sharesToOperators).to.equal(expected!.sharesToOperators, "sharesToOperators mismatch"); + + // 8c. Verify Tier 3 fields (Calculated) + log.info("Verifying Tier 3 fields (APR and Basis Points)..."); + expect(computed!.apr).to.equal(expected!.apr, "apr mismatch"); + expect(computed!.aprRaw).to.equal(expected!.aprRaw, "aprRaw mismatch"); + expect(computed!.aprBeforeFees).to.equal(expected!.aprBeforeFees, "aprBeforeFees mismatch"); + expect(computed!.feeBasis).to.equal(expected!.feeBasis, "feeBasis mismatch"); + expect(computed!.treasuryFeeBasisPoints).to.equal( + expected!.treasuryFeeBasisPoints, + "treasuryFeeBasisPoints mismatch", + ); + expect(computed!.operatorsFeeBasisPoints).to.equal( + expected!.operatorsFeeBasisPoints, + "operatorsFeeBasisPoints mismatch", + ); + + // 8d. Verify fee consistency (shares2mint should equal sharesToTreasury + sharesToOperators) + log.info("Verifying fee consistency..."); + expect(computed!.shares2mint).to.equal( + computed!.sharesToTreasury + computed!.sharesToOperators, + "shares2mint should equal sharesToTreasury + sharesToOperators", + ); + expect(computed!.totalFee).to.equal( + computed!.treasuryFee + computed!.operatorsFee, + "totalFee should equal treasuryFee + operatorsFee", + ); + + // 9. Verify consistency with on-chain state + log.info("Verifying consistency with on-chain state..."); + // TokenRebased.preTotalEther should match state before report + expect(computed!.totalPooledEtherBefore).to.equal(stateBefore.totalPooledEther, "preTotalEther vs stateBefore"); + expect(computed!.totalSharesBefore).to.equal(stateBefore.totalShares, "preTotalShares vs stateBefore"); + + // TokenRebased.postTotalEther should match state after report + expect(computed!.totalPooledEtherAfter).to.equal(stateAfter.totalPooledEther, "postTotalEther vs stateAfter"); + expect(computed!.totalSharesAfter).to.equal(stateAfter.totalShares, "postTotalShares vs stateAfter"); + + log("First oracle report validation PASSED"); + }); + + it("Should compute TotalReward correctly for second oracle report", async () => { + log("=== Second Oracle Report: TotalReward Validation ==="); + + // 1. Capture state before second oracle report + const stateBefore = await capturePoolState(ctx); + + log.info("Pool state before second report", { + "Total Pooled Ether": formatEther(stateBefore.totalPooledEther), + "Total Shares": stateBefore.totalShares.toString(), + }); + + // 2. Execute second oracle report with different rewards + const clDiff = ether("0.005"); + const reportData: Partial = { + clDiff, // Smaller reward + }; + + log.info("Executing second oracle report", { + "CL Diff": formatEther(clDiff), + }); + + await advanceChainTime(12n * 60n * 60n); // 12 hours + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + // Get block timestamp + const block = await ethers.provider.getBlock(receipt.blockNumber); + const blockTimestamp = BigInt(block!.timestamp); + + log.info("Second oracle report transaction", { + "Tx Hash": receipt.hash, + "Block Number": receipt.blockNumber, + "Block Timestamp": blockTimestamp.toString(), + "Log Count": receipt.logs.length, + }); + + // 3. Process events through simulator (using same simulator instance) + const result = simulator.processTransaction(receipt, ctx, blockTimestamp); + + log.info("Simulator processing result (using persistent simulator)", { + "Events Processed": result.eventsProcessed, + "Had Profitable Report": result.hadProfitableReport, + "TotalReward Entities Created": result.totalRewards.size, + "Total Entities in Store": simulator.getStore().totalRewards.size, + }); + + // 4. Capture state after + const stateAfter = await capturePoolState(ctx); + + log.info("Pool state after second report", { + "Total Pooled Ether": formatEther(stateAfter.totalPooledEther), + "Total Shares": stateAfter.totalShares.toString(), + "Ether Change": formatEther(stateAfter.totalPooledEther - stateBefore.totalPooledEther), + "Shares Change": (stateAfter.totalShares - stateBefore.totalShares).toString(), + }); + + // 5. Verify a TotalReward entity was created + expect(result.hadProfitableReport).to.be.true; + expect(result.totalRewards.size).to.equal(1); + + const computed = result.totalRewards.get(receipt.hash); + expect(computed).to.not.be.undefined; + + // 6. Derive expected values directly from events + const expected = deriveExpectedTotalReward(receipt, ctx, initialState.treasuryAddress); + expect(expected).to.not.be.null; + + // Log entity details + log.info("TotalReward Entity - Tier 1 (Metadata)", { + "ID": computed!.id, + "Block": computed!.block.toString(), + "Block Time": computed!.blockTime.toString(), + "Tx Index": computed!.transactionIndex.toString(), + "Log Index": computed!.logIndex.toString(), + }); + + log.info("TotalReward Entity - Tier 2 (Pool State)", { + "Total Pooled Ether Before": formatEther(computed!.totalPooledEtherBefore), + "Total Pooled Ether After": formatEther(computed!.totalPooledEtherAfter), + "Total Shares Before": computed!.totalSharesBefore.toString(), + "Total Shares After": computed!.totalSharesAfter.toString(), + "Shares Minted As Fees": computed!.shares2mint.toString(), + "Time Elapsed": computed!.timeElapsed.toString(), + "MEV Fee": formatEther(computed!.mevFee), + }); + + log.info("TotalReward Entity - Tier 2 (Fee Distribution)", { + "Total Rewards With Fees": formatEther(computed!.totalRewardsWithFees), + "Total Rewards": formatEther(computed!.totalRewards), + "Total Fee": formatEther(computed!.totalFee), + "Treasury Fee": formatEther(computed!.treasuryFee), + "Operators Fee": formatEther(computed!.operatorsFee), + "Shares To Treasury": computed!.sharesToTreasury.toString(), + "Shares To Operators": computed!.sharesToOperators.toString(), + }); + + log.info("TotalReward Entity - Tier 3 (Calculated)", { + "APR": `${computed!.apr.toFixed(4)}%`, + "APR Raw": `${computed!.aprRaw.toFixed(4)}%`, + "APR Before Fees": `${computed!.aprBeforeFees.toFixed(4)}%`, + "Fee Basis": computed!.feeBasis.toString(), + "Treasury Fee Basis Points": computed!.treasuryFeeBasisPoints.toString(), + "Operators Fee Basis Points": computed!.operatorsFeeBasisPoints.toString(), + }); + + // 7. Verify Tier 1 fields + log.info("Verifying Tier 1 fields..."); + expect(computed!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "id mismatch"); + expect(computed!.block).to.equal(BigInt(receipt.blockNumber), "block mismatch"); + expect(computed!.blockTime).to.equal(blockTimestamp, "blockTime mismatch"); + expect(computed!.transactionHash.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "transactionHash mismatch"); + expect(computed!.transactionIndex).to.equal(BigInt(receipt.index), "transactionIndex mismatch"); + expect(computed!.logIndex).to.equal(expected!.logIndex, "logIndex mismatch"); + + // 8. Verify Tier 2 fields + log.info("Verifying Tier 2 fields..."); + expect(computed!.totalPooledEtherBefore).to.equal( + expected!.totalPooledEtherBefore, + "totalPooledEtherBefore mismatch", + ); + expect(computed!.totalPooledEtherAfter).to.equal(expected!.totalPooledEtherAfter, "totalPooledEtherAfter mismatch"); + expect(computed!.totalSharesBefore).to.equal(expected!.totalSharesBefore, "totalSharesBefore mismatch"); + expect(computed!.totalSharesAfter).to.equal(expected!.totalSharesAfter, "totalSharesAfter mismatch"); + expect(computed!.shares2mint).to.equal(expected!.shares2mint, "shares2mint mismatch"); + expect(computed!.timeElapsed).to.equal(expected!.timeElapsed, "timeElapsed mismatch"); + expect(computed!.mevFee).to.equal(expected!.mevFee, "mevFee mismatch"); + + // 8b. Verify Tier 2 fields (Fee Distribution) + log.info("Verifying Tier 2 fields (Fee Distribution)..."); + expect(computed!.totalRewardsWithFees).to.equal(expected!.totalRewardsWithFees, "totalRewardsWithFees mismatch"); + expect(computed!.totalRewards).to.equal(expected!.totalRewards, "totalRewards mismatch"); + expect(computed!.totalFee).to.equal(expected!.totalFee, "totalFee mismatch"); + expect(computed!.treasuryFee).to.equal(expected!.treasuryFee, "treasuryFee mismatch"); + expect(computed!.operatorsFee).to.equal(expected!.operatorsFee, "operatorsFee mismatch"); + expect(computed!.sharesToTreasury).to.equal(expected!.sharesToTreasury, "sharesToTreasury mismatch"); + expect(computed!.sharesToOperators).to.equal(expected!.sharesToOperators, "sharesToOperators mismatch"); + + // 8c. Verify Tier 3 fields (APR and Basis Points) + log.info("Verifying Tier 3 fields (APR and Basis Points)..."); + expect(computed!.apr).to.equal(expected!.apr, "apr mismatch"); + expect(computed!.aprRaw).to.equal(expected!.aprRaw, "aprRaw mismatch"); + expect(computed!.aprBeforeFees).to.equal(expected!.aprBeforeFees, "aprBeforeFees mismatch"); + expect(computed!.feeBasis).to.equal(expected!.feeBasis, "feeBasis mismatch"); + expect(computed!.treasuryFeeBasisPoints).to.equal( + expected!.treasuryFeeBasisPoints, + "treasuryFeeBasisPoints mismatch", + ); + expect(computed!.operatorsFeeBasisPoints).to.equal( + expected!.operatorsFeeBasisPoints, + "operatorsFeeBasisPoints mismatch", + ); + + // 8d. Verify fee consistency + log.info("Verifying fee consistency..."); + expect(computed!.shares2mint).to.equal( + computed!.sharesToTreasury + computed!.sharesToOperators, + "shares2mint should equal sharesToTreasury + sharesToOperators", + ); + expect(computed!.totalFee).to.equal( + computed!.treasuryFee + computed!.operatorsFee, + "totalFee should equal treasuryFee + operatorsFee", + ); + + // 9. Verify state consistency + log.info("Verifying on-chain state consistency..."); + expect(computed!.totalPooledEtherBefore).to.equal(stateBefore.totalPooledEther, "preTotalEther vs stateBefore"); + expect(computed!.totalSharesBefore).to.equal(stateBefore.totalShares, "preTotalShares vs stateBefore"); + expect(computed!.totalPooledEtherAfter).to.equal(stateAfter.totalPooledEther, "postTotalEther vs stateAfter"); + expect(computed!.totalSharesAfter).to.equal(stateAfter.totalShares, "postTotalShares vs stateAfter"); + + // 10. Verify simulator state persistence (should have both reports) + log.info("Verifying simulator state persistence..."); + const storedReport = simulator.getTotalReward(receipt.hash); + expect(storedReport).to.not.be.undefined; + + log("Second oracle report validation PASSED"); + }); + + it("Should verify event processing order", async () => { + // This test validates that events are processed in the correct order + // by examining the logs from the last oracle report + + log("=== Event Processing Order Verification ==="); + + const clDiff = ether("0.002"); + const reportData: Partial = { + clDiff, + }; + + log.info("Executing oracle report for event order test", { + "CL Diff": formatEther(clDiff), + }); + + await advanceChainTime(12n * 60n * 60n); + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + // Extract and examine logs + const logs = extractAllLogs(receipt, ctx); + + log.info("Extracted logs from transaction", { + "Total Logs": logs.length, + "Tx Hash": receipt.hash, + }); + + // Log all event names in order + const eventSummary = logs.map((l) => `${l.logIndex}: ${l.name}`).join(", "); + log.info("Event order", { + Events: eventSummary, + }); + + // Find key events + const ethDistributedIdx = logs.findIndex((l) => l.name === "ETHDistributed"); + const tokenRebasedIdx = logs.findIndex((l) => l.name === "TokenRebased"); + const processingStartedIdx = logs.findIndex((l) => l.name === "ProcessingStarted"); + + log.info("Key event positions", { + "ProcessingStarted Index": processingStartedIdx, + "ETHDistributed Index": ethDistributedIdx, + "TokenRebased Index": tokenRebasedIdx, + }); + + // Verify ETHDistributed comes before TokenRebased (as expected by look-ahead) + expect(ethDistributedIdx).to.be.greaterThanOrEqual(0, "ETHDistributed event not found"); + expect(tokenRebasedIdx).to.be.greaterThanOrEqual(0, "TokenRebased event not found"); + expect(ethDistributedIdx).to.be.lessThan(tokenRebasedIdx, "ETHDistributed should come before TokenRebased"); + + // Verify Transfer events are between ETHDistributed and TokenRebased (fee mints) + const transferEvents = logs.filter( + (l) => l.name === "Transfer" && l.logIndex > ethDistributedIdx && l.logIndex < tokenRebasedIdx, + ); + const transferSharesEvents = logs.filter( + (l) => l.name === "TransferShares" && l.logIndex > ethDistributedIdx && l.logIndex < tokenRebasedIdx, + ); + + log.info("Fee distribution events between ETHDistributed and TokenRebased", { + "Transfer Events": transferEvents.length, + "TransferShares Events": transferSharesEvents.length, + }); + + // There should be at least some transfer events for fee distribution + expect(transferEvents.length).to.be.greaterThanOrEqual(0, "Expected Transfer events for fee distribution"); + + log("Event processing order verification PASSED"); + }); + + it("Should query TotalRewards with filtering and pagination", async () => { + log("=== Query Functionality Test ==="); + + // Execute another oracle report to have more data + const clDiff = ether("0.003"); + const reportData: Partial = { clDiff }; + + await advanceChainTime(12n * 60n * 60n); + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + const block = await ethers.provider.getBlock(receipt.blockNumber); + const blockTimestamp = BigInt(block!.timestamp); + + // Process through simulator + simulator.processTransaction(receipt, ctx, blockTimestamp); + + // Test 1: Count all TotalRewards + const totalCount = simulator.countTotalRewards(0n); + log.info("Query: Count all TotalRewards", { + "Total Count": totalCount, + }); + expect(totalCount).to.be.greaterThanOrEqual(2, "Should have at least 2 TotalReward entities"); + + // Test 2: Query with pagination + const queryResult = simulator.queryTotalRewards({ + skip: 0, + limit: 10, + blockFrom: 0n, + orderBy: "blockTime", + orderDirection: "asc", + }); + + log.info("Query: TotalRewards (skip=0, limit=10, orderBy=blockTime asc)", { + "Results Count": queryResult.length, + "First Block Time": queryResult[0]?.blockTime.toString(), + "Last Block Time": queryResult[queryResult.length - 1]?.blockTime.toString(), + }); + + expect(queryResult.length).to.be.greaterThanOrEqual(2); + + // Verify ordering (ascending by blockTime) + for (let i = 1; i < queryResult.length; i++) { + expect(queryResult[i].blockTime).to.be.gte( + queryResult[i - 1].blockTime, + "Results should be ordered by blockTime ascending", + ); + } + + // Test 3: Query result contains expected fields + const firstResult = queryResult[0]; + log.info("Query result fields check", { + "Has id": firstResult.id !== undefined, + "Has totalPooledEtherBefore": firstResult.totalPooledEtherBefore !== undefined, + "Has totalPooledEtherAfter": firstResult.totalPooledEtherAfter !== undefined, + "Has totalSharesBefore": firstResult.totalSharesBefore !== undefined, + "Has totalSharesAfter": firstResult.totalSharesAfter !== undefined, + "Has apr": firstResult.apr !== undefined, + "Has block": firstResult.block !== undefined, + "Has blockTime": firstResult.blockTime !== undefined, + "Has logIndex": firstResult.logIndex !== undefined, + }); + + expect(firstResult.id).to.be.a("string"); + expect(typeof firstResult.totalPooledEtherBefore).to.equal("bigint"); + expect(typeof firstResult.apr).to.equal("number"); + + // Test 4: Query with block filter + const firstBlock = queryResult[0].block; + const filteredResult = simulator.queryTotalRewards({ + skip: 0, + limit: 10, + blockFrom: firstBlock, // Only get entities AFTER the first block + orderBy: "blockTime", + orderDirection: "asc", + }); + + log.info("Query: TotalRewards with block filter", { + "Filter blockFrom": firstBlock.toString(), + "Filtered Results Count": filteredResult.length, + }); + + // All filtered results should have block > firstBlock + for (const result of filteredResult) { + expect(result.block).to.be.gt(firstBlock, "Filtered results should have block > blockFrom"); + } + + // Test 5: Get latest TotalReward + const latest = simulator.getLatestTotalReward(); + log.info("Query: Latest TotalReward", { + "Latest ID": latest?.id ?? "N/A", + "Latest Block": latest?.block.toString() ?? "N/A", + "Latest APR": latest ? `${latest.apr.toFixed(4)}%` : "N/A", + }); + + expect(latest).to.not.be.null; + expect(latest!.blockTime).to.equal(queryResult[queryResult.length - 1].blockTime); + + // Test 6: Get by ID + const byId = simulator.getTotalRewardById(receipt.hash); + log.info("Query: Get by ID", { + "Requested ID": receipt.hash, + "Found": byId !== null, + }); + + expect(byId).to.not.be.null; + expect(byId!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase()); + + log("Query functionality test PASSED"); + }); +}); diff --git a/test/graph/utils/event-extraction.ts b/test/graph/utils/event-extraction.ts new file mode 100644 index 0000000000..1662f62d64 --- /dev/null +++ b/test/graph/utils/event-extraction.ts @@ -0,0 +1,249 @@ +/** + * Event extraction utilities for Graph Simulator + * + * Wraps and extends the existing lib/event.ts utilities to provide + * Graph-compatible event extraction with extended metadata. + * + * Reference: lib/event.ts - findEventsWithInterfaces() + */ + +import { ContractTransactionReceipt, EventLog, Interface, Log, LogDescription } from "ethers"; + +import { ProtocolContext } from "lib/protocol"; + +/** + * Extended log description with additional metadata needed by the simulator + */ +export interface LogDescriptionWithMeta extends LogDescription { + /** Contract address that emitted the event */ + address: string; + + /** Log index within the transaction */ + logIndex: number; + + /** Block number */ + blockNumber: number; + + /** Block timestamp (if available) */ + blockTimestamp?: bigint; + + /** Transaction hash */ + transactionHash: string; + + /** Transaction index */ + transactionIndex: number; +} + +/** + * Parse a single log entry using provided interfaces + * + * @param entry - The log entry to parse + * @param interfaces - Array of contract interfaces to try + * @returns Parsed log description or null if parsing fails + */ +function parseLogEntry(entry: Log, interfaces: Interface[]): LogDescription | null { + // Try EventLog first (has built-in interface) + if (entry instanceof EventLog) { + try { + return entry.interface.parseLog(entry); + } catch { + // Fall through to try other interfaces + } + } + + // Try each interface + for (const iface of interfaces) { + try { + const parsed = iface.parseLog(entry); + if (parsed) { + return parsed; + } + } catch { + // Continue to next interface + } + } + + return null; +} + +/** + * Extract all parseable logs from a transaction receipt with extended metadata + * + * This function parses all logs in a transaction using the protocol's contract + * interfaces and adds metadata needed for event processing. + * + * @param receipt - Transaction receipt containing logs + * @param ctx - Protocol context with contract interfaces + * @returns Array of parsed logs with metadata, sorted by logIndex + */ +export function extractAllLogs(receipt: ContractTransactionReceipt, ctx: ProtocolContext): LogDescriptionWithMeta[] { + const results: LogDescriptionWithMeta[] = []; + + for (const log of receipt.logs) { + const parsed = parseLogEntry(log, ctx.interfaces); + + if (parsed) { + const extended: LogDescriptionWithMeta = Object.assign(Object.create(Object.getPrototypeOf(parsed)), parsed, { + address: log.address, + logIndex: log.index, + blockNumber: log.blockNumber, + transactionHash: log.transactionHash, + transactionIndex: log.transactionIndex, + }); + + results.push(extended); + } + } + + // Sort by logIndex to ensure correct processing order + return results.sort((a, b) => a.logIndex - b.logIndex); +} + +/** + * Find an event by name in the logs array + * + * @param logs - Array of parsed logs + * @param eventName - Name of the event to find + * @param afterLogIndex - Optional: only search logs after this index + * @returns First matching event or null + */ +export function findEventByName( + logs: LogDescriptionWithMeta[], + eventName: string, + afterLogIndex?: number, +): LogDescriptionWithMeta | null { + const startIndex = afterLogIndex ?? -1; + + for (const log of logs) { + if (log.logIndex > startIndex && log.name === eventName) { + return log; + } + } + + return null; +} + +/** + * Find all events by name in the logs array + * + * @param logs - Array of parsed logs + * @param eventName - Name of the event to find + * @param startLogIndex - Optional: only search logs at or after this index + * @param endLogIndex - Optional: only search logs before this index + * @returns Array of matching events + */ +export function findAllEventsByName( + logs: LogDescriptionWithMeta[], + eventName: string, + startLogIndex?: number, + endLogIndex?: number, +): LogDescriptionWithMeta[] { + const start = startLogIndex ?? 0; + const end = endLogIndex ?? Infinity; + + return logs.filter((log) => log.name === eventName && log.logIndex >= start && log.logIndex < end); +} + +/** + * Get event argument value with type safety + * + * Helper to extract typed values from event args. + * + * @param event - The parsed event + * @param argName - Name of the argument + * @returns The argument value + */ +export function getEventArg(event: LogDescriptionWithMeta, argName: string): T { + return event.args[argName] as T; +} + +/** + * Check if an event exists in the logs + * + * @param logs - Array of parsed logs + * @param eventName - Name of the event to check + * @returns true if event exists + */ +export function hasEvent(logs: LogDescriptionWithMeta[], eventName: string): boolean { + return logs.some((log) => log.name === eventName); +} + +/** + * Zero address constant for mint detection + */ +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +/** + * Represents a paired Transfer and TransferShares event + * + * These events are emitted together by Lido for share transfers. + * The Transfer event contains the ETH value, while TransferShares contains the share amount. + */ +export interface TransferPair { + transfer: { + from: string; + to: string; + value: bigint; + logIndex: number; + }; + transferShares: { + from: string; + to: string; + sharesValue: bigint; + logIndex: number; + }; +} + +/** + * Find paired Transfer/TransferShares events within a log index range + * + * This mirrors the real graph's extractPairedEvent() function from parser.ts. + * Transfer and TransferShares events are emitted consecutively by Lido, + * so we pair them by finding Transfer events followed by TransferShares events. + * + * @param logs - Array of parsed logs + * @param startLogIndex - Start of range (exclusive, typically ETHDistributed logIndex) + * @param endLogIndex - End of range (exclusive, typically TokenRebased logIndex) + * @returns Array of paired Transfer/TransferShares events + */ +export function findTransferSharesPairs( + logs: LogDescriptionWithMeta[], + startLogIndex: number, + endLogIndex: number, +): TransferPair[] { + const pairs: TransferPair[] = []; + + // Get all Transfer and TransferShares events in range + const transferEvents = logs.filter( + (log) => log.name === "Transfer" && log.logIndex > startLogIndex && log.logIndex < endLogIndex, + ); + const transferSharesEvents = logs.filter( + (log) => log.name === "TransferShares" && log.logIndex > startLogIndex && log.logIndex < endLogIndex, + ); + + // Pair Transfer events with their corresponding TransferShares events + // They are emitted consecutively, so TransferShares follows Transfer with logIndex + 1 + for (const transfer of transferEvents) { + // Find the TransferShares event that immediately follows this Transfer + const matchingTransferShares = transferSharesEvents.find((ts) => ts.logIndex === transfer.logIndex + 1); + + if (matchingTransferShares) { + pairs.push({ + transfer: { + from: getEventArg(transfer, "from"), + to: getEventArg(transfer, "to"), + value: getEventArg(transfer, "value"), + logIndex: transfer.logIndex, + }, + transferShares: { + from: getEventArg(matchingTransferShares, "from"), + to: getEventArg(matchingTransferShares, "to"), + sharesValue: getEventArg(matchingTransferShares, "sharesValue"), + logIndex: matchingTransferShares.logIndex, + }, + }); + } + } + + return pairs; +} diff --git a/test/graph/utils/index.ts b/test/graph/utils/index.ts new file mode 100644 index 0000000000..64a471b764 --- /dev/null +++ b/test/graph/utils/index.ts @@ -0,0 +1,6 @@ +/** + * Graph test utilities + */ + +export * from "./event-extraction"; +export * from "./state-capture"; diff --git a/test/graph/utils/state-capture.ts b/test/graph/utils/state-capture.ts new file mode 100644 index 0000000000..ba096250ee --- /dev/null +++ b/test/graph/utils/state-capture.ts @@ -0,0 +1,133 @@ +/** + * State capture utilities for Graph Simulator + * + * Provides functions to capture on-chain state before and after transactions + * for verification against simulator-computed values. + * + * Reference: graph-tests-spec.md - SimulatorInitialState interface + */ + +import { ProtocolContext } from "lib/protocol"; + +/** + * Initial state required to initialize the simulator + * + * This state is captured from the chain at test start and used + * to initialize the simulator's internal state. + */ +export interface SimulatorInitialState { + /** Total pooled ether in the protocol */ + totalPooledEther: bigint; + + /** Total shares in the protocol */ + totalShares: bigint; + + /** Treasury address for fee categorization */ + treasuryAddress: string; + + /** Staking module addresses from StakingRouter */ + stakingModuleAddresses: string[]; +} + +/** + * Pool state snapshot for before/after comparison + */ +export interface PoolState { + /** Total pooled ether */ + totalPooledEther: bigint; + + /** Total shares */ + totalShares: bigint; +} + +/** + * Capture the full chain state needed to initialize the simulator + * + * This should be called once at test suite start (for Scenario tests) + * or at the beginning of each test (for Integration tests). + * + * @param ctx - Protocol context with contracts + * @returns SimulatorInitialState with all required fields + */ +export async function captureChainState(ctx: ProtocolContext): Promise { + const { lido, locator, stakingRouter } = ctx.contracts; + + // Get pool state + const [totalPooledEther, totalShares] = await Promise.all([lido.getTotalPooledEther(), lido.getTotalShares()]); + + // Get treasury address from locator + const treasuryAddress = await locator.treasury(); + + // Get staking module addresses + const stakingModuleAddresses: string[] = []; + const modules = await stakingRouter.getStakingModules(); + + for (const module of modules) { + stakingModuleAddresses.push(module.stakingModuleAddress); + } + + return { + totalPooledEther, + totalShares, + treasuryAddress, + stakingModuleAddresses, + }; +} + +/** + * Capture just the pool state (lighter weight than full state) + * + * Use this for before/after snapshots around transactions. + * + * @param ctx - Protocol context with contracts + * @returns PoolState with totalPooledEther and totalShares + */ +export async function capturePoolState(ctx: ProtocolContext): Promise { + const { lido } = ctx.contracts; + + const [totalPooledEther, totalShares] = await Promise.all([lido.getTotalPooledEther(), lido.getTotalShares()]); + + return { + totalPooledEther, + totalShares, + }; +} + +/** + * Capture treasury balance (shares) + * + * @param ctx - Protocol context with contracts + * @returns Treasury shares balance + */ +export async function captureTreasuryShares(ctx: ProtocolContext): Promise { + const { lido, locator } = ctx.contracts; + + const treasuryAddress = await locator.treasury(); + return lido.sharesOf(treasuryAddress); +} + +/** + * Capture staking module balances + * + * @param ctx - Protocol context with contracts + * @returns Map of module address to shares balance + */ +export async function captureModuleBalances(ctx: ProtocolContext): Promise> { + const { lido, stakingRouter } = ctx.contracts; + + const balances = new Map(); + const modules = await stakingRouter.getStakingModules(); + + const balancePromises = modules.map(async (module) => { + const shares = await lido.sharesOf(module.stakingModuleAddress); + return { address: module.stakingModuleAddress, shares }; + }); + + const results = await Promise.all(balancePromises); + + for (const result of results) { + balances.set(result.address.toLowerCase(), result.shares); + } + + return balances; +} From 62b5548c52caf2c83639a3662eb5ee76489f4c82 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Sun, 14 Dec 2025 19:04:36 +0000 Subject: [PATCH 02/10] fix: integration tests --- ...-delay.ts => report-validator-exit-delay.integration.ts} | 0 test/{graph => integration}/total-reward.integration.ts | 6 +++--- ...dators-exit-bus-submit-and-trigger-exits.integration.ts} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename test/integration/{report-validator-exit-delay.ts => report-validator-exit-delay.integration.ts} (100%) rename test/{graph => integration}/total-reward.integration.ts (99%) rename test/integration/{validators-exit-bus-submit-and-trigger-exits.ts => validators-exit-bus-submit-and-trigger-exits.integration.ts} (100%) diff --git a/test/integration/report-validator-exit-delay.ts b/test/integration/report-validator-exit-delay.integration.ts similarity index 100% rename from test/integration/report-validator-exit-delay.ts rename to test/integration/report-validator-exit-delay.integration.ts diff --git a/test/graph/total-reward.integration.ts b/test/integration/total-reward.integration.ts similarity index 99% rename from test/graph/total-reward.integration.ts rename to test/integration/total-reward.integration.ts index 57bf2ce3e3..d9133665a7 100644 --- a/test/graph/total-reward.integration.ts +++ b/test/integration/total-reward.integration.ts @@ -18,9 +18,9 @@ import { import { bailOnFailure, Snapshot } from "test/suite"; -import { createEntityStore, deriveExpectedTotalReward, GraphSimulator, processTransaction } from "./simulator"; -import { captureChainState, capturePoolState, SimulatorInitialState } from "./utils"; -import { extractAllLogs } from "./utils/event-extraction"; +import { createEntityStore, deriveExpectedTotalReward, GraphSimulator, processTransaction } from "../graph/simulator"; +import { captureChainState, capturePoolState, SimulatorInitialState } from "../graph/utils"; +import { extractAllLogs } from "../graph/utils/event-extraction"; /** * Graph TotalReward Entity Integration Tests diff --git a/test/integration/validators-exit-bus-submit-and-trigger-exits.ts b/test/integration/validators-exit-bus-submit-and-trigger-exits.integration.ts similarity index 100% rename from test/integration/validators-exit-bus-submit-and-trigger-exits.ts rename to test/integration/validators-exit-bus-submit-and-trigger-exits.integration.ts From 63686036961cac3fac0214286719b12b038fa320 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Sun, 14 Dec 2025 19:08:14 +0000 Subject: [PATCH 03/10] chore: better output --- .../{ => core}/total-reward.integration.ts | 118 ++++++++---------- 1 file changed, 53 insertions(+), 65 deletions(-) rename test/integration/{ => core}/total-reward.integration.ts (89%) diff --git a/test/integration/total-reward.integration.ts b/test/integration/core/total-reward.integration.ts similarity index 89% rename from test/integration/total-reward.integration.ts rename to test/integration/core/total-reward.integration.ts index d9133665a7..1c7bd43d51 100644 --- a/test/integration/total-reward.integration.ts +++ b/test/integration/core/total-reward.integration.ts @@ -18,9 +18,14 @@ import { import { bailOnFailure, Snapshot } from "test/suite"; -import { createEntityStore, deriveExpectedTotalReward, GraphSimulator, processTransaction } from "../graph/simulator"; -import { captureChainState, capturePoolState, SimulatorInitialState } from "../graph/utils"; -import { extractAllLogs } from "../graph/utils/event-extraction"; +import { + createEntityStore, + deriveExpectedTotalReward, + GraphSimulator, + processTransaction, +} from "../../graph/simulator"; +import { captureChainState, capturePoolState, SimulatorInitialState } from "../../graph/utils"; +import { extractAllLogs } from "../../graph/utils/event-extraction"; /** * Graph TotalReward Entity Integration Tests @@ -63,7 +68,7 @@ describe("Scenario: Graph TotalReward Validation", () => { // Initialize simulator with treasury address simulator = new GraphSimulator(initialState.treasuryAddress); - log.info("Graph Simulator initialized", { + log.debug("Graph Simulator initialized", { "Total Pooled Ether": formatEther(initialState.totalPooledEther), "Total Shares": initialState.totalShares.toString(), "Treasury Address": initialState.treasuryAddress, @@ -103,7 +108,7 @@ describe("Scenario: Graph TotalReward Validation", () => { it("Should deposit ETH and stake to modules", async () => { const { lido, stakingRouter, depositSecurityModule } = ctx.contracts; - log.info("Submitting ETH for deposits", { + log.debug("Submitting ETH for deposits", { Amount: formatEther(ether("3200")), }); @@ -129,7 +134,7 @@ describe("Scenario: Graph TotalReward Validation", () => { depositCount += deposits; } - log.info("Deposits completed", { + log.debug("Deposits completed", { "Total Deposits": depositCount.toString(), "ETH Staked": formatEther(depositCount * ether("32")), }); @@ -138,12 +143,10 @@ describe("Scenario: Graph TotalReward Validation", () => { }); it("Should compute TotalReward correctly for first oracle report", async () => { - log("=== First Oracle Report: TotalReward Validation ==="); - // 1. Capture state before oracle report const stateBefore = await capturePoolState(ctx); - log.info("Pool state before report", { + log.debug("Pool state before report", { "Total Pooled Ether": formatEther(stateBefore.totalPooledEther), "Total Shares": stateBefore.totalShares.toString(), }); @@ -155,7 +158,7 @@ describe("Scenario: Graph TotalReward Validation", () => { clAppearedValidators: depositCount, }; - log.info("Executing oracle report", { + log.debug("Executing oracle report", { "CL Diff": formatEther(clDiff), "Appeared Validators": depositCount.toString(), }); @@ -168,7 +171,7 @@ describe("Scenario: Graph TotalReward Validation", () => { const block = await ethers.provider.getBlock(receipt.blockNumber); const blockTimestamp = BigInt(block!.timestamp); - log.info("Oracle report transaction", { + log.debug("Oracle report transaction", { "Tx Hash": receipt.hash, "Block Number": receipt.blockNumber, "Block Timestamp": blockTimestamp.toString(), @@ -179,7 +182,7 @@ describe("Scenario: Graph TotalReward Validation", () => { const store = createEntityStore(); const result = processTransaction(receipt, ctx, store, blockTimestamp, initialState.treasuryAddress); - log.info("Simulator processing result", { + log.debug("Simulator processing result", { "Events Processed": result.eventsProcessed, "Had Profitable Report": result.hadProfitableReport, "TotalReward Entities Created": result.totalRewards.size, @@ -188,7 +191,7 @@ describe("Scenario: Graph TotalReward Validation", () => { // 4. Capture state after const stateAfter = await capturePoolState(ctx); - log.info("Pool state after report", { + log.debug("Pool state after report", { "Total Pooled Ether": formatEther(stateAfter.totalPooledEther), "Total Shares": stateAfter.totalShares.toString(), "Ether Change": formatEther(stateAfter.totalPooledEther - stateBefore.totalPooledEther), @@ -207,7 +210,7 @@ describe("Scenario: Graph TotalReward Validation", () => { expect(expected).to.not.be.null; // Log entity details - log.info("TotalReward Entity - Tier 1 (Metadata)", { + log.debug("TotalReward Entity - Tier 1 (Metadata)", { "ID": computed!.id, "Block": computed!.block.toString(), "Block Time": computed!.blockTime.toString(), @@ -215,7 +218,7 @@ describe("Scenario: Graph TotalReward Validation", () => { "Log Index": computed!.logIndex.toString(), }); - log.info("TotalReward Entity - Tier 2 (Pool State)", { + log.debug("TotalReward Entity - Tier 2 (Pool State)", { "Total Pooled Ether Before": formatEther(computed!.totalPooledEtherBefore), "Total Pooled Ether After": formatEther(computed!.totalPooledEtherAfter), "Total Shares Before": computed!.totalSharesBefore.toString(), @@ -225,7 +228,7 @@ describe("Scenario: Graph TotalReward Validation", () => { "MEV Fee": formatEther(computed!.mevFee), }); - log.info("TotalReward Entity - Tier 2 (Fee Distribution)", { + log.debug("TotalReward Entity - Tier 2 (Fee Distribution)", { "Total Rewards With Fees": formatEther(computed!.totalRewardsWithFees), "Total Rewards": formatEther(computed!.totalRewards), "Total Fee": formatEther(computed!.totalFee), @@ -235,7 +238,7 @@ describe("Scenario: Graph TotalReward Validation", () => { "Shares To Operators": computed!.sharesToOperators.toString(), }); - log.info("TotalReward Entity - Tier 3 (Calculated)", { + log.debug("TotalReward Entity - Tier 3 (Calculated)", { "APR": `${computed!.apr.toFixed(4)}%`, "APR Raw": `${computed!.aprRaw.toFixed(4)}%`, "APR Before Fees": `${computed!.aprBeforeFees.toFixed(4)}%`, @@ -245,7 +248,7 @@ describe("Scenario: Graph TotalReward Validation", () => { }); // 7. Verify Tier 1 fields (Direct Event Metadata) - log.info("Verifying Tier 1 fields (Direct Event Metadata)..."); + log.debug("Verifying Tier 1 fields (Direct Event Metadata)..."); expect(computed!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "id mismatch"); expect(computed!.block).to.equal(BigInt(receipt.blockNumber), "block mismatch"); expect(computed!.blockTime).to.equal(blockTimestamp, "blockTime mismatch"); @@ -254,7 +257,7 @@ describe("Scenario: Graph TotalReward Validation", () => { expect(computed!.logIndex).to.equal(expected!.logIndex, "logIndex mismatch"); // 8. Verify Tier 2 fields (Pool State from TokenRebased) - log.info("Verifying Tier 2 fields (Pool State from TokenRebased)..."); + log.debug("Verifying Tier 2 fields (Pool State from TokenRebased)..."); expect(computed!.totalPooledEtherBefore).to.equal( expected!.totalPooledEtherBefore, "totalPooledEtherBefore mismatch", @@ -267,7 +270,7 @@ describe("Scenario: Graph TotalReward Validation", () => { expect(computed!.mevFee).to.equal(expected!.mevFee, "mevFee mismatch"); // 8b. Verify Tier 2 fields (Fee Distribution) - log.info("Verifying Tier 2 fields (Fee Distribution)..."); + log.debug("Verifying Tier 2 fields (Fee Distribution)..."); expect(computed!.totalRewardsWithFees).to.equal(expected!.totalRewardsWithFees, "totalRewardsWithFees mismatch"); expect(computed!.totalRewards).to.equal(expected!.totalRewards, "totalRewards mismatch"); expect(computed!.totalFee).to.equal(expected!.totalFee, "totalFee mismatch"); @@ -277,7 +280,7 @@ describe("Scenario: Graph TotalReward Validation", () => { expect(computed!.sharesToOperators).to.equal(expected!.sharesToOperators, "sharesToOperators mismatch"); // 8c. Verify Tier 3 fields (Calculated) - log.info("Verifying Tier 3 fields (APR and Basis Points)..."); + log.debug("Verifying Tier 3 fields (APR and Basis Points)..."); expect(computed!.apr).to.equal(expected!.apr, "apr mismatch"); expect(computed!.aprRaw).to.equal(expected!.aprRaw, "aprRaw mismatch"); expect(computed!.aprBeforeFees).to.equal(expected!.aprBeforeFees, "aprBeforeFees mismatch"); @@ -292,7 +295,7 @@ describe("Scenario: Graph TotalReward Validation", () => { ); // 8d. Verify fee consistency (shares2mint should equal sharesToTreasury + sharesToOperators) - log.info("Verifying fee consistency..."); + log.debug("Verifying fee consistency..."); expect(computed!.shares2mint).to.equal( computed!.sharesToTreasury + computed!.sharesToOperators, "shares2mint should equal sharesToTreasury + sharesToOperators", @@ -303,7 +306,7 @@ describe("Scenario: Graph TotalReward Validation", () => { ); // 9. Verify consistency with on-chain state - log.info("Verifying consistency with on-chain state..."); + log.debug("Verifying consistency with on-chain state..."); // TokenRebased.preTotalEther should match state before report expect(computed!.totalPooledEtherBefore).to.equal(stateBefore.totalPooledEther, "preTotalEther vs stateBefore"); expect(computed!.totalSharesBefore).to.equal(stateBefore.totalShares, "preTotalShares vs stateBefore"); @@ -311,17 +314,13 @@ describe("Scenario: Graph TotalReward Validation", () => { // TokenRebased.postTotalEther should match state after report expect(computed!.totalPooledEtherAfter).to.equal(stateAfter.totalPooledEther, "postTotalEther vs stateAfter"); expect(computed!.totalSharesAfter).to.equal(stateAfter.totalShares, "postTotalShares vs stateAfter"); - - log("First oracle report validation PASSED"); }); it("Should compute TotalReward correctly for second oracle report", async () => { - log("=== Second Oracle Report: TotalReward Validation ==="); - // 1. Capture state before second oracle report const stateBefore = await capturePoolState(ctx); - log.info("Pool state before second report", { + log.debug("Pool state before second report", { "Total Pooled Ether": formatEther(stateBefore.totalPooledEther), "Total Shares": stateBefore.totalShares.toString(), }); @@ -332,7 +331,7 @@ describe("Scenario: Graph TotalReward Validation", () => { clDiff, // Smaller reward }; - log.info("Executing second oracle report", { + log.debug("Executing second oracle report", { "CL Diff": formatEther(clDiff), }); @@ -344,7 +343,7 @@ describe("Scenario: Graph TotalReward Validation", () => { const block = await ethers.provider.getBlock(receipt.blockNumber); const blockTimestamp = BigInt(block!.timestamp); - log.info("Second oracle report transaction", { + log.debug("Second oracle report transaction", { "Tx Hash": receipt.hash, "Block Number": receipt.blockNumber, "Block Timestamp": blockTimestamp.toString(), @@ -354,7 +353,7 @@ describe("Scenario: Graph TotalReward Validation", () => { // 3. Process events through simulator (using same simulator instance) const result = simulator.processTransaction(receipt, ctx, blockTimestamp); - log.info("Simulator processing result (using persistent simulator)", { + log.debug("Simulator processing result (using persistent simulator)", { "Events Processed": result.eventsProcessed, "Had Profitable Report": result.hadProfitableReport, "TotalReward Entities Created": result.totalRewards.size, @@ -364,7 +363,7 @@ describe("Scenario: Graph TotalReward Validation", () => { // 4. Capture state after const stateAfter = await capturePoolState(ctx); - log.info("Pool state after second report", { + log.debug("Pool state after second report", { "Total Pooled Ether": formatEther(stateAfter.totalPooledEther), "Total Shares": stateAfter.totalShares.toString(), "Ether Change": formatEther(stateAfter.totalPooledEther - stateBefore.totalPooledEther), @@ -383,7 +382,7 @@ describe("Scenario: Graph TotalReward Validation", () => { expect(expected).to.not.be.null; // Log entity details - log.info("TotalReward Entity - Tier 1 (Metadata)", { + log.debug("TotalReward Entity - Tier 1 (Metadata)", { "ID": computed!.id, "Block": computed!.block.toString(), "Block Time": computed!.blockTime.toString(), @@ -391,7 +390,7 @@ describe("Scenario: Graph TotalReward Validation", () => { "Log Index": computed!.logIndex.toString(), }); - log.info("TotalReward Entity - Tier 2 (Pool State)", { + log.debug("TotalReward Entity - Tier 2 (Pool State)", { "Total Pooled Ether Before": formatEther(computed!.totalPooledEtherBefore), "Total Pooled Ether After": formatEther(computed!.totalPooledEtherAfter), "Total Shares Before": computed!.totalSharesBefore.toString(), @@ -401,7 +400,7 @@ describe("Scenario: Graph TotalReward Validation", () => { "MEV Fee": formatEther(computed!.mevFee), }); - log.info("TotalReward Entity - Tier 2 (Fee Distribution)", { + log.debug("TotalReward Entity - Tier 2 (Fee Distribution)", { "Total Rewards With Fees": formatEther(computed!.totalRewardsWithFees), "Total Rewards": formatEther(computed!.totalRewards), "Total Fee": formatEther(computed!.totalFee), @@ -411,7 +410,7 @@ describe("Scenario: Graph TotalReward Validation", () => { "Shares To Operators": computed!.sharesToOperators.toString(), }); - log.info("TotalReward Entity - Tier 3 (Calculated)", { + log.debug("TotalReward Entity - Tier 3 (Calculated)", { "APR": `${computed!.apr.toFixed(4)}%`, "APR Raw": `${computed!.aprRaw.toFixed(4)}%`, "APR Before Fees": `${computed!.aprBeforeFees.toFixed(4)}%`, @@ -421,7 +420,7 @@ describe("Scenario: Graph TotalReward Validation", () => { }); // 7. Verify Tier 1 fields - log.info("Verifying Tier 1 fields..."); + log.debug("Verifying Tier 1 fields..."); expect(computed!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "id mismatch"); expect(computed!.block).to.equal(BigInt(receipt.blockNumber), "block mismatch"); expect(computed!.blockTime).to.equal(blockTimestamp, "blockTime mismatch"); @@ -430,7 +429,7 @@ describe("Scenario: Graph TotalReward Validation", () => { expect(computed!.logIndex).to.equal(expected!.logIndex, "logIndex mismatch"); // 8. Verify Tier 2 fields - log.info("Verifying Tier 2 fields..."); + log.debug("Verifying Tier 2 fields..."); expect(computed!.totalPooledEtherBefore).to.equal( expected!.totalPooledEtherBefore, "totalPooledEtherBefore mismatch", @@ -443,7 +442,7 @@ describe("Scenario: Graph TotalReward Validation", () => { expect(computed!.mevFee).to.equal(expected!.mevFee, "mevFee mismatch"); // 8b. Verify Tier 2 fields (Fee Distribution) - log.info("Verifying Tier 2 fields (Fee Distribution)..."); + log.debug("Verifying Tier 2 fields (Fee Distribution)..."); expect(computed!.totalRewardsWithFees).to.equal(expected!.totalRewardsWithFees, "totalRewardsWithFees mismatch"); expect(computed!.totalRewards).to.equal(expected!.totalRewards, "totalRewards mismatch"); expect(computed!.totalFee).to.equal(expected!.totalFee, "totalFee mismatch"); @@ -453,7 +452,7 @@ describe("Scenario: Graph TotalReward Validation", () => { expect(computed!.sharesToOperators).to.equal(expected!.sharesToOperators, "sharesToOperators mismatch"); // 8c. Verify Tier 3 fields (APR and Basis Points) - log.info("Verifying Tier 3 fields (APR and Basis Points)..."); + log.debug("Verifying Tier 3 fields (APR and Basis Points)..."); expect(computed!.apr).to.equal(expected!.apr, "apr mismatch"); expect(computed!.aprRaw).to.equal(expected!.aprRaw, "aprRaw mismatch"); expect(computed!.aprBeforeFees).to.equal(expected!.aprBeforeFees, "aprBeforeFees mismatch"); @@ -468,7 +467,7 @@ describe("Scenario: Graph TotalReward Validation", () => { ); // 8d. Verify fee consistency - log.info("Verifying fee consistency..."); + log.debug("Verifying fee consistency..."); expect(computed!.shares2mint).to.equal( computed!.sharesToTreasury + computed!.sharesToOperators, "shares2mint should equal sharesToTreasury + sharesToOperators", @@ -479,32 +478,27 @@ describe("Scenario: Graph TotalReward Validation", () => { ); // 9. Verify state consistency - log.info("Verifying on-chain state consistency..."); + log.debug("Verifying on-chain state consistency..."); expect(computed!.totalPooledEtherBefore).to.equal(stateBefore.totalPooledEther, "preTotalEther vs stateBefore"); expect(computed!.totalSharesBefore).to.equal(stateBefore.totalShares, "preTotalShares vs stateBefore"); expect(computed!.totalPooledEtherAfter).to.equal(stateAfter.totalPooledEther, "postTotalEther vs stateAfter"); expect(computed!.totalSharesAfter).to.equal(stateAfter.totalShares, "postTotalShares vs stateAfter"); // 10. Verify simulator state persistence (should have both reports) - log.info("Verifying simulator state persistence..."); + log.debug("Verifying simulator state persistence..."); const storedReport = simulator.getTotalReward(receipt.hash); expect(storedReport).to.not.be.undefined; - - log("Second oracle report validation PASSED"); }); it("Should verify event processing order", async () => { // This test validates that events are processed in the correct order // by examining the logs from the last oracle report - - log("=== Event Processing Order Verification ==="); - const clDiff = ether("0.002"); const reportData: Partial = { clDiff, }; - log.info("Executing oracle report for event order test", { + log.debug("Executing oracle report for event order test", { "CL Diff": formatEther(clDiff), }); @@ -515,14 +509,14 @@ describe("Scenario: Graph TotalReward Validation", () => { // Extract and examine logs const logs = extractAllLogs(receipt, ctx); - log.info("Extracted logs from transaction", { + log.debug("Extracted logs from transaction", { "Total Logs": logs.length, "Tx Hash": receipt.hash, }); // Log all event names in order const eventSummary = logs.map((l) => `${l.logIndex}: ${l.name}`).join(", "); - log.info("Event order", { + log.debug("Event order", { Events: eventSummary, }); @@ -531,7 +525,7 @@ describe("Scenario: Graph TotalReward Validation", () => { const tokenRebasedIdx = logs.findIndex((l) => l.name === "TokenRebased"); const processingStartedIdx = logs.findIndex((l) => l.name === "ProcessingStarted"); - log.info("Key event positions", { + log.debug("Key event positions", { "ProcessingStarted Index": processingStartedIdx, "ETHDistributed Index": ethDistributedIdx, "TokenRebased Index": tokenRebasedIdx, @@ -550,20 +544,16 @@ describe("Scenario: Graph TotalReward Validation", () => { (l) => l.name === "TransferShares" && l.logIndex > ethDistributedIdx && l.logIndex < tokenRebasedIdx, ); - log.info("Fee distribution events between ETHDistributed and TokenRebased", { + log.debug("Fee distribution events between ETHDistributed and TokenRebased", { "Transfer Events": transferEvents.length, "TransferShares Events": transferSharesEvents.length, }); // There should be at least some transfer events for fee distribution expect(transferEvents.length).to.be.greaterThanOrEqual(0, "Expected Transfer events for fee distribution"); - - log("Event processing order verification PASSED"); }); it("Should query TotalRewards with filtering and pagination", async () => { - log("=== Query Functionality Test ==="); - // Execute another oracle report to have more data const clDiff = ether("0.003"); const reportData: Partial = { clDiff }; @@ -580,7 +570,7 @@ describe("Scenario: Graph TotalReward Validation", () => { // Test 1: Count all TotalRewards const totalCount = simulator.countTotalRewards(0n); - log.info("Query: Count all TotalRewards", { + log.debug("Query: Count all TotalRewards", { "Total Count": totalCount, }); expect(totalCount).to.be.greaterThanOrEqual(2, "Should have at least 2 TotalReward entities"); @@ -594,7 +584,7 @@ describe("Scenario: Graph TotalReward Validation", () => { orderDirection: "asc", }); - log.info("Query: TotalRewards (skip=0, limit=10, orderBy=blockTime asc)", { + log.debug("Query: TotalRewards (skip=0, limit=10, orderBy=blockTime asc)", { "Results Count": queryResult.length, "First Block Time": queryResult[0]?.blockTime.toString(), "Last Block Time": queryResult[queryResult.length - 1]?.blockTime.toString(), @@ -612,7 +602,7 @@ describe("Scenario: Graph TotalReward Validation", () => { // Test 3: Query result contains expected fields const firstResult = queryResult[0]; - log.info("Query result fields check", { + log.debug("Query result fields check", { "Has id": firstResult.id !== undefined, "Has totalPooledEtherBefore": firstResult.totalPooledEtherBefore !== undefined, "Has totalPooledEtherAfter": firstResult.totalPooledEtherAfter !== undefined, @@ -638,7 +628,7 @@ describe("Scenario: Graph TotalReward Validation", () => { orderDirection: "asc", }); - log.info("Query: TotalRewards with block filter", { + log.debug("Query: TotalRewards with block filter", { "Filter blockFrom": firstBlock.toString(), "Filtered Results Count": filteredResult.length, }); @@ -650,7 +640,7 @@ describe("Scenario: Graph TotalReward Validation", () => { // Test 5: Get latest TotalReward const latest = simulator.getLatestTotalReward(); - log.info("Query: Latest TotalReward", { + log.debug("Query: Latest TotalReward", { "Latest ID": latest?.id ?? "N/A", "Latest Block": latest?.block.toString() ?? "N/A", "Latest APR": latest ? `${latest.apr.toFixed(4)}%` : "N/A", @@ -661,14 +651,12 @@ describe("Scenario: Graph TotalReward Validation", () => { // Test 6: Get by ID const byId = simulator.getTotalRewardById(receipt.hash); - log.info("Query: Get by ID", { + log.debug("Query: Get by ID", { "Requested ID": receipt.hash, "Found": byId !== null, }); expect(byId).to.not.be.null; expect(byId!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase()); - - log("Query functionality test PASSED"); }); }); From a2426bd9a606a77e6ef631f18f6a50f3c8a2c8bb Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Sun, 14 Dec 2025 19:22:49 +0000 Subject: [PATCH 04/10] chore: cleanup --- .../core/total-reward.integration.ts | 487 ++++-------------- 1 file changed, 90 insertions(+), 397 deletions(-) diff --git a/test/integration/core/total-reward.integration.ts b/test/integration/core/total-reward.integration.ts index 1c7bd43d51..4a86ee36fc 100644 --- a/test/integration/core/total-reward.integration.ts +++ b/test/integration/core/total-reward.integration.ts @@ -4,7 +4,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { advanceChainTime, ether, log, updateBalance } from "lib"; +import { advanceChainTime, ether, impersonate, log, mEqual, updateBalance } from "lib"; import { finalizeWQViaElVault, getProtocolContext, @@ -27,6 +27,8 @@ import { import { captureChainState, capturePoolState, SimulatorInitialState } from "../../graph/utils"; import { extractAllLogs } from "../../graph/utils/event-extraction"; +const INTERVAL_12_HOURS = 12n * 60n * 60n; + /** * Graph TotalReward Entity Integration Tests * @@ -108,16 +110,10 @@ describe("Scenario: Graph TotalReward Validation", () => { it("Should deposit ETH and stake to modules", async () => { const { lido, stakingRouter, depositSecurityModule } = ctx.contracts; - log.debug("Submitting ETH for deposits", { - Amount: formatEther(ether("3200")), - }); - // Submit more ETH for deposits await lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("3200") }); - const { impersonate, ether: etherFn } = await import("lib"); - - const dsmSigner = await impersonate(depositSecurityModule.address, etherFn("100")); + const dsmSigner = await impersonate(depositSecurityModule.address, ether("100")); const stakingModules = (await stakingRouter.getStakingModules()).filter((m) => m.id === 1n); depositCount = 0n; @@ -134,409 +130,160 @@ describe("Scenario: Graph TotalReward Validation", () => { depositCount += deposits; } - log.debug("Deposits completed", { - "Total Deposits": depositCount.toString(), - "ETH Staked": formatEther(depositCount * ether("32")), - }); - expect(depositCount).to.be.gt(0n, "No deposits applied"); }); it("Should compute TotalReward correctly for first oracle report", async () => { - // 1. Capture state before oracle report const stateBefore = await capturePoolState(ctx); - log.debug("Pool state before report", { - "Total Pooled Ether": formatEther(stateBefore.totalPooledEther), - "Total Shares": stateBefore.totalShares.toString(), - }); - - // 2. Execute oracle report with rewards const clDiff = ether("32") * depositCount + ether("0.001"); const reportData: Partial = { clDiff, clAppearedValidators: depositCount, }; - log.debug("Executing oracle report", { - "CL Diff": formatEther(clDiff), - "Appeared Validators": depositCount.toString(), - }); + await advanceChainTime(INTERVAL_12_HOURS); - await advanceChainTime(12n * 60n * 60n); // 12 hours const { reportTx } = await report(ctx, reportData); const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - // Get block timestamp const block = await ethers.provider.getBlock(receipt.blockNumber); const blockTimestamp = BigInt(block!.timestamp); - log.debug("Oracle report transaction", { - "Tx Hash": receipt.hash, - "Block Number": receipt.blockNumber, - "Block Timestamp": blockTimestamp.toString(), - "Log Count": receipt.logs.length, - }); - - // 3. Process events through simulator const store = createEntityStore(); const result = processTransaction(receipt, ctx, store, blockTimestamp, initialState.treasuryAddress); - log.debug("Simulator processing result", { - "Events Processed": result.eventsProcessed, - "Had Profitable Report": result.hadProfitableReport, - "TotalReward Entities Created": result.totalRewards.size, - }); - - // 4. Capture state after const stateAfter = await capturePoolState(ctx); - log.debug("Pool state after report", { - "Total Pooled Ether": formatEther(stateAfter.totalPooledEther), - "Total Shares": stateAfter.totalShares.toString(), - "Ether Change": formatEther(stateAfter.totalPooledEther - stateBefore.totalPooledEther), - "Shares Change": (stateAfter.totalShares - stateBefore.totalShares).toString(), - }); - - // 5. Verify a TotalReward entity was created expect(result.hadProfitableReport).to.be.true; expect(result.totalRewards.size).to.equal(1); const computed = result.totalRewards.get(receipt.hash); expect(computed).to.not.be.undefined; - // 6. Derive expected values directly from events const expected = deriveExpectedTotalReward(receipt, ctx, initialState.treasuryAddress); expect(expected).to.not.be.null; - // Log entity details - log.debug("TotalReward Entity - Tier 1 (Metadata)", { - "ID": computed!.id, - "Block": computed!.block.toString(), - "Block Time": computed!.blockTime.toString(), - "Tx Index": computed!.transactionIndex.toString(), - "Log Index": computed!.logIndex.toString(), - }); - - log.debug("TotalReward Entity - Tier 2 (Pool State)", { - "Total Pooled Ether Before": formatEther(computed!.totalPooledEtherBefore), - "Total Pooled Ether After": formatEther(computed!.totalPooledEtherAfter), - "Total Shares Before": computed!.totalSharesBefore.toString(), - "Total Shares After": computed!.totalSharesAfter.toString(), - "Shares Minted As Fees": computed!.shares2mint.toString(), - "Time Elapsed": computed!.timeElapsed.toString(), - "MEV Fee": formatEther(computed!.mevFee), - }); - - log.debug("TotalReward Entity - Tier 2 (Fee Distribution)", { - "Total Rewards With Fees": formatEther(computed!.totalRewardsWithFees), - "Total Rewards": formatEther(computed!.totalRewards), - "Total Fee": formatEther(computed!.totalFee), - "Treasury Fee": formatEther(computed!.treasuryFee), - "Operators Fee": formatEther(computed!.operatorsFee), - "Shares To Treasury": computed!.sharesToTreasury.toString(), - "Shares To Operators": computed!.sharesToOperators.toString(), - }); - - log.debug("TotalReward Entity - Tier 3 (Calculated)", { - "APR": `${computed!.apr.toFixed(4)}%`, - "APR Raw": `${computed!.aprRaw.toFixed(4)}%`, - "APR Before Fees": `${computed!.aprBeforeFees.toFixed(4)}%`, - "Fee Basis": computed!.feeBasis.toString(), - "Treasury Fee Basis Points": computed!.treasuryFeeBasisPoints.toString(), - "Operators Fee Basis Points": computed!.operatorsFeeBasisPoints.toString(), - }); - - // 7. Verify Tier 1 fields (Direct Event Metadata) - log.debug("Verifying Tier 1 fields (Direct Event Metadata)..."); - expect(computed!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "id mismatch"); - expect(computed!.block).to.equal(BigInt(receipt.blockNumber), "block mismatch"); - expect(computed!.blockTime).to.equal(blockTimestamp, "blockTime mismatch"); - expect(computed!.transactionHash.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "transactionHash mismatch"); - expect(computed!.transactionIndex).to.equal(BigInt(receipt.index), "transactionIndex mismatch"); - expect(computed!.logIndex).to.equal(expected!.logIndex, "logIndex mismatch"); - - // 8. Verify Tier 2 fields (Pool State from TokenRebased) - log.debug("Verifying Tier 2 fields (Pool State from TokenRebased)..."); - expect(computed!.totalPooledEtherBefore).to.equal( - expected!.totalPooledEtherBefore, - "totalPooledEtherBefore mismatch", - ); - expect(computed!.totalPooledEtherAfter).to.equal(expected!.totalPooledEtherAfter, "totalPooledEtherAfter mismatch"); - expect(computed!.totalSharesBefore).to.equal(expected!.totalSharesBefore, "totalSharesBefore mismatch"); - expect(computed!.totalSharesAfter).to.equal(expected!.totalSharesAfter, "totalSharesAfter mismatch"); - expect(computed!.shares2mint).to.equal(expected!.shares2mint, "shares2mint mismatch"); - expect(computed!.timeElapsed).to.equal(expected!.timeElapsed, "timeElapsed mismatch"); - expect(computed!.mevFee).to.equal(expected!.mevFee, "mevFee mismatch"); - - // 8b. Verify Tier 2 fields (Fee Distribution) - log.debug("Verifying Tier 2 fields (Fee Distribution)..."); - expect(computed!.totalRewardsWithFees).to.equal(expected!.totalRewardsWithFees, "totalRewardsWithFees mismatch"); - expect(computed!.totalRewards).to.equal(expected!.totalRewards, "totalRewards mismatch"); - expect(computed!.totalFee).to.equal(expected!.totalFee, "totalFee mismatch"); - expect(computed!.treasuryFee).to.equal(expected!.treasuryFee, "treasuryFee mismatch"); - expect(computed!.operatorsFee).to.equal(expected!.operatorsFee, "operatorsFee mismatch"); - expect(computed!.sharesToTreasury).to.equal(expected!.sharesToTreasury, "sharesToTreasury mismatch"); - expect(computed!.sharesToOperators).to.equal(expected!.sharesToOperators, "sharesToOperators mismatch"); - - // 8c. Verify Tier 3 fields (Calculated) - log.debug("Verifying Tier 3 fields (APR and Basis Points)..."); - expect(computed!.apr).to.equal(expected!.apr, "apr mismatch"); - expect(computed!.aprRaw).to.equal(expected!.aprRaw, "aprRaw mismatch"); - expect(computed!.aprBeforeFees).to.equal(expected!.aprBeforeFees, "aprBeforeFees mismatch"); - expect(computed!.feeBasis).to.equal(expected!.feeBasis, "feeBasis mismatch"); - expect(computed!.treasuryFeeBasisPoints).to.equal( - expected!.treasuryFeeBasisPoints, - "treasuryFeeBasisPoints mismatch", - ); - expect(computed!.operatorsFeeBasisPoints).to.equal( - expected!.operatorsFeeBasisPoints, - "operatorsFeeBasisPoints mismatch", - ); - - // 8d. Verify fee consistency (shares2mint should equal sharesToTreasury + sharesToOperators) - log.debug("Verifying fee consistency..."); - expect(computed!.shares2mint).to.equal( - computed!.sharesToTreasury + computed!.sharesToOperators, - "shares2mint should equal sharesToTreasury + sharesToOperators", - ); - expect(computed!.totalFee).to.equal( - computed!.treasuryFee + computed!.operatorsFee, - "totalFee should equal treasuryFee + operatorsFee", - ); - - // 9. Verify consistency with on-chain state - log.debug("Verifying consistency with on-chain state..."); - // TokenRebased.preTotalEther should match state before report - expect(computed!.totalPooledEtherBefore).to.equal(stateBefore.totalPooledEther, "preTotalEther vs stateBefore"); - expect(computed!.totalSharesBefore).to.equal(stateBefore.totalShares, "preTotalShares vs stateBefore"); - - // TokenRebased.postTotalEther should match state after report - expect(computed!.totalPooledEtherAfter).to.equal(stateAfter.totalPooledEther, "postTotalEther vs stateAfter"); - expect(computed!.totalSharesAfter).to.equal(stateAfter.totalShares, "postTotalShares vs stateAfter"); + await mEqual([ + [computed!.id.toLowerCase(), receipt.hash.toLowerCase()], + [computed!.block, BigInt(receipt.blockNumber)], + [computed!.blockTime, blockTimestamp], + [computed!.transactionHash.toLowerCase(), receipt.hash.toLowerCase()], + [computed!.transactionIndex, BigInt(receipt.index)], + [computed!.logIndex, expected!.logIndex], + [computed!.totalPooledEtherBefore, expected!.totalPooledEtherBefore], + [computed!.totalPooledEtherAfter, expected!.totalPooledEtherAfter], + [computed!.totalSharesBefore, expected!.totalSharesBefore], + [computed!.totalSharesAfter, expected!.totalSharesAfter], + [computed!.shares2mint, expected!.shares2mint], + [computed!.timeElapsed, expected!.timeElapsed], + [computed!.mevFee, expected!.mevFee], + [computed!.totalRewardsWithFees, expected!.totalRewardsWithFees], + [computed!.totalRewards, expected!.totalRewards], + [computed!.totalFee, expected!.totalFee], + [computed!.treasuryFee, expected!.treasuryFee], + [computed!.operatorsFee, expected!.operatorsFee], + [computed!.sharesToTreasury, expected!.sharesToTreasury], + [computed!.sharesToOperators, expected!.sharesToOperators], + [computed!.apr, expected!.apr], + [computed!.aprRaw, expected!.aprRaw], + [computed!.aprBeforeFees, expected!.aprBeforeFees], + [computed!.feeBasis, expected!.feeBasis], + [computed!.treasuryFeeBasisPoints, expected!.treasuryFeeBasisPoints], + [computed!.operatorsFeeBasisPoints, expected!.operatorsFeeBasisPoints], + [computed!.shares2mint, computed!.sharesToTreasury + computed!.sharesToOperators], + [computed!.totalFee, computed!.treasuryFee + computed!.operatorsFee], + [computed!.totalPooledEtherBefore, stateBefore.totalPooledEther], + [computed!.totalSharesBefore, stateBefore.totalShares], + [computed!.totalPooledEtherAfter, stateAfter.totalPooledEther], + [computed!.totalSharesAfter, stateAfter.totalShares], + ]); }); it("Should compute TotalReward correctly for second oracle report", async () => { - // 1. Capture state before second oracle report const stateBefore = await capturePoolState(ctx); - log.debug("Pool state before second report", { - "Total Pooled Ether": formatEther(stateBefore.totalPooledEther), - "Total Shares": stateBefore.totalShares.toString(), - }); - - // 2. Execute second oracle report with different rewards const clDiff = ether("0.005"); - const reportData: Partial = { - clDiff, // Smaller reward - }; + const reportData: Partial = { clDiff }; - log.debug("Executing second oracle report", { - "CL Diff": formatEther(clDiff), - }); + await advanceChainTime(INTERVAL_12_HOURS); - await advanceChainTime(12n * 60n * 60n); // 12 hours const { reportTx } = await report(ctx, reportData); const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - // Get block timestamp const block = await ethers.provider.getBlock(receipt.blockNumber); const blockTimestamp = BigInt(block!.timestamp); - - log.debug("Second oracle report transaction", { - "Tx Hash": receipt.hash, - "Block Number": receipt.blockNumber, - "Block Timestamp": blockTimestamp.toString(), - "Log Count": receipt.logs.length, - }); - - // 3. Process events through simulator (using same simulator instance) const result = simulator.processTransaction(receipt, ctx, blockTimestamp); - log.debug("Simulator processing result (using persistent simulator)", { - "Events Processed": result.eventsProcessed, - "Had Profitable Report": result.hadProfitableReport, - "TotalReward Entities Created": result.totalRewards.size, - "Total Entities in Store": simulator.getStore().totalRewards.size, - }); - - // 4. Capture state after const stateAfter = await capturePoolState(ctx); - log.debug("Pool state after second report", { - "Total Pooled Ether": formatEther(stateAfter.totalPooledEther), - "Total Shares": stateAfter.totalShares.toString(), - "Ether Change": formatEther(stateAfter.totalPooledEther - stateBefore.totalPooledEther), - "Shares Change": (stateAfter.totalShares - stateBefore.totalShares).toString(), - }); - - // 5. Verify a TotalReward entity was created - expect(result.hadProfitableReport).to.be.true; - expect(result.totalRewards.size).to.equal(1); + await mEqual([ + [result.hadProfitableReport, true], + [result.totalRewards.size, 1], + ]); const computed = result.totalRewards.get(receipt.hash); expect(computed).to.not.be.undefined; - // 6. Derive expected values directly from events const expected = deriveExpectedTotalReward(receipt, ctx, initialState.treasuryAddress); expect(expected).to.not.be.null; - // Log entity details - log.debug("TotalReward Entity - Tier 1 (Metadata)", { - "ID": computed!.id, - "Block": computed!.block.toString(), - "Block Time": computed!.blockTime.toString(), - "Tx Index": computed!.transactionIndex.toString(), - "Log Index": computed!.logIndex.toString(), - }); - - log.debug("TotalReward Entity - Tier 2 (Pool State)", { - "Total Pooled Ether Before": formatEther(computed!.totalPooledEtherBefore), - "Total Pooled Ether After": formatEther(computed!.totalPooledEtherAfter), - "Total Shares Before": computed!.totalSharesBefore.toString(), - "Total Shares After": computed!.totalSharesAfter.toString(), - "Shares Minted As Fees": computed!.shares2mint.toString(), - "Time Elapsed": computed!.timeElapsed.toString(), - "MEV Fee": formatEther(computed!.mevFee), - }); - - log.debug("TotalReward Entity - Tier 2 (Fee Distribution)", { - "Total Rewards With Fees": formatEther(computed!.totalRewardsWithFees), - "Total Rewards": formatEther(computed!.totalRewards), - "Total Fee": formatEther(computed!.totalFee), - "Treasury Fee": formatEther(computed!.treasuryFee), - "Operators Fee": formatEther(computed!.operatorsFee), - "Shares To Treasury": computed!.sharesToTreasury.toString(), - "Shares To Operators": computed!.sharesToOperators.toString(), - }); - - log.debug("TotalReward Entity - Tier 3 (Calculated)", { - "APR": `${computed!.apr.toFixed(4)}%`, - "APR Raw": `${computed!.aprRaw.toFixed(4)}%`, - "APR Before Fees": `${computed!.aprBeforeFees.toFixed(4)}%`, - "Fee Basis": computed!.feeBasis.toString(), - "Treasury Fee Basis Points": computed!.treasuryFeeBasisPoints.toString(), - "Operators Fee Basis Points": computed!.operatorsFeeBasisPoints.toString(), - }); - - // 7. Verify Tier 1 fields - log.debug("Verifying Tier 1 fields..."); - expect(computed!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "id mismatch"); - expect(computed!.block).to.equal(BigInt(receipt.blockNumber), "block mismatch"); - expect(computed!.blockTime).to.equal(blockTimestamp, "blockTime mismatch"); - expect(computed!.transactionHash.toLowerCase()).to.equal(receipt.hash.toLowerCase(), "transactionHash mismatch"); - expect(computed!.transactionIndex).to.equal(BigInt(receipt.index), "transactionIndex mismatch"); - expect(computed!.logIndex).to.equal(expected!.logIndex, "logIndex mismatch"); - - // 8. Verify Tier 2 fields - log.debug("Verifying Tier 2 fields..."); - expect(computed!.totalPooledEtherBefore).to.equal( - expected!.totalPooledEtherBefore, - "totalPooledEtherBefore mismatch", - ); - expect(computed!.totalPooledEtherAfter).to.equal(expected!.totalPooledEtherAfter, "totalPooledEtherAfter mismatch"); - expect(computed!.totalSharesBefore).to.equal(expected!.totalSharesBefore, "totalSharesBefore mismatch"); - expect(computed!.totalSharesAfter).to.equal(expected!.totalSharesAfter, "totalSharesAfter mismatch"); - expect(computed!.shares2mint).to.equal(expected!.shares2mint, "shares2mint mismatch"); - expect(computed!.timeElapsed).to.equal(expected!.timeElapsed, "timeElapsed mismatch"); - expect(computed!.mevFee).to.equal(expected!.mevFee, "mevFee mismatch"); - - // 8b. Verify Tier 2 fields (Fee Distribution) - log.debug("Verifying Tier 2 fields (Fee Distribution)..."); - expect(computed!.totalRewardsWithFees).to.equal(expected!.totalRewardsWithFees, "totalRewardsWithFees mismatch"); - expect(computed!.totalRewards).to.equal(expected!.totalRewards, "totalRewards mismatch"); - expect(computed!.totalFee).to.equal(expected!.totalFee, "totalFee mismatch"); - expect(computed!.treasuryFee).to.equal(expected!.treasuryFee, "treasuryFee mismatch"); - expect(computed!.operatorsFee).to.equal(expected!.operatorsFee, "operatorsFee mismatch"); - expect(computed!.sharesToTreasury).to.equal(expected!.sharesToTreasury, "sharesToTreasury mismatch"); - expect(computed!.sharesToOperators).to.equal(expected!.sharesToOperators, "sharesToOperators mismatch"); - - // 8c. Verify Tier 3 fields (APR and Basis Points) - log.debug("Verifying Tier 3 fields (APR and Basis Points)..."); - expect(computed!.apr).to.equal(expected!.apr, "apr mismatch"); - expect(computed!.aprRaw).to.equal(expected!.aprRaw, "aprRaw mismatch"); - expect(computed!.aprBeforeFees).to.equal(expected!.aprBeforeFees, "aprBeforeFees mismatch"); - expect(computed!.feeBasis).to.equal(expected!.feeBasis, "feeBasis mismatch"); - expect(computed!.treasuryFeeBasisPoints).to.equal( - expected!.treasuryFeeBasisPoints, - "treasuryFeeBasisPoints mismatch", - ); - expect(computed!.operatorsFeeBasisPoints).to.equal( - expected!.operatorsFeeBasisPoints, - "operatorsFeeBasisPoints mismatch", - ); - - // 8d. Verify fee consistency - log.debug("Verifying fee consistency..."); - expect(computed!.shares2mint).to.equal( - computed!.sharesToTreasury + computed!.sharesToOperators, - "shares2mint should equal sharesToTreasury + sharesToOperators", - ); - expect(computed!.totalFee).to.equal( - computed!.treasuryFee + computed!.operatorsFee, - "totalFee should equal treasuryFee + operatorsFee", - ); - - // 9. Verify state consistency - log.debug("Verifying on-chain state consistency..."); - expect(computed!.totalPooledEtherBefore).to.equal(stateBefore.totalPooledEther, "preTotalEther vs stateBefore"); - expect(computed!.totalSharesBefore).to.equal(stateBefore.totalShares, "preTotalShares vs stateBefore"); - expect(computed!.totalPooledEtherAfter).to.equal(stateAfter.totalPooledEther, "postTotalEther vs stateAfter"); - expect(computed!.totalSharesAfter).to.equal(stateAfter.totalShares, "postTotalShares vs stateAfter"); - - // 10. Verify simulator state persistence (should have both reports) - log.debug("Verifying simulator state persistence..."); - const storedReport = simulator.getTotalReward(receipt.hash); - expect(storedReport).to.not.be.undefined; + await mEqual([ + [computed!.id.toLowerCase(), receipt.hash.toLowerCase()], + [computed!.block, BigInt(receipt.blockNumber)], + [computed!.blockTime, blockTimestamp], + [computed!.transactionHash.toLowerCase(), receipt.hash.toLowerCase()], + [computed!.transactionIndex, BigInt(receipt.index)], + [computed!.logIndex, expected!.logIndex], + [computed!.totalPooledEtherBefore, expected!.totalPooledEtherBefore], + [computed!.totalPooledEtherAfter, expected!.totalPooledEtherAfter], + [computed!.totalSharesBefore, expected!.totalSharesBefore], + [computed!.totalSharesAfter, expected!.totalSharesAfter], + [computed!.shares2mint, expected!.shares2mint], + [computed!.timeElapsed, expected!.timeElapsed], + [computed!.mevFee, expected!.mevFee], + [computed!.totalRewardsWithFees, expected!.totalRewardsWithFees], + [computed!.totalRewards, expected!.totalRewards], + [computed!.totalFee, expected!.totalFee], + [computed!.treasuryFee, expected!.treasuryFee], + [computed!.operatorsFee, expected!.operatorsFee], + [computed!.sharesToTreasury, expected!.sharesToTreasury], + [computed!.sharesToOperators, expected!.sharesToOperators], + [computed!.apr, expected!.apr], + [computed!.aprRaw, expected!.aprRaw], + [computed!.aprBeforeFees, expected!.aprBeforeFees], + [computed!.feeBasis, expected!.feeBasis], + [computed!.treasuryFeeBasisPoints, expected!.treasuryFeeBasisPoints], + [computed!.operatorsFeeBasisPoints, expected!.operatorsFeeBasisPoints], + [computed!.shares2mint, computed!.sharesToTreasury + computed!.sharesToOperators], + [computed!.totalFee, computed!.treasuryFee + computed!.operatorsFee], + [computed!.totalPooledEtherBefore, stateBefore.totalPooledEther], + [computed!.totalSharesBefore, stateBefore.totalShares], + [computed!.totalPooledEtherAfter, stateAfter.totalPooledEther], + [computed!.totalSharesAfter, stateAfter.totalShares], + ]); }); it("Should verify event processing order", async () => { // This test validates that events are processed in the correct order // by examining the logs from the last oracle report const clDiff = ether("0.002"); - const reportData: Partial = { - clDiff, - }; + const reportData: Partial = { clDiff }; - log.debug("Executing oracle report for event order test", { - "CL Diff": formatEther(clDiff), - }); + await advanceChainTime(INTERVAL_12_HOURS); - await advanceChainTime(12n * 60n * 60n); const { reportTx } = await report(ctx, reportData); const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - // Extract and examine logs const logs = extractAllLogs(receipt, ctx); - log.debug("Extracted logs from transaction", { - "Total Logs": logs.length, - "Tx Hash": receipt.hash, - }); - - // Log all event names in order - const eventSummary = logs.map((l) => `${l.logIndex}: ${l.name}`).join(", "); - log.debug("Event order", { - Events: eventSummary, - }); - - // Find key events const ethDistributedIdx = logs.findIndex((l) => l.name === "ETHDistributed"); const tokenRebasedIdx = logs.findIndex((l) => l.name === "TokenRebased"); - const processingStartedIdx = logs.findIndex((l) => l.name === "ProcessingStarted"); - - log.debug("Key event positions", { - "ProcessingStarted Index": processingStartedIdx, - "ETHDistributed Index": ethDistributedIdx, - "TokenRebased Index": tokenRebasedIdx, - }); - // Verify ETHDistributed comes before TokenRebased (as expected by look-ahead) - expect(ethDistributedIdx).to.be.greaterThanOrEqual(0, "ETHDistributed event not found"); - expect(tokenRebasedIdx).to.be.greaterThanOrEqual(0, "TokenRebased event not found"); - expect(ethDistributedIdx).to.be.lessThan(tokenRebasedIdx, "ETHDistributed should come before TokenRebased"); + expect(ethDistributedIdx).to.be.gte(0, "ETHDistributed event not found"); + expect(tokenRebasedIdx).to.be.gte(0, "TokenRebased event not found"); + expect(ethDistributedIdx).to.be.lt(tokenRebasedIdx, "ETHDistributed should come before TokenRebased"); - // Verify Transfer events are between ETHDistributed and TokenRebased (fee mints) const transferEvents = logs.filter( (l) => l.name === "Transfer" && l.logIndex > ethDistributedIdx && l.logIndex < tokenRebasedIdx, ); @@ -544,13 +291,9 @@ describe("Scenario: Graph TotalReward Validation", () => { (l) => l.name === "TransferShares" && l.logIndex > ethDistributedIdx && l.logIndex < tokenRebasedIdx, ); - log.debug("Fee distribution events between ETHDistributed and TokenRebased", { - "Transfer Events": transferEvents.length, - "TransferShares Events": transferSharesEvents.length, - }); - // There should be at least some transfer events for fee distribution - expect(transferEvents.length).to.be.greaterThanOrEqual(0, "Expected Transfer events for fee distribution"); + expect(transferEvents.length).to.be.gte(0, "Expected Transfer events for fee distribution"); + expect(transferSharesEvents.length).to.be.gte(0, "Expected TransferShares events for fee distribution"); }); it("Should query TotalRewards with filtering and pagination", async () => { @@ -558,24 +301,19 @@ describe("Scenario: Graph TotalReward Validation", () => { const clDiff = ether("0.003"); const reportData: Partial = { clDiff }; - await advanceChainTime(12n * 60n * 60n); + await advanceChainTime(INTERVAL_12_HOURS); + const { reportTx } = await report(ctx, reportData); const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; const block = await ethers.provider.getBlock(receipt.blockNumber); const blockTimestamp = BigInt(block!.timestamp); - // Process through simulator simulator.processTransaction(receipt, ctx, blockTimestamp); - // Test 1: Count all TotalRewards const totalCount = simulator.countTotalRewards(0n); - log.debug("Query: Count all TotalRewards", { - "Total Count": totalCount, - }); - expect(totalCount).to.be.greaterThanOrEqual(2, "Should have at least 2 TotalReward entities"); + expect(totalCount).to.be.gte(2, "Should have at least 2 TotalReward entities"); - // Test 2: Query with pagination const queryResult = simulator.queryTotalRewards({ skip: 0, limit: 10, @@ -583,16 +321,8 @@ describe("Scenario: Graph TotalReward Validation", () => { orderBy: "blockTime", orderDirection: "asc", }); + expect(queryResult.length).to.be.gte(2); - log.debug("Query: TotalRewards (skip=0, limit=10, orderBy=blockTime asc)", { - "Results Count": queryResult.length, - "First Block Time": queryResult[0]?.blockTime.toString(), - "Last Block Time": queryResult[queryResult.length - 1]?.blockTime.toString(), - }); - - expect(queryResult.length).to.be.greaterThanOrEqual(2); - - // Verify ordering (ascending by blockTime) for (let i = 1; i < queryResult.length; i++) { expect(queryResult[i].blockTime).to.be.gte( queryResult[i - 1].blockTime, @@ -600,25 +330,6 @@ describe("Scenario: Graph TotalReward Validation", () => { ); } - // Test 3: Query result contains expected fields - const firstResult = queryResult[0]; - log.debug("Query result fields check", { - "Has id": firstResult.id !== undefined, - "Has totalPooledEtherBefore": firstResult.totalPooledEtherBefore !== undefined, - "Has totalPooledEtherAfter": firstResult.totalPooledEtherAfter !== undefined, - "Has totalSharesBefore": firstResult.totalSharesBefore !== undefined, - "Has totalSharesAfter": firstResult.totalSharesAfter !== undefined, - "Has apr": firstResult.apr !== undefined, - "Has block": firstResult.block !== undefined, - "Has blockTime": firstResult.blockTime !== undefined, - "Has logIndex": firstResult.logIndex !== undefined, - }); - - expect(firstResult.id).to.be.a("string"); - expect(typeof firstResult.totalPooledEtherBefore).to.equal("bigint"); - expect(typeof firstResult.apr).to.equal("number"); - - // Test 4: Query with block filter const firstBlock = queryResult[0].block; const filteredResult = simulator.queryTotalRewards({ skip: 0, @@ -628,34 +339,16 @@ describe("Scenario: Graph TotalReward Validation", () => { orderDirection: "asc", }); - log.debug("Query: TotalRewards with block filter", { - "Filter blockFrom": firstBlock.toString(), - "Filtered Results Count": filteredResult.length, - }); - - // All filtered results should have block > firstBlock for (const result of filteredResult) { expect(result.block).to.be.gt(firstBlock, "Filtered results should have block > blockFrom"); } - // Test 5: Get latest TotalReward const latest = simulator.getLatestTotalReward(); - log.debug("Query: Latest TotalReward", { - "Latest ID": latest?.id ?? "N/A", - "Latest Block": latest?.block.toString() ?? "N/A", - "Latest APR": latest ? `${latest.apr.toFixed(4)}%` : "N/A", - }); expect(latest).to.not.be.null; expect(latest!.blockTime).to.equal(queryResult[queryResult.length - 1].blockTime); - // Test 6: Get by ID const byId = simulator.getTotalRewardById(receipt.hash); - log.debug("Query: Get by ID", { - "Requested ID": receipt.hash, - "Found": byId !== null, - }); - expect(byId).to.not.be.null; expect(byId!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase()); }); From 1c38c9514f1d248119f882a03bbbac8df099ad0c Mon Sep 17 00:00:00 2001 From: Artyom Veremeenko Date: Tue, 16 Dec 2025 18:13:55 +0300 Subject: [PATCH 05/10] test(graph): add handling of Totals entity + tests edge cases --- data/temp/total-rewards-comparison.md | 631 ++++++++++++++++++++++ test/graph/edge-cases.integration.ts | 694 +++++++++++++++++++++++++ test/graph/graph-tests-spec.md | 568 +++++++++++++++----- test/graph/simulator/entities.ts | 64 ++- test/graph/simulator/handlers/index.ts | 69 ++- test/graph/simulator/handlers/lido.ts | 220 +++++++- test/graph/simulator/helpers.ts | 159 +++++- test/graph/simulator/index.ts | 50 +- test/graph/simulator/store.ts | 35 +- 9 files changed, 2316 insertions(+), 174 deletions(-) create mode 100644 data/temp/total-rewards-comparison.md create mode 100644 test/graph/edge-cases.integration.ts diff --git a/data/temp/total-rewards-comparison.md b/data/temp/total-rewards-comparison.md new file mode 100644 index 0000000000..6634457f3f --- /dev/null +++ b/data/temp/total-rewards-comparison.md @@ -0,0 +1,631 @@ +# Total Rewards Calculation: Real Graph vs Simulator Comparison + +This document provides a step-by-step comparison of how TotalReward entities are calculated in: + +1. **Real Graph** (`lidofinance/lido-subgraph`) +2. **Simulator** (`test/graph/simulator/`) + +--- + +## Overview + +Both implementations follow the same high-level process: + +1. Process `ETHDistributed` event to create TotalReward entity +2. Look-ahead to find `TokenRebased` event for pool state +3. Extract Transfer/TransferShares pairs for fee distribution +4. Calculate APR and basis points + +--- + +## Step 1: Entry Point - handleETHDistributed + +### Real Graph (`src/Lido.ts` lines 477-571) + +```typescript +export function handleETHDistributed(event: ETHDistributedEvent): void { + // Parse all events from tx receipt + const parsedEvents = parseEventLogs(event, event.address) + + // TokenRebased event should exist (look-ahead) + const tokenRebasedEvent = getParsedEventByName( + parsedEvents, + 'TokenRebased', + event.logIndex + ) + if (!tokenRebasedEvent) { + log.critical('Event TokenRebased not found when ETHDistributed!...') + return + } + + // Totals should be already non-null on oracle report + const totals = _loadTotalsEntity()! + + // Update totals for correct SharesBurnt handling + totals.totalPooledEther = tokenRebasedEvent.params.postTotalEther + totals.save() + + // Handle SharesBurnt if present + const sharesBurntEvent = getParsedEventByName(...) + if (sharesBurntEvent) { + handleSharesBurnt(sharesBurntEvent) + } + + // Update totalShares for next mint transfers + totals.totalShares = tokenRebasedEvent.params.postTotalShares + totals.save() + + // LIP-12: Non-profitable report check + const postCLTotalBalance = event.params.postCLBalance.plus( + event.params.withdrawalsWithdrawn + ) + if (postCLTotalBalance <= event.params.preCLBalance) { + return // Skip non-profitable reports + } + + // Calculate total rewards with fees + const totalRewards = postCLTotalBalance + .minus(event.params.preCLBalance) + .plus(event.params.executionLayerRewardsWithdrawn) + + const totalRewardsEntity = _loadTotalRewardEntity(event, true)! + totalRewardsEntity.totalRewards = totalRewards + totalRewardsEntity.totalRewardsWithFees = totalRewardsEntity.totalRewards + totalRewardsEntity.mevFee = event.params.executionLayerRewardsWithdrawn + + _processTokenRebase(totalRewardsEntity, event, tokenRebasedEvent, parsedEvents) + totalRewardsEntity.save() +} +``` + +### Simulator (`handlers/lido.ts` lines 67-129) + +```typescript +export function handleETHDistributed( + event: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + store: EntityStore, + ctx: HandlerContext, +): ETHDistributedResult { + // Extract ETHDistributed event params + const preCLBalance = getEventArg(event, "preCLBalance"); + const postCLBalance = getEventArg(event, "postCLBalance"); + const withdrawalsWithdrawn = getEventArg(event, "withdrawalsWithdrawn"); + const executionLayerRewardsWithdrawn = getEventArg(event, "executionLayerRewardsWithdrawn"); + + // Find TokenRebased event (look-ahead) + const tokenRebasedEvent = findEventByName(allLogs, "TokenRebased", event.logIndex); + if (!tokenRebasedEvent) { + throw new Error(`TokenRebased event not found after ETHDistributed...`); + } + + // LIP-12: Non-profitable report check + const postCLTotalBalance = postCLBalance + withdrawalsWithdrawn; + if (postCLTotalBalance <= preCLBalance) { + return { totalReward: null, isProfitable: false }; + } + + // Calculate total rewards with fees + const totalRewardsWithFees = postCLTotalBalance - preCLBalance + executionLayerRewardsWithdrawn; + + // Create TotalReward entity + const entity = createTotalRewardEntity(ctx.transactionHash); + entity.block = ctx.blockNumber; + entity.blockTime = ctx.blockTimestamp; + entity.transactionHash = ctx.transactionHash; + entity.transactionIndex = BigInt(ctx.transactionIndex); + entity.logIndex = BigInt(event.logIndex); + entity.mevFee = executionLayerRewardsWithdrawn; + entity.totalRewardsWithFees = totalRewardsWithFees; + + _processTokenRebase(entity, tokenRebasedEvent, allLogs, event.logIndex, ctx.treasuryAddress); + saveTotalReward(store, entity); + + return { totalReward: entity, isProfitable: true }; +} +``` + +### ✅ Differences in Step 1 (Now Aligned) + +| Aspect | Real Graph | Simulator | Status | +| ------------------------------- | ----------------------------------------------- | ----------------------------------- | ------------- | +| Totals state management | Updates shared `Totals` entity before/after | ✅ Now updates `Totals` entity | ✅ Equivalent | +| SharesBurnt handling | Manually calls `handleSharesBurnt()` if present | **NOT IMPLEMENTED** (noted in code) | ⚠️ Missing | +| Error handling | Uses `log.critical()` | Throws Error | ✅ Equivalent | +| Non-profitable check | Returns silently | Returns with `isProfitable: false` | ✅ Equivalent | +| Totals update on non-profitable | Totals still updated | ✅ Totals still updated | ✅ Equivalent | + +--- + +## Step 2: Process TokenRebased - Pool State Extraction + +### Real Graph (`src/Lido.ts` lines 573-590) + +```typescript +export function _processTokenRebase( + entity: TotalReward, + ethDistributedEvent: ETHDistributedEvent, + tokenRebasedEvent: TokenRebasedEvent, + parsedEvents: ParsedEvent[], +): void { + entity.totalPooledEtherBefore = tokenRebasedEvent.params.preTotalEther; + entity.totalSharesBefore = tokenRebasedEvent.params.preTotalShares; + entity.totalPooledEtherAfter = tokenRebasedEvent.params.postTotalEther; + entity.totalSharesAfter = tokenRebasedEvent.params.postTotalShares; + entity.shares2mint = tokenRebasedEvent.params.sharesMintedAsFees; + entity.timeElapsed = tokenRebasedEvent.params.timeElapsed; + // ...continues with fee distribution +} +``` + +### Simulator (`handlers/lido.ts` lines 144-175) + +```typescript +export function _processTokenRebase( + entity: TotalRewardEntity, + tokenRebasedEvent: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + ethDistributedLogIndex: number, + treasuryAddress: string, +): void { + const preTotalEther = getEventArg(tokenRebasedEvent, "preTotalEther"); + const postTotalEther = getEventArg(tokenRebasedEvent, "postTotalEther"); + const preTotalShares = getEventArg(tokenRebasedEvent, "preTotalShares"); + const postTotalShares = getEventArg(tokenRebasedEvent, "postTotalShares"); + const sharesMintedAsFees = getEventArg(tokenRebasedEvent, "sharesMintedAsFees"); + const timeElapsed = getEventArg(tokenRebasedEvent, "timeElapsed"); + + entity.totalPooledEtherBefore = preTotalEther; + entity.totalPooledEtherAfter = postTotalEther; + entity.totalSharesBefore = preTotalShares; + entity.totalSharesAfter = postTotalShares; + entity.shares2mint = sharesMintedAsFees; + entity.timeElapsed = timeElapsed; + // ...continues with fee distribution +} +``` + +### ✅ Pool State Fields - Equivalent + +Both implementations extract the same fields from TokenRebased event: + +- `totalPooledEtherBefore` ← `preTotalEther` +- `totalPooledEtherAfter` ← `postTotalEther` +- `totalSharesBefore` ← `preTotalShares` +- `totalSharesAfter` ← `postTotalShares` +- `shares2mint` ← `sharesMintedAsFees` +- `timeElapsed` ← `timeElapsed` + +--- + +## Step 3: Fee Distribution - Transfer/TransferShares Extraction + +### Real Graph (`src/Lido.ts` lines 586-651) + +```typescript +// Extract Transfer/TransferShares pairs between ETHDistributed and TokenRebased +const transferEventPairs = extractPairedEvent( + parsedEvents, + "Transfer", + "TransferShares", + ethDistributedEvent.logIndex, // start from ETHDistributed + tokenRebasedEvent.logIndex, // to TokenRebased +); + +let sharesToTreasury = ZERO; +let sharesToOperators = ZERO; +let treasuryFee = ZERO; +let operatorsFee = ZERO; + +for (let i = 0; i < transferEventPairs.length; i++) { + const eventTransfer = getParsedEvent(transferEventPairs[i], 0); + const eventTransferShares = getParsedEvent(transferEventPairs[i], 1); + + const treasuryAddress = getAddress("TREASURY"); + + // Process only mint events (from = 0x0) + if (eventTransfer.params.from == ZERO_ADDRESS) { + if (eventTransfer.params.to == treasuryAddress) { + // Mint to treasury + sharesToTreasury = sharesToTreasury.plus(eventTransferShares.params.sharesValue); + treasuryFee = treasuryFee.plus(eventTransfer.params.value); + } else { + // Mint to SR module (operators) + sharesToOperators = sharesToOperators.plus(eventTransferShares.params.sharesValue); + operatorsFee = operatorsFee.plus(eventTransfer.params.value); + } + } +} + +entity.sharesToTreasury = sharesToTreasury; +entity.treasuryFee = treasuryFee; +entity.sharesToOperators = sharesToOperators; +entity.operatorsFee = operatorsFee; +entity.totalFee = treasuryFee.plus(operatorsFee); +entity.totalRewards = entity.totalRewardsWithFees.minus(entity.totalFee); +``` + +### Simulator (`handlers/lido.ts` lines 177-212) + +```typescript +// Extract Transfer/TransferShares pairs between ETHDistributed and TokenRebased +const transferPairs = findTransferSharesPairs(allLogs, ethDistributedLogIndex, tokenRebasedEvent.logIndex); + +let sharesToTreasury = 0n; +let sharesToOperators = 0n; +let treasuryFee = 0n; +let operatorsFee = 0n; + +const treasuryAddressLower = treasuryAddress.toLowerCase(); + +for (const pair of transferPairs) { + // Only process mint events (from = ZERO_ADDRESS) + if (pair.transfer.from.toLowerCase() === ZERO_ADDRESS.toLowerCase()) { + if (pair.transfer.to.toLowerCase() === treasuryAddressLower) { + // Mint to treasury + sharesToTreasury += pair.transferShares.sharesValue; + treasuryFee += pair.transfer.value; + } else { + // Mint to staking router module (operators) + sharesToOperators += pair.transferShares.sharesValue; + operatorsFee += pair.transfer.value; + } + } +} + +entity.sharesToTreasury = sharesToTreasury; +entity.sharesToOperators = sharesToOperators; +entity.treasuryFee = treasuryFee; +entity.operatorsFee = operatorsFee; +entity.totalFee = treasuryFee + operatorsFee; +entity.totalRewards = entity.totalRewardsWithFees - entity.totalFee; +``` + +### Transfer Pairing Logic Comparison + +**Real Graph (`src/parser.ts`):** + +```typescript +// Uses extractPairedEvent which matches Transfer/TransferShares pairs +// within the specified logIndex range +``` + +**Simulator (`utils/event-extraction.ts` lines 209-249):** + +```typescript +export function findTransferSharesPairs( + logs: LogDescriptionWithMeta[], + startLogIndex: number, + endLogIndex: number, +): TransferPair[] { + // Get all Transfer and TransferShares events in range + const transferEvents = logs.filter( + (log) => log.name === "Transfer" && log.logIndex > startLogIndex && log.logIndex < endLogIndex, + ); + const transferSharesEvents = logs.filter( + (log) => log.name === "TransferShares" && log.logIndex > startLogIndex && log.logIndex < endLogIndex, + ); + + // Pair Transfer events with their corresponding TransferShares events + // They are emitted consecutively, so TransferShares follows Transfer with logIndex + 1 + for (const transfer of transferEvents) { + const matchingTransferShares = transferSharesEvents.find((ts) => ts.logIndex === transfer.logIndex + 1); + // ... + } +} +``` + +### ✅ Fee Distribution - Equivalent + +Both implementations: + +1. Filter Transfer/TransferShares pairs between ETHDistributed and TokenRebased +2. Only process mint events (from = 0x0) +3. Categorize by destination: treasury vs operators (SR modules) +4. Calculate totals for shares and ETH values + +--- + +## Step 4: Sanity Check - shares2mint Validation + +### Real Graph (`src/Lido.ts` lines 653-662) + +```typescript +if (entity.shares2mint != sharesToTreasury.plus(sharesToOperators)) { + log.critical( + "totalRewardsEntity.shares2mint != sharesToTreasury + sharesToOperators: shares2mint {} sharesToTreasury {} sharesToOperators {}", + [entity.shares2mint.toString(), sharesToTreasury.toString(), sharesToOperators.toString()], + ); +} +``` + +### Simulator + +**NOT IMPLEMENTED** - The simulator does not include this validation check. + +### ⚠️ Missing Validation + +The simulator lacks the sanity check that verifies: + +``` +shares2mint === sharesToTreasury + sharesToOperators +``` + +This could potentially hide bugs in fee distribution tracking. + +--- + +## Step 5: Basis Points Calculation + +### Real Graph (`src/Lido.ts` lines 669-677) + +```typescript +entity.treasuryFeeBasisPoints = treasuryFee.times(CALCULATION_UNIT).div(entity.totalFee); + +entity.operatorsFeeBasisPoints = operatorsFee.times(CALCULATION_UNIT).div(entity.totalFee); + +entity.feeBasis = entity.totalFee.times(CALCULATION_UNIT).div(entity.totalRewardsWithFees); +``` + +### Simulator (`handlers/lido.ts` lines 214-225) + +```typescript +// feeBasis = totalFee * 10000 / totalRewardsWithFees +entity.feeBasis = + entity.totalRewardsWithFees > 0n ? (entity.totalFee * CALCULATION_UNIT) / entity.totalRewardsWithFees : 0n; + +// treasuryFeeBasisPoints = treasuryFee * 10000 / totalFee +entity.treasuryFeeBasisPoints = entity.totalFee > 0n ? (treasuryFee * CALCULATION_UNIT) / entity.totalFee : 0n; + +// operatorsFeeBasisPoints = operatorsFee * 10000 / totalFee +entity.operatorsFeeBasisPoints = entity.totalFee > 0n ? (operatorsFee * CALCULATION_UNIT) / entity.totalFee : 0n; +``` + +### ⚠️ Difference in Division-by-Zero Handling + +| Aspect | Real Graph | Simulator | +| ---------------- | ------------------------------------------------- | -------------------------------------- | +| Division by zero | No explicit check (Graph's BigInt.div handles it) | Explicit checks with ternary operators | +| Default value | Would throw on division by zero | Returns 0n | + +The simulator is **more defensive** with explicit zero checks. + +--- + +## Step 6: APR Calculation + +### Real Graph (`src/helpers.ts` lines 318-348) + +```typescript +export function _calcAPR_v2( + entity: TotalReward, + preTotalEther: BigInt, + postTotalEther: BigInt, + preTotalShares: BigInt, + postTotalShares: BigInt, + timeElapsed: BigInt, +): void { + // https://docs.lido.fi/integrations/api/#last-lido-apr-for-steth + + const preShareRate = preTotalEther.toBigDecimal().times(E27_PRECISION_BASE).div(preTotalShares.toBigDecimal()); + + const postShareRate = postTotalEther.toBigDecimal().times(E27_PRECISION_BASE).div(postTotalShares.toBigDecimal()); + + const secondsInYear = BigInt.fromI32(60 * 60 * 24 * 365).toBigDecimal(); + + entity.apr = secondsInYear + .times(postShareRate.minus(preShareRate)) + .times(ONE_HUNDRED_PERCENT) // 100 as BigDecimal + .div(preShareRate) + .div(timeElapsed.toBigDecimal()); + + entity.aprRaw = entity.apr; + entity.aprBeforeFees = entity.apr; +} +``` + +### Simulator (`helpers.ts` lines 39-69) + +```typescript +export function calcAPR_v2( + preTotalEther: bigint, + postTotalEther: bigint, + preTotalShares: bigint, + postTotalShares: bigint, + timeElapsed: bigint, +): number { + if (timeElapsed === 0n || preTotalShares === 0n || postTotalShares === 0n) { + return 0; + } + + // APR formula from lido-subgraph: + // preShareRate = preTotalEther * E27 / preTotalShares + // postShareRate = postTotalEther * E27 / postTotalShares + // apr = secondsInYear * (postShareRate - preShareRate) * 100 / preShareRate / timeElapsed + + const preShareRate = (preTotalEther * E27_PRECISION_BASE) / preTotalShares; + const postShareRate = (postTotalEther * E27_PRECISION_BASE) / postTotalShares; + + if (preShareRate === 0n) { + return 0; + } + + // Use BigInt arithmetic then convert to number at the end + // Multiply by 10000 for precision, then divide by 100 at the end + const aprScaled = (SECONDS_PER_YEAR * (postShareRate - preShareRate) * 10000n * 100n) / (preShareRate * timeElapsed); + + return Number(aprScaled) / 10000; +} +``` + +### APR Calculation Comparison + +| Aspect | Real Graph | Simulator | +| ------------------ | ---------------------------------- | ------------------------------------ | +| Arithmetic | `BigDecimal` (arbitrary precision) | `bigint` (integer only) + conversion | +| E27_PRECISION_BASE | `BigDecimal` constant | `bigint` constant (10n \*\* 27n) | +| Result type | `BigDecimal` | `number` | +| Division by zero | No explicit check | Explicit checks return 0 | +| Precision scaling | Direct BigDecimal math | Scaled by 10000 then divided | + +### ⚠️ Potential Precision Differences + +The simulator uses integer arithmetic with scaling, while the real graph uses arbitrary-precision `BigDecimal`. This could lead to minor rounding differences, though the test results suggest they match in practice. + +--- + +## Step 7: Entity Creation and Field Initialization + +### Real Graph (`src/helpers.ts` lines 96-147) + +```typescript +export function _loadTotalRewardEntity(event: ethereum.Event, create: bool = false): TotalReward | null { + let entity = TotalReward.load(event.transaction.hash); + if (!entity && create) { + entity = new TotalReward(event.transaction.hash); + + entity.block = event.block.number; + entity.blockTime = event.block.timestamp; + entity.transactionHash = event.transaction.hash; + entity.transactionIndex = event.transaction.index; + entity.logIndex = event.logIndex; + + entity.feeBasis = ZERO; + entity.treasuryFeeBasisPoints = ZERO; + entity.insuranceFeeBasisPoints = ZERO; // ← Insurance fund (legacy) + entity.operatorsFeeBasisPoints = ZERO; + + entity.totalRewardsWithFees = ZERO; + entity.totalRewards = ZERO; + entity.totalFee = ZERO; + entity.treasuryFee = ZERO; + entity.insuranceFee = ZERO; // ← Insurance fund (legacy) + entity.operatorsFee = ZERO; + entity.dust = ZERO; // ← Dust handling (legacy) + entity.mevFee = ZERO; + + entity.apr = ZERO.toBigDecimal(); + entity.aprRaw = ZERO.toBigDecimal(); + entity.aprBeforeFees = ZERO.toBigDecimal(); + + entity.timeElapsed = ZERO; + entity.totalPooledEtherAfter = ZERO; + entity.totalSharesAfter = ZERO; + entity.shares2mint = ZERO; + + entity.sharesToOperators = ZERO; + entity.sharesToTreasury = ZERO; + entity.sharesToInsuranceFund = ZERO; // ← Insurance fund (legacy) + entity.dustSharesToTreasury = ZERO; // ← Dust handling (legacy) + } + return entity; +} +``` + +### Simulator (`entities.ts` lines 117-153) + +```typescript +export function createTotalRewardEntity(id: string): TotalRewardEntity { + return { + // Tier 1 + id, + block: 0n, + blockTime: 0n, + transactionHash: id, + transactionIndex: 0n, + logIndex: 0n, + + // Tier 2 - Pool State + totalPooledEtherBefore: 0n, + totalPooledEtherAfter: 0n, + totalSharesBefore: 0n, + totalSharesAfter: 0n, + shares2mint: 0n, + timeElapsed: 0n, + mevFee: 0n, + + // Tier 2 - Fee Distribution + totalRewardsWithFees: 0n, + totalRewards: 0n, + totalFee: 0n, + treasuryFee: 0n, + operatorsFee: 0n, + sharesToTreasury: 0n, + sharesToOperators: 0n, + + // Tier 3 + apr: 0, + aprRaw: 0, + aprBeforeFees: 0, + feeBasis: 0n, + treasuryFeeBasisPoints: 0n, + operatorsFeeBasisPoints: 0n, + }; +} +``` + +### ⚠️ Missing Fields in Simulator + +| Field | Real Graph | Simulator | Notes | +| ------------------------- | ---------- | --------- | ---------------------------------------- | +| `insuranceFeeBasisPoints` | ✅ | ❌ | Legacy field, no insurance fund since V2 | +| `insuranceFee` | ✅ | ❌ | Legacy field | +| `sharesToInsuranceFund` | ✅ | ❌ | Legacy field | +| `dust` | ✅ | ❌ | Rounding dust handling | +| `dustSharesToTreasury` | ✅ | ❌ | Rounding dust handling | + +These are **legacy fields** from Lido V1 and are not used in V2/V3 oracle reports, so omitting them is intentional for V2+ testing. + +--- + +## Summary of Differences + +### ❌ Missing in Simulator + +1. **SharesBurnt handling** - Real graph manually processes SharesBurnt events within handleETHDistributed; simulator has placeholder but does not handle this yet. + +2. **shares2mint validation** - Real graph has sanity check that shares2mint equals sum of distributed shares; simulator lacks this. + +3. **Legacy V1 fields** - Insurance fund, dust handling fields not present in simulator entity. + +4. **NodeOperatorFees/NodeOperatorsShares entities** - Real graph creates these related entities; simulator focuses only on TotalReward and Totals. + +### ⚠️ Behavioral Differences + +1. **APR precision** - Real graph uses BigDecimal; simulator uses scaled bigint arithmetic converted to number. + +2. **Division-by-zero handling** - Simulator is more defensive with explicit zero checks. + +### ✅ Now Equivalent + +1. **Totals entity state management** - Simulator now tracks and updates the `Totals` entity during `handleETHDistributed`, matching the real graph's behavior: + - Updates `totalPooledEther` to `postTotalEther` before SharesBurnt handling + - Updates `totalShares` to `postTotalShares` after SharesBurnt handling + - Totals are updated even for non-profitable reports + +### ✅ Equivalent + +1. **Non-profitable report check (LIP-12)** +2. **totalRewardsWithFees calculation** +3. **Pool state extraction from TokenRebased** +4. **Transfer/TransferShares pairing logic** +5. **Fee categorization (treasury vs operators)** +6. **Basis points calculations** +7. **APR formula (same mathematical approach)** + +--- + +## Recommendations + +1. **Add SharesBurnt handling** if testing scenarios that include withdrawal finalization (burns shares). + +2. **Add shares2mint validation** as a sanity check to catch potential bugs early. + +3. **Consider adding Totals state tracking** for multi-transaction scenarios that depend on cumulative state. + +4. **Document that legacy fields are intentionally omitted** for V2+ testing focus. + +5. **Add tests for edge cases**: + - Zero rewards + - Division by zero scenarios + - Very small/large values for APR precision diff --git a/test/graph/edge-cases.integration.ts b/test/graph/edge-cases.integration.ts new file mode 100644 index 0000000000..9ca18b3b12 --- /dev/null +++ b/test/graph/edge-cases.integration.ts @@ -0,0 +1,694 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { advanceChainTime, ether, log, updateBalance } from "lib"; +import { + getProtocolContext, + norSdvtEnsureOperators, + OracleReportParams, + ProtocolContext, + removeStakingLimit, + report, + setStakingLimit, +} from "lib/protocol"; + +import { bailOnFailure, Snapshot } from "test/suite"; + +import { + calcAPR_v2, + calcAPR_v2Extended, + E27_PRECISION_BASE, + GraphSimulator, + MAX_APR_SCALED, + MIN_SHARE_RATE, + SECONDS_PER_YEAR, +} from "./simulator"; +import { captureChainState, capturePoolState, SimulatorInitialState } from "./utils"; + +/** + * Graph Simulator Edge Case Tests + * + * These tests verify correct handling of edge cases: + * - Zero rewards / non-profitable reports + * - Division by zero scenarios + * - Very small/large values for APR precision + * - SharesBurnt handling during withdrawal finalization + * - Totals state validation across multiple transactions + * + * Reference: test/graph/graph-tests-spec.md + */ +describe("Graph Simulator: Edge Cases", () => { + /** + * Unit tests for APR calculation edge cases + * These don't require chain interaction + */ + describe("APR Calculation Edge Cases", () => { + describe("Zero Time Elapsed", () => { + it("Should return 0 APR when timeElapsed is 0", () => { + const apr = calcAPR_v2( + ether("1000000"), // preTotalEther + ether("1001000"), // postTotalEther (0.1% increase) + ether("1000000"), // preTotalShares + ether("1000000"), // postTotalShares + 0n, // timeElapsed = 0 + ); + + expect(apr).to.equal(0); + }); + + it("Should return edge case info via extended function", () => { + const result = calcAPR_v2Extended(ether("1000000"), ether("1001000"), ether("1000000"), ether("1000000"), 0n); + + expect(result.apr).to.equal(0); + expect(result.edgeCase).to.equal("zero_time_elapsed"); + }); + }); + + describe("Zero Shares", () => { + it("Should return 0 APR when preTotalShares is 0", () => { + const apr = calcAPR_v2( + ether("1000000"), + ether("1001000"), + 0n, // preTotalShares = 0 + ether("1000000"), + 86400n, // 1 day + ); + + expect(apr).to.equal(0); + }); + + it("Should return edge case info for zero pre shares", () => { + const result = calcAPR_v2Extended(ether("1000000"), ether("1001000"), 0n, ether("1000000"), 86400n); + + expect(result.apr).to.equal(0); + expect(result.edgeCase).to.equal("zero_pre_shares"); + }); + + it("Should return 0 APR when postTotalShares is 0", () => { + const apr = calcAPR_v2( + ether("1000000"), + ether("1001000"), + ether("1000000"), + 0n, // postTotalShares = 0 + 86400n, + ); + + expect(apr).to.equal(0); + }); + + it("Should return edge case info for zero post shares", () => { + const result = calcAPR_v2Extended(ether("1000000"), ether("1001000"), ether("1000000"), 0n, 86400n); + + expect(result.apr).to.equal(0); + expect(result.edgeCase).to.equal("zero_post_shares"); + }); + }); + + describe("Zero Ether", () => { + it("Should return 0 APR when preTotalEther is 0", () => { + const apr = calcAPR_v2( + 0n, // preTotalEther = 0 + ether("1000"), + ether("1000000"), + ether("1000000"), + 86400n, + ); + + expect(apr).to.equal(0); + }); + + it("Should return edge case info for zero pre ether", () => { + const result = calcAPR_v2Extended(0n, ether("1000"), ether("1000000"), ether("1000000"), 86400n); + + expect(result.apr).to.equal(0); + expect(result.edgeCase).to.equal("zero_pre_ether"); + }); + }); + + describe("Zero Rate Change", () => { + it("Should return 0 APR when share rate is unchanged", () => { + // Same ether and shares = same rate + const apr = calcAPR_v2( + ether("1000000"), // preTotalEther + ether("1000000"), // postTotalEther (same) + ether("1000000"), // preTotalShares + ether("1000000"), // postTotalShares (same) + 86400n, + ); + + expect(apr).to.equal(0); + }); + + it("Should return 0 APR when rates are proportionally the same", () => { + // Double both ether and shares = same rate + const apr = calcAPR_v2( + ether("1000000"), + ether("2000000"), // 2x ether + ether("500000"), + ether("1000000"), // 2x shares (same rate) + 86400n, + ); + + expect(apr).to.equal(0); + }); + + it("Should return edge case info for zero rate change", () => { + const result = calcAPR_v2Extended( + ether("1000000"), + ether("1000000"), + ether("1000000"), + ether("1000000"), + 86400n, + ); + + expect(result.apr).to.equal(0); + expect(result.edgeCase).to.equal("zero_rate_change"); + }); + }); + + describe("Very Small Values", () => { + it("Should handle very small share amounts", () => { + // 1 wei of ether and shares + const apr = calcAPR_v2( + 1n, // 1 wei preTotalEther + 2n, // 2 wei postTotalEther + 1n, // 1 wei preTotalShares + 1n, // 1 wei postTotalShares + SECONDS_PER_YEAR, // 1 year + ); + + // 100% increase over 1 year = 100% APR + expect(apr).to.equal(100); + }); + + it("Should handle share rate at minimum threshold", () => { + // Very small ether relative to shares + const preShareRate = (1n * E27_PRECISION_BASE) / ether("1000000000"); + expect(preShareRate).to.be.lt(MIN_SHARE_RATE); + + const result = calcAPR_v2Extended( + 1n, // very small ether + 2n, + ether("1000000000"), // huge shares + ether("1000000000"), + 86400n, + ); + + expect(result.edgeCase).to.equal("share_rate_too_small"); + }); + }); + + describe("Very Large Values", () => { + it("Should cap extremely large APR to prevent overflow", () => { + // Massive increase in short time + const result = calcAPR_v2Extended( + 1n, // preTotalEther + ether("1000000000000"), // postTotalEther (massive increase) + 1n, // preTotalShares + 1n, // postTotalShares + 1n, // 1 second + ); + + // Should be capped + expect(result.apr).to.equal(Number(MAX_APR_SCALED) / 10000); + expect(result.edgeCase).to.equal("apr_overflow_positive"); + }); + + it("Should handle large but valid APR", () => { + // 100% increase over 1 hour + const apr = calcAPR_v2( + ether("1000000"), + ether("2000000"), // 100% increase + ether("1000000"), + ether("1000000"), + 3600n, // 1 hour + ); + + // APR should be approximately 100% * (365*24) = 876,000% + expect(apr).to.be.gt(800000); + expect(apr).to.be.lt(900000); + }); + }); + + describe("Negative Rate Change (Slashing)", () => { + it("Should calculate negative APR for slashing scenario", () => { + // Post ether less than pre ether (slashing) + const apr = calcAPR_v2( + ether("1000000"), // preTotalEther + ether("990000"), // postTotalEther (1% decrease) + ether("1000000"), // preTotalShares + ether("1000000"), // postTotalShares + SECONDS_PER_YEAR, // 1 year + ); + + // -1% over 1 year = -1% APR + expect(apr).to.equal(-1); + }); + + it("Should handle extreme negative APR", () => { + const result = calcAPR_v2Extended( + ether("1000000000000"), + 1n, // Massive decrease + 1n, + 1n, + 1n, + ); + + expect(result.apr).to.equal(-Number(MAX_APR_SCALED) / 10000); + expect(result.edgeCase).to.equal("apr_overflow_negative"); + }); + }); + + describe("Normal APR Calculation Sanity Checks", () => { + it("Should calculate approximately 5% APR for typical scenario", () => { + // 5% annual yield + const preTotalEther = ether("1000000"); + const postTotalEther = preTotalEther + (preTotalEther * 5n) / 100n; // 5% increase + + const apr = calcAPR_v2( + preTotalEther, + postTotalEther, + ether("1000000"), + ether("1000000"), + SECONDS_PER_YEAR, // 1 year + ); + + // Should be approximately 5% + expect(apr).to.be.approximately(5, 0.001); + }); + + it("Should correctly annualize from 1 day data", () => { + // 0.01% daily = ~3.65% annually + const preTotalEther = ether("1000000"); + const postTotalEther = preTotalEther + (preTotalEther * 1n) / 10000n; // 0.01% increase + + const apr = calcAPR_v2( + preTotalEther, + postTotalEther, + ether("1000000"), + ether("1000000"), + 86400n, // 1 day + ); + + // Should be approximately 365 * 0.01% = 3.65% + expect(apr).to.be.approximately(3.65, 0.01); + }); + }); + }); + + /** + * Integration tests for edge cases requiring chain interaction + */ + describe("Scenario: Non-Profitable Oracle Report", () => { + let ctx: ProtocolContext; + let snapshot: string; + let stEthHolder: HardhatEthersSigner; + let simulator: GraphSimulator; + let initialState: SimulatorInitialState; + + before(async () => { + ctx = await getProtocolContext(); + [stEthHolder] = await ethers.getSigners(); + await updateBalance(stEthHolder.address, ether("100000000")); + + snapshot = await Snapshot.take(); + + initialState = await captureChainState(ctx); + simulator = new GraphSimulator(initialState.treasuryAddress); + + // Initialize simulator with current chain state + simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); + + // Setup protocol + await removeStakingLimit(ctx); + await setStakingLimit(ctx, ether("200000"), ether("20")); + + // Submit some ETH + await ctx.contracts.lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("1000") }); + }); + + after(async () => await Snapshot.restore(snapshot)); + + beforeEach(bailOnFailure); + + it("Should handle zero CL rewards (non-profitable report)", async () => { + log("=== Zero Rewards Test ==="); + + // Execute oracle report with zero CL diff (no rewards) + const reportData: Partial = { + clDiff: 0n, // Zero rewards + }; + + log.info("Executing oracle report with zero CL diff"); + + await advanceChainTime(12n * 60n * 60n); + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + const block = await ethers.provider.getBlock(receipt.blockNumber); + const blockTimestamp = BigInt(block!.timestamp); + + // Process through simulator + const result = simulator.processTransaction(receipt, ctx, blockTimestamp); + + log.info("Non-profitable report result", { + "Had Profitable Report": result.hadProfitableReport, + "TotalReward Entities": result.totalRewards.size, + "Totals Updated": result.totalsUpdated, + "Warnings": result.warnings.length, + }); + + // Verify: No TotalReward entity should be created + expect(result.hadProfitableReport).to.be.false; + expect(result.totalRewards.size).to.equal(0); + + // But Totals should still be updated + expect(result.totalsUpdated).to.be.true; + expect(result.totals).to.not.be.null; + + log("Zero rewards test PASSED"); + }); + + it("Should handle negative CL diff (slashing scenario)", async () => { + log("=== Negative Rewards (Slashing) Test ==="); + + // Execute oracle report with negative CL diff + const reportData: Partial = { + clDiff: -ether("0.001"), // Small loss due to slashing + }; + + log.info("Executing oracle report with negative CL diff"); + + await advanceChainTime(12n * 60n * 60n); + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + const block = await ethers.provider.getBlock(receipt.blockNumber); + const blockTimestamp = BigInt(block!.timestamp); + + // Process through simulator + const result = simulator.processTransaction(receipt, ctx, blockTimestamp); + + log.info("Negative rewards result", { + "Had Profitable Report": result.hadProfitableReport, + "TotalReward Entities": result.totalRewards.size, + "Totals Updated": result.totalsUpdated, + }); + + // Verify: No TotalReward entity (non-profitable) + expect(result.hadProfitableReport).to.be.false; + expect(result.totalRewards.size).to.equal(0); + + // Totals should still be updated + expect(result.totalsUpdated).to.be.true; + + log("Negative rewards test PASSED"); + }); + }); + + describe("Scenario: Totals State Validation", () => { + let ctx: ProtocolContext; + let snapshot: string; + let stEthHolder: HardhatEthersSigner; + let simulator: GraphSimulator; + let initialState: SimulatorInitialState; + let depositCount: bigint; + + before(async () => { + ctx = await getProtocolContext(); + [stEthHolder] = await ethers.getSigners(); + await updateBalance(stEthHolder.address, ether("100000000")); + + snapshot = await Snapshot.take(); + + initialState = await captureChainState(ctx); + simulator = new GraphSimulator(initialState.treasuryAddress); + + // Initialize simulator with current chain state + simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); + + // Setup protocol + await removeStakingLimit(ctx); + await setStakingLimit(ctx, ether("200000"), ether("20")); + + // Ensure operators exist + await norSdvtEnsureOperators(ctx, ctx.contracts.nor, 3n, 5n); + + // Submit ETH + await ctx.contracts.lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("1600") }); + + // Make deposits + const { impersonate, ether: etherFn } = await import("lib"); + const dsmSigner = await impersonate(ctx.contracts.depositSecurityModule.address, etherFn("100")); + const depositTx = await ctx.contracts.lido.connect(dsmSigner).deposit(50n, 1n, new Uint8Array(32)); + const depositReceipt = (await depositTx.wait()) as ContractTransactionReceipt; + const unbufferedEvent = ctx.getEvents(depositReceipt, "Unbuffered")[0]; + const unbufferedAmount = unbufferedEvent?.args[0] || 0n; + depositCount = unbufferedAmount / ether("32"); + }); + + after(async () => await Snapshot.restore(snapshot)); + + beforeEach(bailOnFailure); + + it("Should validate Totals consistency across multiple reports", async () => { + log("=== Multi-Transaction Totals Validation ==="); + + // First report + const clDiff1 = ether("32") * depositCount + ether("0.001"); + await advanceChainTime(12n * 60n * 60n); + const { reportTx: reportTx1 } = await report(ctx, { clDiff: clDiff1, clAppearedValidators: depositCount }); + const receipt1 = (await reportTx1!.wait()) as ContractTransactionReceipt; + const block1 = await ethers.provider.getBlock(receipt1.blockNumber); + + const result1 = simulator.processTransaction(receipt1, ctx, BigInt(block1!.timestamp)); + + log.info("First report result", { + "Totals Updated": result1.totalsUpdated, + "Warnings": result1.warnings.length, + }); + + // Check for no state mismatch warnings (initialized correctly) + const stateMismatchWarnings1 = result1.warnings.filter((w) => w.type === "totals_state_mismatch"); + expect(stateMismatchWarnings1.length).to.equal(0, "Should have no state mismatch on first report"); + + // Get state after first report + const stateAfter1 = await capturePoolState(ctx); + const totalsAfter1 = simulator.getTotals(); + + // Verify simulator Totals match on-chain state + expect(totalsAfter1!.totalPooledEther).to.equal( + stateAfter1.totalPooledEther, + "Totals.totalPooledEther should match chain", + ); + expect(totalsAfter1!.totalShares).to.equal(stateAfter1.totalShares, "Totals.totalShares should match chain"); + + // Second report (simulator state should persist) + const clDiff2 = ether("0.002"); + await advanceChainTime(12n * 60n * 60n); + const { reportTx: reportTx2 } = await report(ctx, { clDiff: clDiff2 }); + const receipt2 = (await reportTx2!.wait()) as ContractTransactionReceipt; + const block2 = await ethers.provider.getBlock(receipt2.blockNumber); + + const result2 = simulator.processTransaction(receipt2, ctx, BigInt(block2!.timestamp)); + + log.info("Second report result", { + "Totals Updated": result2.totalsUpdated, + "Warnings": result2.warnings.length, + }); + + // Check for state consistency (should have no warnings since state was carried over) + const stateMismatchWarnings2 = result2.warnings.filter((w) => w.type === "totals_state_mismatch"); + expect(stateMismatchWarnings2.length).to.equal(0, "Should have no state mismatch on second report"); + + // Final verification + const stateAfter2 = await capturePoolState(ctx); + const totalsAfter2 = simulator.getTotals(); + + expect(totalsAfter2!.totalPooledEther).to.equal(stateAfter2.totalPooledEther); + expect(totalsAfter2!.totalShares).to.equal(stateAfter2.totalShares); + + log("Multi-transaction Totals validation PASSED"); + }); + + it("Should detect Totals state mismatch when not initialized", async () => { + log("=== Totals Mismatch Detection ==="); + + // Create a fresh simulator WITHOUT initializing Totals + const freshSimulator = new GraphSimulator(initialState.treasuryAddress); + // Deliberately initialize with wrong values + freshSimulator.initializeTotals(1n, 1n); // Wrong values + + // Execute a report + const clDiff = ether("0.001"); + await advanceChainTime(12n * 60n * 60n); + const { reportTx } = await report(ctx, { clDiff }); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + const block = await ethers.provider.getBlock(receipt.blockNumber); + + const result = freshSimulator.processTransaction(receipt, ctx, BigInt(block!.timestamp)); + + log.info("Mismatch detection result", { + "Warnings Count": result.warnings.length, + "Warnings": result.warnings.map((w) => w.type).join(", "), + }); + + // Should have state mismatch warnings + const stateMismatchWarnings = result.warnings.filter((w) => w.type === "totals_state_mismatch"); + expect(stateMismatchWarnings.length).to.be.gt(0, "Should detect state mismatch"); + + log("Totals mismatch detection PASSED"); + }); + }); + + describe("shares2mint Validation", () => { + let ctx: ProtocolContext; + let snapshot: string; + let stEthHolder: HardhatEthersSigner; + let simulator: GraphSimulator; + let initialState: SimulatorInitialState; + let depositCount: bigint; + + before(async () => { + ctx = await getProtocolContext(); + [stEthHolder] = await ethers.getSigners(); + await updateBalance(stEthHolder.address, ether("100000000")); + + snapshot = await Snapshot.take(); + + initialState = await captureChainState(ctx); + simulator = new GraphSimulator(initialState.treasuryAddress); + simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); + + // Setup + await removeStakingLimit(ctx); + await setStakingLimit(ctx, ether("200000"), ether("20")); + await norSdvtEnsureOperators(ctx, ctx.contracts.nor, 3n, 5n); + await ctx.contracts.lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("1600") }); + + // Deposits + const { impersonate, ether: etherFn } = await import("lib"); + const dsmSigner = await impersonate(ctx.contracts.depositSecurityModule.address, etherFn("100")); + const depositTx = await ctx.contracts.lido.connect(dsmSigner).deposit(50n, 1n, new Uint8Array(32)); + const depositReceipt = (await depositTx.wait()) as ContractTransactionReceipt; + const unbufferedEvent = ctx.getEvents(depositReceipt, "Unbuffered")[0]; + depositCount = (unbufferedEvent?.args[0] || 0n) / ether("32"); + }); + + after(async () => await Snapshot.restore(snapshot)); + + beforeEach(bailOnFailure); + + it("Should validate shares2mint matches actual minted shares", async () => { + log("=== shares2mint Validation Test ==="); + + const clDiff = ether("32") * depositCount + ether("0.001"); + await advanceChainTime(12n * 60n * 60n); + const { reportTx } = await report(ctx, { clDiff, clAppearedValidators: depositCount }); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + const block = await ethers.provider.getBlock(receipt.blockNumber); + + const result = simulator.processTransaction(receipt, ctx, BigInt(block!.timestamp)); + + // Check for shares2mint validation warnings + const shares2mintWarnings = result.warnings.filter((w) => w.type === "shares2mint_mismatch"); + + log.info("shares2mint validation result", { + "Had Profitable Report": result.hadProfitableReport, + "shares2mint Warnings": shares2mintWarnings.length, + }); + + // In a correctly functioning protocol, there should be no mismatch + expect(shares2mintWarnings.length).to.equal(0, "shares2mint should match minted shares"); + + if (result.hadProfitableReport) { + const entity = result.totalRewards.values().next().value; + if (entity) { + // Verify the sanity check relationship + const totalSharesMinted = entity.sharesToTreasury + entity.sharesToOperators; + expect(entity.shares2mint).to.equal(totalSharesMinted, "shares2mint consistency check"); + + log.info("shares2mint details", { + "shares2mint (from event)": entity.shares2mint.toString(), + "sharesToTreasury": entity.sharesToTreasury.toString(), + "sharesToOperators": entity.sharesToOperators.toString(), + "Sum": totalSharesMinted.toString(), + }); + } + } + + log("shares2mint validation PASSED"); + }); + }); + + describe("Very Small Reward Precision", () => { + let ctx: ProtocolContext; + let snapshot: string; + let stEthHolder: HardhatEthersSigner; + let simulator: GraphSimulator; + let initialState: SimulatorInitialState; + + before(async () => { + ctx = await getProtocolContext(); + [stEthHolder] = await ethers.getSigners(); + await updateBalance(stEthHolder.address, ether("100000000")); + + snapshot = await Snapshot.take(); + + initialState = await captureChainState(ctx); + simulator = new GraphSimulator(initialState.treasuryAddress); + simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); + + await removeStakingLimit(ctx); + await setStakingLimit(ctx, ether("200000"), ether("20")); + await ctx.contracts.lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("1000") }); + }); + + after(async () => await Snapshot.restore(snapshot)); + + beforeEach(bailOnFailure); + + it("Should handle very small rewards (1 wei)", async () => { + log("=== Very Small Rewards Test ==="); + + // Very small but positive reward + const clDiff = 1n; // 1 wei + + await advanceChainTime(12n * 60n * 60n); + const { reportTx } = await report(ctx, { clDiff }); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + const block = await ethers.provider.getBlock(receipt.blockNumber); + + const result = simulator.processTransaction(receipt, ctx, BigInt(block!.timestamp)); + + log.info("Very small rewards result", { + "Had Profitable Report": result.hadProfitableReport, + "TotalReward Entities": result.totalRewards.size, + }); + + // Even 1 wei should be considered profitable (postCL > preCL) + expect(result.hadProfitableReport).to.be.true; + + if (result.hadProfitableReport) { + const entity = result.totalRewards.values().next().value; + if (entity) { + log.info("Small reward entity details", { + "Total Rewards With Fees": entity.totalRewardsWithFees.toString(), + "APR": entity.apr.toString(), + }); + + // Total rewards should be very small + expect(entity.totalRewardsWithFees).to.be.gt(0n); + + // APR calculation should still work (no division by zero) + expect(Number.isFinite(entity.apr)).to.be.true; + } + } + + log("Very small rewards test PASSED"); + }); + }); +}); diff --git a/test/graph/graph-tests-spec.md b/test/graph/graph-tests-spec.md index daf6caa1e4..35c848099b 100644 --- a/test/graph/graph-tests-spec.md +++ b/test/graph/graph-tests-spec.md @@ -20,6 +20,22 @@ The actual Graph works since the Lido contracts genesis, but this requires long The legacy `OracleCompleted` entity tracking is skipped since V3 uses `TokenRebased.timeElapsed` directly. +### Legacy V1 Fields + +The following fields exist in the real Graph schema but are **intentionally omitted** from the simulator as they are legacy V1 fields not used in V2+ oracle reports: + +| Field | Purpose | Why Omitted | +| ------------------------- | -------------------------------- | --------------------------- | +| `insuranceFee` | ETH minted to insurance fund | No insurance fund since V2 | +| `insuranceFeeBasisPoints` | Insurance fee as basis points | No insurance fund since V2 | +| `sharesToInsuranceFund` | Shares minted to insurance fund | No insurance fund since V2 | +| `dust` | Rounding dust ETH to treasury | V2 handles dust differently | +| `dustSharesToTreasury` | Rounding dust shares to treasury | V2 handles dust differently | + +These fields are initialized to zero in the real Graph but never populated for V2+ reports. + +**Note:** The `TotalRewardEntity` interface in `entities.ts` documents these omissions inline for developer reference. + --- ## Architecture @@ -39,22 +55,25 @@ Standalone module in `test/graph/` importable by integration tests. ``` test/graph/ ├── graph-tests-spec.md # This specification +├── index.ts # Re-exports for external use ├── simulator/ -│ ├── index.ts # Main entry point, processTransaction() -│ ├── entities.ts # Entity type definitions (TotalReward, etc.) -│ ├── store.ts # In-memory entity store +│ ├── index.ts # Main entry point, GraphSimulator class, processTransaction() +│ ├── entities.ts # Entity type definitions (TotalRewardEntity, TotalsEntity) +│ ├── store.ts # In-memory entity store with Totals tracking +│ ├── query.ts # Query methods (filtering, pagination, ordering) │ ├── handlers/ -│ │ ├── lido.ts # handleETHDistributed, _processTokenRebase -│ │ ├── accountingOracle.ts # handleProcessingStarted, handleExtraDataSubmitted -│ │ └── index.ts # Handler registry -│ └── helpers.ts # APR calculation, utilities +│ │ ├── lido.ts # handleETHDistributed, handleSharesBurnt, _processTokenRebase +│ │ └── index.ts # Handler registry, processTransactionEvents() +│ └── helpers.ts # APR calculation (calcAPR_v2), basis point 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 +│ ├── index.ts # Re-exports +│ ├── state-capture.ts # captureChainState(), capturePoolState() +│ └── event-extraction.ts # extractAllLogs(), findTransferSharesPairs() +├── total-reward.integration.ts # Integration test for TotalReward entity +└── edge-cases.integration.ts # Edge case tests (zero rewards, division by zero, etc.) ``` -The simulator structure should mirror `lido-subgraph/src/` where practical. +The simulator structure mirrors `lido-subgraph/src/` where practical. --- @@ -84,39 +103,174 @@ In-memory store mimicking Graph's database: ```typescript interface EntityStore { - // Keyed by entity ID (transaction hash for TotalReward) + /** Totals singleton entity (pool state) */ + totals: TotalsEntity | null; + + /** TotalReward entities keyed by transaction hash */ totalRewards: Map; - // Future: other entities + + // Future: other entities (NodeOperatorFees, OracleReport, etc.) +} +``` + +### Totals State Tracking + +The `TotalsEntity` tracks cumulative pool state across transactions: + +```typescript +interface TotalsEntity { + id: string; // Singleton ID (always "") + totalPooledEther: bigint; + totalShares: bigint; } ``` +**Key Behaviors:** + +- Updated during every `handleETHDistributed` call (even for non-profitable reports) +- Updated when `SharesBurnt` events are processed (withdrawal finalization) +- Validated against event params to detect state inconsistencies + ### Transaction Processing ```typescript interface ProcessTransactionResult { - // Mapping of entity type to entities created/updated - totalRewards?: Map; - // Future: other entity types + /** TotalReward entities created/updated (keyed by tx hash) */ + totalRewards: Map; + /** Number of events processed */ + eventsProcessed: number; + /** Whether any profitable oracle report was found */ + hadProfitableReport: boolean; + /** Whether Totals entity was updated */ + totalsUpdated: boolean; + /** The current state of the Totals entity after processing */ + totals: TotalsEntity | null; + /** SharesBurnt events processed during withdrawal finalization */ + sharesBurnt: SharesBurntResult[]; + /** Validation warnings from sanity checks */ + warnings: ValidationWarning[]; } function processTransaction( - logs: LogDescriptionExtended[], - state: SimulatorInitialState, + receipt: ContractTransactionReceipt, + ctx: ProtocolContext, store: EntityStore, + blockTimestamp?: bigint, + treasuryAddress?: string, ): ProcessTransactionResult; ``` -- Accepts batch of logs from a single transaction +- Extracts and parses all logs from the transaction receipt - Logs are processed in `logIndex` order - Handlers can "look ahead" in the logs array (matches Graph behavior) -- Returns mapping of all entities computed in the transaction +- Returns result with created entities, processing metadata, and validation warnings ### Event Extraction -Use existing helpers from `lib/event.ts`: +Custom utilities in `utils/event-extraction.ts`: + +- `extractAllLogs()` - Parse all logs from receipt using protocol interfaces +- `findEventByName()` - Find event by name with optional start index (for look-ahead) +- `findAllEventsByName()` - Find all events by name within a range +- `findTransferSharesPairs()` - Extract paired Transfer/TransferShares events in range +- `getEventArg()` - Type-safe event argument extraction + +--- + +## Validation and Sanity Checks + +### shares2mint Validation + +The simulator validates that `TokenRebased.sharesMintedAsFees` equals the sum of shares actually minted to treasury and operators: + +```typescript +const totalSharesMinted = sharesToTreasury + sharesToOperators; +if (sharesMintedAsFees !== totalSharesMinted) { + warnings.push({ + type: "shares2mint_mismatch", + message: `shares2mint mismatch: expected ${sharesMintedAsFees}, got ${totalSharesMinted}`, + expected: sharesMintedAsFees, + actual: totalSharesMinted, + }); +} +``` + +**Reference:** lido-subgraph/src/Lido.ts lines 664-667 -- `findEventsWithInterfaces()` for parsing logs with contract interfaces -- `findEvents()` for simple event extraction +### Totals State Validation + +When processing `ETHDistributed`, the simulator validates that the current `Totals` state matches the event's `preTotalEther` and `preTotalShares`: + +```typescript +if (totals.totalPooledEther !== 0n && totals.totalPooledEther !== preTotalEther) { + warnings.push({ + type: "totals_state_mismatch", + message: `Totals.totalPooledEther mismatch`, + expected: preTotalEther, + actual: totals.totalPooledEther, + }); +} +``` + +This catches cases where the simulator state gets out of sync with the actual chain state. + +### Validation Warning Types + +```typescript +type ValidationWarningType = + | "shares2mint_mismatch" // TokenRebased.sharesMintedAsFees != actual minted + | "totals_state_mismatch"; // Totals state doesn't match event params +``` + +--- + +## SharesBurnt Handling + +### Overview + +When withdrawal finalization occurs during an oracle report, `SharesBurnt` events are emitted that reduce `totalShares`. The simulator now handles these events. + +### Event Processing Order + +``` +1. ETHDistributed ← Creates TotalReward, updates totalPooledEther +2. SharesBurnt (optional) ← Burns shares during withdrawal finalization +3. Transfer (fee mints) ← Mint shares to treasury/operators +4. TransferShares ← Paired with Transfer +5. TokenRebased ← Final pool state +``` + +### Handler Implementation + +```typescript +function handleSharesBurnt( + event: LogDescriptionWithMeta, + store: EntityStore +): SharesBurntResult { + const sharesAmount = getEventArg(event, "sharesAmount"); + + // Decrease totalShares + totals.totalShares = totals.totalShares - sharesAmount; + saveTotals(store, totals); + + return { sharesBurnt: sharesAmount, ... }; +} +``` + +**Reference:** lido-subgraph/src/Lido.ts handleSharesBurnt() lines 444-476 + +### Integration with handleETHDistributed + +`SharesBurnt` events between `ETHDistributed` and `TokenRebased` are automatically processed: + +```typescript +// Step 2: Handle SharesBurnt if present (for withdrawal finalization) +const sharesBurntEvents = findAllEventsByName(allLogs, "SharesBurnt", event.logIndex, tokenRebasedEvent.logIndex); + +for (const sharesBurntEvent of sharesBurntEvents) { + handleSharesBurnt(sharesBurntEvent, store); +} +``` --- @@ -130,13 +284,15 @@ For Scenario tests (state persists across `it` blocks), initialize simulator at describe("Scenario: Graph TotalReward Validation", () => { let ctx: ProtocolContext; let simulator: GraphSimulator; - let store: EntityStore; + let initialState: SimulatorInitialState; before(async () => { ctx = await getProtocolContext(); - const initialState = await captureChainState(ctx); - store = createEntityStore(); - simulator = new GraphSimulator(initialState, store); + initialState = await captureChainState(ctx); + simulator = new GraphSimulator(initialState.treasuryAddress); + + // Initialize Totals with current chain state + simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); }); it("Should compute TotalReward correctly for first oracle report", async () => { @@ -146,92 +302,185 @@ describe("Scenario: Graph TotalReward Validation", () => { // 2. Execute oracle report const { reportTx } = await report(ctx, reportData); const receipt = await reportTx!.wait(); + const blockTimestamp = BigInt((await ethers.provider.getBlock(receipt.blockNumber))!.timestamp); - // 3. Feed events to simulator - const logs = extractAllLogs(receipt, ctx); - const result = simulator.processTransaction(logs); + // 3. Process through simulator + const result = simulator.processTransaction(receipt, ctx, blockTimestamp); - // 4. Capture state after - const stateAfter = await capturePoolState(ctx); + // 4. Check for validation warnings + expect(result.warnings.length).to.equal(0); - // 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 ... + // 5. Verify entity fields + const computed = result.totalRewards.get(receipt.hash); + // ... verify all fields ... }); }); ``` -### Integration Tests +### Single Transaction Tests -For Integration tests (independent `it` blocks), initialize per-test: +For one-off tests, use `processTransaction()` with a fresh store: ```typescript -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 ... - }); +it("Should compute TotalReward correctly", async () => { + const ctx = await getProtocolContext(); + const initialState = await captureChainState(ctx); + const store = createEntityStore(); + + // Execute transaction... + const result = processTransaction(receipt, ctx, store, blockTimestamp, initialState.treasuryAddress); + + // Verify... }); ``` +### Edge Case Tests + +The `edge-cases.integration.ts` file tests: + +1. **APR Calculation Edge Cases** (unit tests) + + - Zero time elapsed + - Zero shares (pre/post) + - Zero ether + - Zero rate change + - Very small values + - Very large values (overflow protection) + - Negative rate change (slashing) + +2. **Non-Profitable Reports** (integration) + + - Zero CL rewards + - Negative CL diff (slashing) + +3. **Totals State Validation** (integration) + + - Multi-transaction consistency + - Mismatch detection + +4. **shares2mint Validation** (integration) + + - Verify minted shares match event param + +5. **Very Small Rewards** (integration) + - 1 wei rewards + - APR precision with tiny amounts + --- ## TotalReward Entity Fields -### Implementation Tiers - -#### Tier 1 - Direct Event Metadata (Iteration 1) - -| 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 | - -#### Tier 2 - Pool State (Iteration 1) - -| 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 | - -#### Tier 3 - Calculated Fields (Iteration 2+) - -| 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 | +### Implemented Fields + +#### Tier 1 - Direct Event Metadata ✅ + +| Field | Source | Verification | Status | +| ------------------ | ------------------------- | ------------------- | ------ | +| `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 | ✅ | + +#### Tier 2 - Pool State ✅ + +| Field | Source | Verification | Status | +| ------------------------ | ----------------------------------------------- | -------------------------------------- | ------ | +| `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 + validation | ✅ | +| `timeElapsed` | `TokenRebased.timeElapsed` | Event param | ✅ | +| `mevFee` | `ETHDistributed.executionLayerRewardsWithdrawn` | Event param | ✅ | + +#### Tier 2 - Fee Distribution ✅ + +| Field | Source | Verification | Status | +| ---------------------- | ----------------------------------------- | --------------------- | ------ | +| `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 | Transfer events | ✅ | +| `operatorsFee` | Sum of mints to staking modules | Transfer events | ✅ | +| `sharesToTreasury` | From TransferShares to treasury | TransferShares events | ✅ | +| `sharesToOperators` | From TransferShares to modules | TransferShares events | ✅ | + +#### Tier 3 - Calculated Fields ✅ + +| Field | Calculation | Verification | Status | +| ------------------------- | ----------------------------------------- | ----------------------- | ------ | +| `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 | ✅ | + +### Omitted Legacy Fields (V1 only) + +These fields exist in the real Graph schema but are **not implemented** in the simulator: + +| Field | Reason for Omission | +| ------------------------- | ---------------------------------------------- | +| `insuranceFee` | No insurance fund since V2 | +| `insuranceFeeBasisPoints` | No insurance fund since V2 | +| `sharesToInsuranceFund` | No insurance fund since V2 | +| `dust` | Legacy rounding dust handling, not used in V2+ | +| `dustSharesToTreasury` | Legacy rounding dust handling, not used in V2+ | + +--- + +## APR Calculation + +### Formula + +```typescript +// Share rate calculation +preShareRate = (preTotalEther * E27) / preTotalShares; +postShareRate = (postTotalEther * E27) / postTotalShares; + +// APR = annualized percentage change in share rate +apr = (secondsPerYear * (postShareRate - preShareRate) * 100) / preShareRate / timeElapsed; +``` + +### Edge Case Handling + +The `calcAPR_v2` function handles these edge cases: + +| Edge Case | Behavior | +| ------------------------------- | ------------------------------- | +| `timeElapsed = 0` | Returns 0 | +| `preTotalShares = 0` | Returns 0 | +| `postTotalShares = 0` | Returns 0 | +| `preTotalEther = 0` | Returns 0 | +| `preShareRate < MIN_SHARE_RATE` | Returns 0 | +| `rateChange = 0` | Returns 0 | +| `apr > MAX_APR_SCALED` | Capped to prevent overflow | +| `apr < -MAX_APR_SCALED` | Capped to prevent underflow | +| Negative rate change | Returns negative APR (slashing) | + +### Extended APR Function + +For debugging, use `calcAPR_v2Extended` which returns edge case information: + +```typescript +interface APRResult { + apr: number; + edgeCase: APREdgeCase | null; +} + +type APREdgeCase = + | "zero_time_elapsed" + | "zero_pre_shares" + | "zero_post_shares" + | "zero_pre_ether" + | "share_rate_too_small" + | "zero_rate_change" + | "apr_overflow_positive" + | "apr_overflow_negative"; +``` --- @@ -242,10 +491,11 @@ The Graph indexer processes events in the order they appear in the transaction r ``` 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) +3. SharesBurnt (optional) ← Lido contract (withdrawal finalization) +4. Transfer (fee mints) ← Lido contract (multiple, from 0x0) +5. TransferShares ← Lido contract (multiple, paired with Transfer) +6. TokenRebased ← Lido contract (pool state, accessed via look-ahead) +7. ExtraDataSubmitted ← AccountingOracle (links NodeOperator entities) ``` The `handleETHDistributed` handler uses "look-ahead" to access `TokenRebased` event data before it's formally processed. @@ -288,95 +538,125 @@ Uses existing test infrastructure: 1. Simulator-computed entity values 2. Expected values derived from on-chain state -No tolerance for rounding differences (all values are `bigint`). +No tolerance for rounding differences: + +- All integer values use `bigint` for exact matching +- APR values use `number` with scaled integer arithmetic to maintain precision + +**Additional validation:** + +- No `shares2mint_mismatch` warnings in normal operation +- No `totals_state_mismatch` warnings when simulator is properly initialized +- Edge cases handled gracefully without errors --- -## Iteration Plan +## Implementation Status -### Iteration 1 (Current) +### Iteration 1 ✅ Complete **Scope:** - `TotalReward` entity only -- Tier 1 + Tier 2 fields -- Two consecutive oracle reports scenario -- State persistence validation across reports +- Tier 1 fields (event metadata) +- Tier 2 fields (pool state from TokenRebased) **Deliverables:** -- Simulator module with basic handlers +- Simulator module with `handleETHDistributed` handler - Entity store implementation -- State capture utilities -- Integration test with two oracle reports +- State capture utilities (`captureChainState`, `capturePoolState`) +- Event extraction utilities (`extractAllLogs`, `findTransferSharesPairs`) -### Iteration 2 +### Iteration 2 ✅ Complete **Scope:** -- Tier 3 fields (fee calculations, APR) +- Tier 2 fields (fee distribution tracking) +- Tier 3 fields (APR calculations, basis points) - Fee distribution to treasury and staking modules +- Transfer/TransferShares pair extraction -### Iteration 3 - -**Scope:** - -- Related entities: `NodeOperatorFees`, `NodeOperatorsShares`, `OracleReport` - ---- +**Deliverables:** -## Future Iterations - Edge Cases +- `_processTokenRebase` with full fee tracking +- `calcAPR_v2` implementation +- Basis point calculations +- Query functionality (filtering, pagination, ordering) +- Integration tests with multiple oracle reports -The following edge cases should be addressed in future iterations: +### Iteration 2.1 ✅ Complete -### Non-Profitable Oracle Report +**Scope:** -- When `postCLBalance + withdrawalsWithdrawn <= preCLBalance` -- No `TotalReward` entity should be created -- Test that simulator correctly skips entity creation +- SharesBurnt event handling for withdrawal finalization +- shares2mint validation sanity check +- Totals state tracking and validation +- Edge case tests +- Documentation updates -### Report with Withdrawal Finalization +**Deliverables:** -- `WithdrawalsFinalized` event in same transaction -- Shares burnt via `SharesBurnt` event -- Affects `totalSharesAfter` calculation +- `handleSharesBurnt` handler implementation +- `ValidationWarning` types and reporting +- `calcAPR_v2Extended` with edge case info +- `edge-cases.integration.ts` test suite +- Updated spec documentation -### Report with Slashing Penalties +### Iteration 3 (Future) -- Negative rewards scenario -- Validator exit edge cases +**Scope:** -### Multiple Staking Modules +- Related entities: `NodeOperatorFees`, `NodeOperatorsShares`, `OracleReport` +- `handleProcessingStarted` from AccountingOracle +- `handleExtraDataSubmitted` from AccountingOracle -- CSM (Community Staking Module) integration -- Fee distribution across NOR, SDVT, CSM +--- -### Dust and Rounding +## Future Considerations -- `dustSharesToTreasury` field -- Rounding in fee distribution +### Edge Cases to Monitor ---- +| Scenario | Current Status | Notes | +| ------------------------ | -------------- | ----------------------------- | +| Non-profitable report | ✅ Tested | Returns `isProfitable: false` | +| Withdrawal finalization | ✅ Implemented | `handleSharesBurnt` called | +| Slashing penalties | ✅ APR handles | Returns negative APR | +| Multiple staking modules | ✅ Tested | NOR + SDVT + CSM support | +| Zero rewards | ✅ Tested | No entity created | +| Very small rewards | ✅ Tested | 1 wei handled | +| APR overflow | ✅ Protected | Capped at MAX_APR_SCALED | -## Relationship to Actual Graph Code +### Relationship to Actual Graph Code -### Current Approach +#### Current Approach - Manual TypeScript port of relevant handler logic - Comments referencing original `lido-subgraph/src/` file locations - Focus on correctness over exact code mirroring -### Maintenance +#### Key Differences from Real Graph + +| Aspect | Real Graph | Simulator | +| ---------------------- | ----------------------------------- | -------------------------------- | +| State management | Persistent `Totals` entity | ✅ Tracks `Totals` entity | +| SharesBurnt handling | Manual call in handleETHDistributed | ✅ Implemented | +| APR arithmetic | `BigDecimal` (arbitrary precision) | `bigint` with scaling → `number` | +| Division by zero | Graph's implicit handling | Explicit defensive checks | +| shares2mint validation | Critical log on mismatch | ✅ Validation warnings | +| State consistency | Assertions | ✅ Warning-based validation | + +#### Maintenance - When Graph code changes, tests serve as validation - Discrepancies indicate either bug in Graph or test update needed -- Consider shared test vectors in future +- Detailed comparison available in `data/temp/total-rewards-comparison.md` -### Reference Files +#### Reference Files Key Graph source files to mirror: -- `lido-subgraph/src/Lido.ts` - `handleETHDistributed`, `_processTokenRebase` +- `lido-subgraph/src/Lido.ts` - `handleETHDistributed`, `handleSharesBurnt`, `_processTokenRebase` - `lido-subgraph/src/helpers.ts` - `_calcAPR_v2`, entity loaders - `lido-subgraph/src/AccountingOracle.ts` - `handleProcessingStarted` - `lido-subgraph/src/constants.ts` - Calculation units, addresses diff --git a/test/graph/simulator/entities.ts b/test/graph/simulator/entities.ts index 51c93a3755..85c5a34835 100644 --- a/test/graph/simulator/entities.ts +++ b/test/graph/simulator/entities.ts @@ -5,14 +5,74 @@ * All numeric values use bigint to ensure exact matching without precision loss. * APR values use number (BigDecimal equivalent). * - * Reference: lido-subgraph/schema.graphql - TotalReward entity - * Reference: lido-subgraph/src/helpers.ts - _loadTotalRewardEntity() + * ## V2+ Testing Focus + * + * This simulator is designed for V2+ (post-V2 upgrade) testing. The following + * legacy V1 fields exist in the real Graph schema but are **intentionally omitted** + * from this simulator as they are not populated for V2+ oracle reports: + * + * | Field | Purpose | Why Omitted | + * | ------------------------- | ------------------------------------ | ---------------------------------- | + * | `insuranceFee` | ETH minted to insurance fund | No insurance fund since V2 | + * | `insuranceFeeBasisPoints` | Insurance fee as basis points | No insurance fund since V2 | + * | `sharesToInsuranceFund` | Shares minted to insurance fund | No insurance fund since V2 | + * | `dust` | Rounding dust ETH to treasury | V2 handles dust differently | + * | `dustSharesToTreasury` | Rounding dust shares to treasury | V2 handles dust differently | + * + * These fields are initialized to zero in the real Graph but never populated for V2+ reports. + * If testing V1 scenarios (historical data), these fields would need to be added. + * + * Reference: lido-subgraph/schema.graphql - TotalReward, Totals entities + * Reference: lido-subgraph/src/helpers.ts - _loadTotalRewardEntity(), _loadTotalsEntity() */ +/** + * Totals entity representing the current state of the Lido pool + * + * This entity is a singleton (id = "") that tracks the total pooled ether and shares. + * It is updated during oracle reports and other operations that change the pool state. + * + * Reference: lido-subgraph/src/helpers.ts _loadTotalsEntity() + */ +export interface TotalsEntity { + /** Singleton ID (always empty string) */ + id: string; + + /** Total pooled ether in the protocol */ + totalPooledEther: bigint; + + /** Total shares in the protocol */ + totalShares: bigint; +} + +/** + * Create a new Totals entity with default values + * + * @returns New TotalsEntity with zero values + */ +export function createTotalsEntity(): TotalsEntity { + return { + id: "", + totalPooledEther: 0n, + totalShares: 0n, + }; +} + /** * TotalReward entity representing rewards data from an oracle report * * This entity is created by handleETHDistributed when processing a profitable oracle report. + * + * ## Legacy Fields Not Included (V1 only) + * + * The following fields exist in the real Graph schema but are **not implemented** here: + * - `insuranceFee`: ETH value minted to insurance fund (no insurance fund since V2) + * - `insuranceFeeBasisPoints`: Insurance fee as basis points (no insurance fund since V2) + * - `sharesToInsuranceFund`: Shares minted to insurance fund (no insurance fund since V2) + * - `dust`: Rounding dust ETH to treasury (V2 handles dust differently) + * - `dustSharesToTreasury`: Rounding dust shares to treasury (V2 handles dust differently) + * + * These would be set to 0 in V2+ oracle reports anyway. */ export interface TotalRewardEntity { // ========== Tier 1 - Direct Event Metadata ========== diff --git a/test/graph/simulator/handlers/index.ts b/test/graph/simulator/handlers/index.ts index be671f81fb..6032f097f0 100644 --- a/test/graph/simulator/handlers/index.ts +++ b/test/graph/simulator/handlers/index.ts @@ -6,13 +6,21 @@ */ import { LogDescriptionWithMeta } from "../../utils/event-extraction"; -import { TotalRewardEntity } from "../entities"; +import { TotalRewardEntity, TotalsEntity } from "../entities"; import { EntityStore } from "../store"; -import { handleETHDistributed, HandlerContext, isETHDistributedEvent } from "./lido"; +import { + handleETHDistributed, + HandlerContext, + handleSharesBurnt, + isETHDistributedEvent, + isSharesBurntEvent, + SharesBurntResult, + ValidationWarning, +} from "./lido"; // Re-export for convenience -export { HandlerContext } from "./lido"; +export { HandlerContext, ValidationWarning, SharesBurntResult } from "./lido"; /** * Result of processing a transaction's events @@ -26,6 +34,18 @@ export interface ProcessTransactionResult { /** Whether any profitable oracle report was found */ hadProfitableReport: boolean; + + /** Whether Totals entity was updated */ + totalsUpdated: boolean; + + /** The current state of the Totals entity after processing */ + totals: TotalsEntity | null; + + /** SharesBurnt events processed during withdrawal finalization */ + sharesBurnt: SharesBurntResult[]; + + /** Validation warnings from sanity checks */ + warnings: ValidationWarning[]; } /** @@ -34,6 +54,10 @@ export interface ProcessTransactionResult { * Events are processed in logIndex order. Some handlers (like handleETHDistributed) * use look-ahead to access later events in the same transaction. * + * Note: SharesBurnt events are handled within handleETHDistributed when they occur + * between ETHDistributed and TokenRebased events (withdrawal finalization scenario). + * Standalone SharesBurnt events outside of oracle reports are also tracked. + * * @param logs - All parsed logs from the transaction, sorted by logIndex * @param store - Entity store for persisting entities * @param ctx - Handler context with transaction metadata @@ -48,8 +72,15 @@ export function processTransactionEvents( totalRewards: new Map(), eventsProcessed: 0, hadProfitableReport: false, + totalsUpdated: false, + totals: null, + sharesBurnt: [], + warnings: [], }; + // Track which SharesBurnt events were already processed by handleETHDistributed + const processedSharesBurntIndices = new Set(); + // Process events in logIndex order for (const log of logs) { result.eventsProcessed++; @@ -58,10 +89,37 @@ export function processTransactionEvents( if (isETHDistributedEvent(log)) { const ethDistributedResult = handleETHDistributed(log, logs, store, ctx); + // Track Totals update (happens even for non-profitable reports) + result.totalsUpdated = true; + result.totals = ethDistributedResult.totals; + + // Collect warnings from handler + result.warnings.push(...ethDistributedResult.warnings); + if (ethDistributedResult.isProfitable && ethDistributedResult.totalReward) { result.totalRewards.set(ethDistributedResult.totalReward.id, ethDistributedResult.totalReward); result.hadProfitableReport = true; } + + // Mark SharesBurnt events that were processed as part of this ETHDistributed handler + // (they occur between ETHDistributed and TokenRebased) + const tokenRebasedIdx = logs.findIndex((l) => l.name === "TokenRebased" && l.logIndex > log.logIndex); + if (tokenRebasedIdx >= 0) { + const tokenRebasedLogIndex = logs[tokenRebasedIdx].logIndex; + for (const l of logs) { + if (l.name === "SharesBurnt" && l.logIndex > log.logIndex && l.logIndex < tokenRebasedLogIndex) { + processedSharesBurntIndices.add(l.logIndex); + } + } + } + } + + // Handle standalone SharesBurnt events (not part of oracle report) + if (isSharesBurntEvent(log) && !processedSharesBurntIndices.has(log.logIndex)) { + const sharesBurntResult = handleSharesBurnt(log, store); + result.sharesBurnt.push(sharesBurntResult); + result.totalsUpdated = true; + result.totals = sharesBurntResult.totals; } // Future handlers can be added here: @@ -70,5 +128,10 @@ export function processTransactionEvents( // - handleTransfer (Lido) - for fee distribution tracking } + // Get final Totals state from store if not already set + if (!result.totals && store.totals) { + result.totals = store.totals; + } + return result; } diff --git a/test/graph/simulator/handlers/lido.ts b/test/graph/simulator/handlers/lido.ts index 4c7d10ad05..fa2f3aa95f 100644 --- a/test/graph/simulator/handlers/lido.ts +++ b/test/graph/simulator/handlers/lido.ts @@ -2,22 +2,24 @@ * Lido event handlers for Graph Simulator * * Ports the core logic from lido-subgraph/src/Lido.ts: - * - handleETHDistributed() - Main handler that creates TotalReward entity + * - handleETHDistributed() - Main handler that creates TotalReward entity and updates Totals + * - handleSharesBurnt() - Handles SharesBurnt events during withdrawal finalization * - _processTokenRebase() - Extracts pool state from TokenRebased event * * Reference: lido-subgraph/src/Lido.ts lines 477-690 */ import { + findAllEventsByName, findEventByName, findTransferSharesPairs, getEventArg, LogDescriptionWithMeta, ZERO_ADDRESS, } from "../../utils/event-extraction"; -import { createTotalRewardEntity, TotalRewardEntity } from "../entities"; +import { createTotalRewardEntity, TotalRewardEntity, TotalsEntity } from "../entities"; import { calcAPR_v2, CALCULATION_UNIT } from "../helpers"; -import { EntityStore, saveTotalReward } from "../store"; +import { EntityStore, loadTotalsEntity, saveTotalReward, saveTotals } from "../store"; /** * Context passed to handlers containing transaction metadata @@ -48,6 +50,47 @@ export interface ETHDistributedResult { /** Whether the report was profitable (entity was created) */ isProfitable: boolean; + + /** The updated Totals entity (always updated, even for non-profitable reports) */ + totals: TotalsEntity; + + /** Any validation warnings encountered during processing */ + warnings: ValidationWarning[]; +} + +/** + * Result of processing a SharesBurnt event + */ +export interface SharesBurntResult { + /** Amount of shares burnt */ + sharesBurnt: bigint; + + /** Account whose shares were burnt */ + account: string; + + /** Pre-rebase token amount */ + preRebaseTokenAmount: bigint; + + /** Post-rebase token amount */ + postRebaseTokenAmount: bigint; + + /** The updated Totals entity */ + totals: TotalsEntity; +} + +/** + * Validation warning types for sanity checks + */ +export type ValidationWarningType = "shares2mint_mismatch" | "totals_state_mismatch"; + +/** + * Validation warning issued during event processing + */ +export interface ValidationWarning { + type: ValidationWarningType; + message: string; + expected?: bigint; + actual?: bigint; } /** @@ -56,6 +99,11 @@ export interface ETHDistributedResult { * This is the main entry point for processing oracle reports. * It looks ahead to find the TokenRebased event and extracts pool state. * + * IMPORTANT: This handler also updates the Totals entity to match the real graph behavior: + * 1. Update totalPooledEther to postTotalEther (before SharesBurnt handling) + * 2. Handle SharesBurnt if present (decreases totalShares) during withdrawal finalization + * 3. Update totalShares to postTotalShares (after SharesBurnt handling) + * * Reference: lido-subgraph/src/Lido.ts handleETHDistributed() lines 477-571 * * @param event - The ETHDistributed event @@ -70,6 +118,8 @@ export function handleETHDistributed( store: EntityStore, ctx: HandlerContext, ): ETHDistributedResult { + const warnings: ValidationWarning[] = []; + // Extract ETHDistributed event params const preCLBalance = getEventArg(event, "preCLBalance"); const postCLBalance = getEventArg(event, "postCLBalance"); @@ -77,6 +127,7 @@ export function handleETHDistributed( const executionLayerRewardsWithdrawn = getEventArg(event, "executionLayerRewardsWithdrawn"); // Find TokenRebased event (look-ahead) + // Reference: lido-subgraph/src/Lido.ts lines 487-502 const tokenRebasedEvent = findEventByName(allLogs, "TokenRebased", event.logIndex); if (!tokenRebasedEvent) { @@ -85,22 +136,82 @@ export function handleETHDistributed( ); } - // Check for non-profitable report (LIP-12) + // Extract TokenRebased params for Totals update + const preTotalEther = getEventArg(tokenRebasedEvent, "preTotalEther"); + const postTotalEther = getEventArg(tokenRebasedEvent, "postTotalEther"); + const preTotalShares = getEventArg(tokenRebasedEvent, "preTotalShares"); + const postTotalShares = getEventArg(tokenRebasedEvent, "postTotalShares"); + const sharesMintedAsFees = getEventArg(tokenRebasedEvent, "sharesMintedAsFees"); + + // ========== Update Totals Entity ========== + // Reference: lido-subgraph/src/Lido.ts lines 504-540 + + // Load Totals entity (should already exist on oracle report) + const totals = loadTotalsEntity(store, true)!; + + // ========== Totals State Validation (Sanity Check) ========== + // In real graph, there are assertions here - we convert to warnings + // Reference: lido-subgraph/src/Lido.ts lines 509-513 + if (totals.totalPooledEther !== 0n && totals.totalPooledEther !== preTotalEther) { + warnings.push({ + type: "totals_state_mismatch", + message: `Totals.totalPooledEther mismatch: expected ${preTotalEther}, got ${totals.totalPooledEther}`, + expected: preTotalEther, + actual: totals.totalPooledEther, + }); + } + if (totals.totalShares !== 0n && totals.totalShares !== preTotalShares) { + warnings.push({ + type: "totals_state_mismatch", + message: `Totals.totalShares mismatch: expected ${preTotalShares}, got ${totals.totalShares}`, + expected: preTotalShares, + actual: totals.totalShares, + }); + } + + // Step 1: Update totalPooledEther for correct SharesBurnt handling + // Reference: lido-subgraph/src/Lido.ts lines 515-517 + totals.totalPooledEther = postTotalEther; + saveTotals(store, totals); + + // Step 2: Handle SharesBurnt if present (for withdrawal finalization) + // Reference: lido-subgraph/src/Lido.ts lines 521-535 + // Find all SharesBurnt events between ETHDistributed and TokenRebased + const sharesBurntEvents = findAllEventsByName(allLogs, "SharesBurnt", event.logIndex, tokenRebasedEvent.logIndex); + + for (const sharesBurntEvent of sharesBurntEvents) { + handleSharesBurnt(sharesBurntEvent, store); + } + + // Step 3: Update totalShares for next mint transfers + // Reference: lido-subgraph/src/Lido.ts lines 537-540 + totals.totalShares = postTotalShares; + saveTotals(store, totals); + + // ========== Non-Profitable Report Check (LIP-12) ========== + // Reference: lido-subgraph/src/Lido.ts lines 542-551 // Don't mint/distribute any protocol fee on non-profitable oracle report // when consensus layer balance delta is zero or negative const postCLTotalBalance = postCLBalance + withdrawalsWithdrawn; if (postCLTotalBalance <= preCLBalance) { + // Note: Totals are still updated even for non-profitable reports! return { totalReward: null, isProfitable: false, + totals, + warnings, }; } + // ========== Create TotalReward Entity ========== + // Reference: lido-subgraph/src/Lido.ts lines 553-570 + // Calculate total rewards with fees (same as real graph lines 553-556) // totalRewardsWithFees = (postCLBalance + withdrawalsWithdrawn - preCLBalance) + executionLayerRewardsWithdrawn const totalRewardsWithFees = postCLTotalBalance - preCLBalance + executionLayerRewardsWithdrawn; // Create TotalReward entity + // Reference: lido-subgraph/src/helpers.ts _loadTotalRewardEntity() const entity = createTotalRewardEntity(ctx.transactionHash); // Tier 1 - Direct Event Metadata @@ -114,10 +225,22 @@ export function handleETHDistributed( entity.mevFee = executionLayerRewardsWithdrawn; // Tier 2 - Total rewards with fees + // Reference: lido-subgraph/src/Lido.ts lines 559-561 + // In real graph: totalRewardsEntity.totalRewards = totalRewards (initially same as totalRewardsWithFees) + // totalRewardsEntity.totalRewardsWithFees = totalRewardsEntity.totalRewards entity.totalRewardsWithFees = totalRewardsWithFees; // Process TokenRebased to fill in pool state and fee distribution - _processTokenRebase(entity, tokenRebasedEvent, allLogs, event.logIndex, ctx.treasuryAddress); + // This will also set entity.totalRewards = totalRewardsWithFees - totalFee + const rebaseWarnings = _processTokenRebase( + entity, + tokenRebasedEvent, + allLogs, + event.logIndex, + ctx.treasuryAddress, + sharesMintedAsFees, + ); + warnings.push(...rebaseWarnings); // Save entity saveTotalReward(store, entity); @@ -125,9 +248,58 @@ export function handleETHDistributed( return { totalReward: entity, isProfitable: true, + totals, + warnings, }; } +/** + * Handle SharesBurnt event - updates Totals when shares are burnt during withdrawal finalization + * + * This is called from handleETHDistributed when SharesBurnt events are found + * between ETHDistributed and TokenRebased events. + * + * Reference: lido-subgraph/src/Lido.ts handleSharesBurnt() lines 444-476 + * + * @param event - The SharesBurnt event + * @param store - Entity store + * @returns Result containing the burnt shares details and updated Totals + */ +export function handleSharesBurnt(event: LogDescriptionWithMeta, store: EntityStore): SharesBurntResult { + // Extract SharesBurnt event params + // event SharesBurnt(address indexed account, uint256 preRebaseTokenAmount, uint256 postRebaseTokenAmount, uint256 sharesAmount) + const account = getEventArg(event, "account"); + const preRebaseTokenAmount = getEventArg(event, "preRebaseTokenAmount"); + const postRebaseTokenAmount = getEventArg(event, "postRebaseTokenAmount"); + const sharesAmount = getEventArg(event, "sharesAmount"); + + // Load Totals entity + const totals = loadTotalsEntity(store, true)!; + + // Update totalShares by subtracting burnt shares + // Reference: lido-subgraph/src/Lido.ts lines 460-463 + totals.totalShares = totals.totalShares - sharesAmount; + saveTotals(store, totals); + + return { + sharesBurnt: sharesAmount, + account, + preRebaseTokenAmount, + postRebaseTokenAmount, + totals, + }; +} + +/** + * Check if an event is a SharesBurnt event + * + * @param event - The event to check + * @returns true if this is a SharesBurnt event + */ +export function isSharesBurntEvent(event: LogDescriptionWithMeta): boolean { + return event.name === "SharesBurnt"; +} + /** * Process TokenRebased event to extract pool state fields, fee distribution, and calculate APR * @@ -140,6 +312,8 @@ export function handleETHDistributed( * @param allLogs - All parsed logs from the transaction (for Transfer/TransferShares extraction) * @param ethDistributedLogIndex - Log index of the ETHDistributed event * @param treasuryAddress - Treasury address for fee categorization + * @param sharesMintedAsFees - Expected shares minted as fees from TokenRebased (for validation) + * @returns Array of validation warnings encountered during processing */ export function _processTokenRebase( entity: TotalRewardEntity, @@ -147,7 +321,10 @@ export function _processTokenRebase( allLogs: LogDescriptionWithMeta[], ethDistributedLogIndex: number, treasuryAddress: string, -): void { + sharesMintedAsFees?: bigint, +): ValidationWarning[] { + const warnings: ValidationWarning[] = []; + // Extract TokenRebased event params // event TokenRebased( // uint256 indexed reportTimestamp, @@ -163,7 +340,7 @@ export function _processTokenRebase( const postTotalEther = getEventArg(tokenRebasedEvent, "postTotalEther"); const preTotalShares = getEventArg(tokenRebasedEvent, "preTotalShares"); const postTotalShares = getEventArg(tokenRebasedEvent, "postTotalShares"); - const sharesMintedAsFees = getEventArg(tokenRebasedEvent, "sharesMintedAsFees"); + const sharesMintedAsFeesFromEvent = getEventArg(tokenRebasedEvent, "sharesMintedAsFees"); const timeElapsed = getEventArg(tokenRebasedEvent, "timeElapsed"); // Tier 2 - Pool State @@ -171,7 +348,7 @@ export function _processTokenRebase( entity.totalPooledEtherAfter = postTotalEther; entity.totalSharesBefore = preTotalShares; entity.totalSharesAfter = postTotalShares; - entity.shares2mint = sharesMintedAsFees; + entity.shares2mint = sharesMintedAsFeesFromEvent; entity.timeElapsed = timeElapsed; // ========== Fee Distribution Tracking ========== @@ -211,6 +388,31 @@ export function _processTokenRebase( entity.totalFee = treasuryFee + operatorsFee; entity.totalRewards = entity.totalRewardsWithFees - entity.totalFee; + // ========== shares2mint Validation (Sanity Check) ========== + // Reference: lido-subgraph/src/Lido.ts lines 664-667 + // In the real graph, there's a critical log if shares2mint != sharesToTreasury + sharesToOperators + const totalSharesMinted = sharesToTreasury + sharesToOperators; + if (sharesMintedAsFeesFromEvent !== totalSharesMinted) { + warnings.push({ + type: "shares2mint_mismatch", + message: + `shares2mint mismatch: TokenRebased.sharesMintedAsFees (${sharesMintedAsFeesFromEvent}) != ` + + `sharesToTreasury + sharesToOperators (${totalSharesMinted})`, + expected: sharesMintedAsFeesFromEvent, + actual: totalSharesMinted, + }); + } + + // Also validate against the passed sharesMintedAsFees if provided + if (sharesMintedAsFees !== undefined && sharesMintedAsFees !== sharesMintedAsFeesFromEvent) { + warnings.push({ + type: "shares2mint_mismatch", + message: `shares2mint event param inconsistency: passed ${sharesMintedAsFees} vs event ${sharesMintedAsFeesFromEvent}`, + expected: sharesMintedAsFees, + actual: sharesMintedAsFeesFromEvent, + }); + } + // ========== Calculate Basis Points ========== // Reference: lido-subgraph/src/Lido.ts lines 669-677 @@ -231,6 +433,8 @@ export function _processTokenRebase( // In v2, aprRaw and aprBeforeFees are the same as apr entity.aprRaw = entity.apr; entity.aprBeforeFees = entity.apr; + + return warnings; } /** diff --git a/test/graph/simulator/helpers.ts b/test/graph/simulator/helpers.ts index 08f1955de5..d90cfdd5c2 100644 --- a/test/graph/simulator/helpers.ts +++ b/test/graph/simulator/helpers.ts @@ -1,8 +1,9 @@ /** * Helper functions for Graph Simulator * - * This module will contain APR calculations and other derived value - * computations in future iterations. + * This module contains APR calculations and other derived value computations. + * All functions include defensive checks for edge cases (division by zero, + * very small/large values) to ensure robust behavior. * * Reference: lido-subgraph/src/helpers.ts - _calcAPR_v2() */ @@ -23,9 +24,30 @@ export const E27_PRECISION_BASE = 10n ** 27n; export const SECONDS_PER_YEAR = BigInt(60 * 60 * 24 * 365); /** - * Placeholder for APR calculation (Iteration 2) + * Maximum safe value for APR to prevent overflow when converting to number + * This represents approximately 1 trillion percent APR + */ +export const MAX_APR_SCALED = BigInt(Number.MAX_SAFE_INTEGER); + +/** + * Minimum meaningful share rate to prevent precision loss + * Share rates below this are treated as zero + */ +export const MIN_SHARE_RATE = 1n; + +/** + * Calculate V2 APR based on share rate changes + * + * The APR is calculated as the annualized percentage change in share rate: + * APR = (postShareRate - preShareRate) / preShareRate * secondsPerYear / timeElapsed * 100 + * + * ## Edge Cases Handled * - * This will implement the V2 APR calculation based on share rate changes. + * - **Zero time elapsed**: Returns 0 (no meaningful APR can be calculated) + * - **Zero shares**: Returns 0 (prevents division by zero) + * - **Zero share rate**: Returns 0 (prevents division by zero) + * - **Very large values**: Capped to prevent JavaScript number overflow + * - **Negative share rate change**: Returns negative APR (slashing/penalties scenario) * * Reference: lido-subgraph/src/helpers.ts _calcAPR_v2() lines 318-348 * @@ -34,7 +56,7 @@ export const SECONDS_PER_YEAR = BigInt(60 * 60 * 24 * 365); * @param preTotalShares - Total shares before rebase * @param postTotalShares - Total shares after rebase * @param timeElapsed - Time elapsed in seconds - * @returns APR as a percentage (e.g., 5.0 for 5%) + * @returns APR as a percentage (e.g., 5.0 for 5%), or 0 for edge cases */ export function calcAPR_v2( preTotalEther: bigint, @@ -43,9 +65,18 @@ export function calcAPR_v2( postTotalShares: bigint, timeElapsed: bigint, ): number { - // Will be implemented in Iteration 2 - // For now, return 0 - if (timeElapsed === 0n || preTotalShares === 0n || postTotalShares === 0n) { + // Edge case: zero time elapsed - no meaningful APR + if (timeElapsed === 0n) { + return 0; + } + + // Edge case: zero shares - prevents division by zero + if (preTotalShares === 0n || postTotalShares === 0n) { + return 0; + } + + // Edge case: zero ether - share rate would be 0 + if (preTotalEther === 0n) { return 0; } @@ -57,17 +88,125 @@ export function calcAPR_v2( const preShareRate = (preTotalEther * E27_PRECISION_BASE) / preTotalShares; const postShareRate = (postTotalEther * E27_PRECISION_BASE) / postTotalShares; - if (preShareRate === 0n) { + // Edge case: pre share rate too small (would cause division by zero or precision loss) + if (preShareRate < MIN_SHARE_RATE) { + return 0; + } + + // Calculate rate change (can be negative for slashing scenarios) + const rateChange = postShareRate - preShareRate; + + // Edge case: zero rate change - APR is exactly 0 + if (rateChange === 0n) { return 0; } // Use BigInt arithmetic then convert to number at the end // Multiply by 10000 for precision, then divide by 100 at the end - const aprScaled = (SECONDS_PER_YEAR * (postShareRate - preShareRate) * 10000n * 100n) / (preShareRate * timeElapsed); + // Formula: secondsPerYear * rateChange * 100 * 10000 / preShareRate / timeElapsed + const aprScaled = (SECONDS_PER_YEAR * rateChange * 10000n * 100n) / (preShareRate * timeElapsed); + + // Edge case: very large APR (prevent overflow when converting to number) + if (aprScaled > MAX_APR_SCALED) { + return Number(MAX_APR_SCALED) / 10000; + } + if (aprScaled < -MAX_APR_SCALED) { + return -Number(MAX_APR_SCALED) / 10000; + } return Number(aprScaled) / 10000; } +/** + * Safely calculate APR with explicit edge case information + * + * This is an extended version of calcAPR_v2 that returns additional + * information about which edge case (if any) was encountered. + * + * @param preTotalEther - Total ether before rebase + * @param postTotalEther - Total ether after rebase + * @param preTotalShares - Total shares before rebase + * @param postTotalShares - Total shares after rebase + * @param timeElapsed - Time elapsed in seconds + * @returns Object with APR value and edge case information + */ +export function calcAPR_v2Extended( + preTotalEther: bigint, + postTotalEther: bigint, + preTotalShares: bigint, + postTotalShares: bigint, + timeElapsed: bigint, +): APRResult { + // Edge case: zero time elapsed + if (timeElapsed === 0n) { + return { apr: 0, edgeCase: "zero_time_elapsed" }; + } + + // Edge case: zero shares + if (preTotalShares === 0n) { + return { apr: 0, edgeCase: "zero_pre_shares" }; + } + if (postTotalShares === 0n) { + return { apr: 0, edgeCase: "zero_post_shares" }; + } + + // Edge case: zero ether + if (preTotalEther === 0n) { + return { apr: 0, edgeCase: "zero_pre_ether" }; + } + + const preShareRate = (preTotalEther * E27_PRECISION_BASE) / preTotalShares; + const postShareRate = (postTotalEther * E27_PRECISION_BASE) / postTotalShares; + + // Edge case: share rate too small + if (preShareRate < MIN_SHARE_RATE) { + return { apr: 0, edgeCase: "share_rate_too_small" }; + } + + const rateChange = postShareRate - preShareRate; + + // Edge case: zero rate change + if (rateChange === 0n) { + return { apr: 0, edgeCase: "zero_rate_change" }; + } + + const aprScaled = (SECONDS_PER_YEAR * rateChange * 10000n * 100n) / (preShareRate * timeElapsed); + + // Edge case: APR overflow + if (aprScaled > MAX_APR_SCALED) { + return { apr: Number(MAX_APR_SCALED) / 10000, edgeCase: "apr_overflow_positive" }; + } + if (aprScaled < -MAX_APR_SCALED) { + return { apr: -Number(MAX_APR_SCALED) / 10000, edgeCase: "apr_overflow_negative" }; + } + + return { apr: Number(aprScaled) / 10000, edgeCase: null }; +} + +/** + * APR calculation result with edge case information + */ +export interface APRResult { + /** Calculated APR as percentage */ + apr: number; + + /** Which edge case was encountered, or null if normal calculation */ + edgeCase: APREdgeCase | null; +} + +/** + * Edge cases that can occur during APR calculation + */ +export type APREdgeCase = + | "zero_time_elapsed" + | "zero_pre_shares" + | "zero_post_shares" + | "zero_pre_ether" + | "share_rate_too_small" + | "zero_rate_change" + | "apr_overflow_positive" + | "apr_overflow_negative"; + /** * Calculate fee basis points * diff --git a/test/graph/simulator/index.ts b/test/graph/simulator/index.ts index f004a51023..c89ee0f515 100644 --- a/test/graph/simulator/index.ts +++ b/test/graph/simulator/index.ts @@ -21,7 +21,7 @@ import { ProtocolContext } from "lib/protocol"; import { extractAllLogs, findTransferSharesPairs, ZERO_ADDRESS } from "../utils/event-extraction"; -import { TotalRewardEntity } from "./entities"; +import { TotalRewardEntity, TotalsEntity } from "./entities"; import { HandlerContext, processTransactionEvents, ProcessTransactionResult } from "./handlers"; import { calcAPR_v2, CALCULATION_UNIT } from "./helpers"; import { @@ -33,13 +33,13 @@ import { TotalRewardsQueryParamsExtended, TotalRewardsQueryResult, } from "./query"; -import { createEntityStore, EntityStore } from "./store"; +import { createEntityStore, EntityStore, loadTotalsEntity, saveTotals } from "./store"; // Re-export types and utilities -export { TotalRewardEntity, createTotalRewardEntity } from "./entities"; -export { EntityStore, createEntityStore, getTotalReward, saveTotalReward } from "./store"; +export { TotalRewardEntity, createTotalRewardEntity, TotalsEntity, createTotalsEntity } from "./entities"; +export { EntityStore, createEntityStore, getTotalReward, saveTotalReward, loadTotalsEntity, saveTotals } from "./store"; export { SimulatorInitialState, PoolState, captureChainState, capturePoolState } from "../utils/state-capture"; -export { ProcessTransactionResult } from "./handlers"; +export { ProcessTransactionResult, ValidationWarning, SharesBurntResult } from "./handlers"; // Re-export query types and functions export { @@ -53,6 +53,19 @@ export { TotalRewardsQueryResult, } from "./query"; +// Re-export helper functions and types for testing +export { + calcAPR_v2, + calcAPR_v2Extended, + CALCULATION_UNIT, + E27_PRECISION_BASE, + SECONDS_PER_YEAR, + MAX_APR_SCALED, + MIN_SHARE_RATE, + APRResult, + APREdgeCase, +} from "./helpers"; + /** * Process a transaction's events through the Graph simulator * @@ -163,6 +176,33 @@ export class GraphSimulator { this.store = createEntityStore(); } + // ========== Totals Entity Methods ========== + + /** + * Get the current Totals entity + * + * @returns The Totals entity or null if not initialized + */ + getTotals(): TotalsEntity | null { + return this.store.totals; + } + + /** + * Initialize Totals entity with values from chain state + * + * This should be called at test setup to initialize the simulator + * with the current chain state before processing transactions. + * + * @param totalPooledEther - Total pooled ether from lido.getTotalPooledEther() + * @param totalShares - Total shares from lido.getTotalShares() + */ + initializeTotals(totalPooledEther: bigint, totalShares: bigint): void { + const totals = loadTotalsEntity(this.store, true)!; + totals.totalPooledEther = totalPooledEther; + totals.totalShares = totalShares; + saveTotals(this.store, totals); + } + // ========== Query Methods ========== /** diff --git a/test/graph/simulator/store.ts b/test/graph/simulator/store.ts index e0d89a2406..bf7929441e 100644 --- a/test/graph/simulator/store.ts +++ b/test/graph/simulator/store.ts @@ -7,7 +7,7 @@ * Reference: The Graph's store API provides load/save operations for entities */ -import { TotalRewardEntity } from "./entities"; +import { createTotalsEntity, TotalRewardEntity, TotalsEntity } from "./entities"; /** * Entity store interface containing all entity collections @@ -16,10 +16,13 @@ import { TotalRewardEntity } from "./entities"; * Future iterations will add more entity types (NodeOperatorFees, etc.) */ export interface EntityStore { + /** Totals singleton entity (pool state) */ + totals: TotalsEntity | null; + /** TotalReward entities keyed by transaction hash */ totalRewards: Map; - // Future entity collections (Iteration 2+): + // Future entity collections: // nodeOperatorFees: Map; // nodeOperatorsShares: Map; // oracleReports: Map; @@ -32,6 +35,7 @@ export interface EntityStore { */ export function createEntityStore(): EntityStore { return { + totals: null, totalRewards: new Map(), }; } @@ -44,9 +48,36 @@ export function createEntityStore(): EntityStore { * @param store - The store to clear */ export function clearStore(store: EntityStore): void { + store.totals = null; store.totalRewards.clear(); } +/** + * Load or create the Totals entity + * + * Mimics _loadTotalsEntity from lido-subgraph/src/helpers.ts + * + * @param store - The entity store + * @param create - Whether to create if not exists + * @returns The Totals entity or null if not exists and create=false + */ +export function loadTotalsEntity(store: EntityStore, create: boolean = false): TotalsEntity | null { + if (!store.totals && create) { + store.totals = createTotalsEntity(); + } + return store.totals; +} + +/** + * Save the Totals entity to the store + * + * @param store - The entity store + * @param entity - The Totals entity to save + */ +export function saveTotals(store: EntityStore, entity: TotalsEntity): void { + store.totals = entity; +} + /** * Get a TotalReward entity by ID (transaction hash) * From 84d6e8adbae2c0c03dcd179cbad880e9a807cf52 Mon Sep 17 00:00:00 2001 From: Artyom Veremeenko Date: Wed, 17 Dec 2025 16:39:42 +0300 Subject: [PATCH 06/10] test(graph): add more entities and human-readable README.md --- CLAUDE.md | 79 ++ test/graph/README.md | 270 ++++++ test/graph/edge-cases.integration.ts | 694 -------------- test/graph/entities-scenario.integration.ts | 891 ++++++++++++++++++ test/graph/graph-tests-spec.md | 662 ------------- test/graph/simulator/entities.ts | 276 ++++++ test/graph/simulator/handlers/index.ts | 127 ++- test/graph/simulator/handlers/lido.ts | 601 +++++++++++- test/graph/simulator/index.ts | 230 ++++- test/graph/simulator/store.ts | 253 ++++- .../core/total-reward.integration.ts | 355 ------- 11 files changed, 2696 insertions(+), 1742 deletions(-) create mode 100644 CLAUDE.md create mode 100644 test/graph/README.md delete mode 100644 test/graph/edge-cases.integration.ts create mode 100644 test/graph/entities-scenario.integration.ts delete mode 100644 test/graph/graph-tests-spec.md delete mode 100644 test/integration/core/total-reward.integration.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..654e7d736d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Lido on Ethereum liquid-staking protocol core contracts repository. Users deposit ETH and receive stETH tokens representing their stake. The protocol uses Aragon DAO for governance. + +## Build & Test Commands + +```bash +yarn compile # Compile all contracts +yarn test # Run all unit tests (parallel) +yarn test:sequential # Run tests sequentially (required for .only) +yarn test:trace # Run with call tracing +yarn test:forge # Run Foundry fuzzing tests +yarn test:integration:scratch # Integration tests on scratch deploy +yarn test:integration # Integration tests on mainnet fork +yarn lint # Run all linters +yarn lint:sol:fix # Fix Solidity lint issues +yarn lint:ts:fix # Fix TypeScript lint issues +yarn format:fix # Fix formatting +yarn typecheck # TypeScript type checking +``` + +To run a single test: add `.only` to the test and run `yarn test:sequential`. + +Environment: Copy `.env.example` to `.env` and configure `RPC_URL` for mainnet fork tests. + +## Project Structure + +### Contracts (`/contracts`) + +Organized by Solidity version: + +- `0.4.24/` - Core Aragon-managed contracts (Lido, stETH, NodeOperatorsRegistry) +- `0.6.12/` - wstETH (non-upgradeable, version-locked) +- `0.8.9/` - Modern contracts (StakingRouter, WithdrawalQueue, Oracles, Accounting) +- `0.8.25/` - Staking Vaults system (VaultHub, StakingVault, Dashboard, PredepositGuarantee) +- `common/` - Shared interfaces and libraries across versions + +### Tests (`/test`) + +Mirror the contracts structure. Naming conventions: + +- `*.test.ts` - Hardhat unit tests +- `*.integration.ts` - Integration tests +- `*.t.sol` - Foundry tests (fuzzing) +- `__Harness` suffix - Test wrappers exposing private functions +- `__Mock` suffix - Simulated contract behavior + +### Library (`/lib`) + +TypeScript utilities for tests and scripts. Key modules: + +- `protocol/` - Protocol discovery and helpers (accounting, staking, withdrawal, vaults) +- `eips/` - EIP implementations (EIP-712, EIP-4788, EIP-7002, EIP-7251) +- Test helpers: deposit, dsm, oracle, signing-keys + +### Scripts (`/scripts`) + +- `scratch/steps/` - Scratch deployment steps +- `upgrade/steps/` - Protocol upgrade scripts + +## Architecture Notes + +**Multi-compiler setup**: Different contracts use different Solidity versions due to Aragon compatibility (0.4.24) and feature requirements. See `contracts/COMPILERS.md`. + +**OpenZeppelin v5.2 local copies**: Located in `contracts/openzeppelin/5.2/upgradeable/` with modified imports to support the aliased dependency `@openzeppelin/contracts-v5.2`. + +**Tracing in tests**: Wrap code with `Tracing.enable()` and `Tracing.disable()` from `test/suite`, then run with `yarn test:trace`. + +## Conventions + +- Package manager: yarn (not npx) +- Commits: Conventional Commits format +- Solidity: Follow Official Solidity Style Guide, auto-format with Solhint +- TypeScript: Auto-format with ESLint +- Temporary data: Store in `data/temp/` directory diff --git a/test/graph/README.md b/test/graph/README.md new file mode 100644 index 0000000000..0e7b842064 --- /dev/null +++ b/test/graph/README.md @@ -0,0 +1,270 @@ +# Graph tests intro + +These graph integration tests are intended to simulate calculations done by the Graph based on +(mostly) events and compare with the actual on-chain state after a number of transactions. + +## Scope & Limitations + +- **V3 only**: Tests only V3 (post-V2) code paths; historical sync is skipped +- **Initial state from chain**: Simulator initializes from current on-chain state at test start +- **Legacy fields omitted**: V1 fields (`insuranceFee`, `dust`, `sharesToInsuranceFund`, etc.) are not implemented as they're unused since V2 +- **OracleCompleted skipped**: Legacy entity tracking replaced by `TokenRebased.timeElapsed` + +## Test Environment + +- Hoodi testnet via forking +- Uses `lib/protocol/` helpers and `test/suite/` utilities + +## Success Criteria + +- **Exact match**: All `bigint` values must match exactly (no tolerance for rounding) +- **Entity consistency**: Simulator's `Totals` must match on-chain `lido.getTotalPooledEther()` and `lido.getTotalShares()` +- **No validation warnings**: `shares2mint_mismatch` and `totals_state_mismatch` warnings indicate bugs + +## Transactions Scenario + +File `entities-scenario.integration.ts`. + +**Minimum targets:** 6 deposits, 5 transfers, 7 oracle reports, 5 withdrawal requests, 4 V3 mints, 4 V3 burns. + +The test performs 32 interleaved actions across 6 phases: + +1. user1 deposits 100 ETH (no referral) +2. user2 deposits 50 ETH (referral=user1) +3. Oracle report #1 (profitable, clDiff=0.01 ETH) +4. user3 deposits 200 ETH +5. Transfer user1→user2 (10 ETH) +6. Vault1 report → Vault1 mints 50 stETH to user3 +7. Oracle report #2 (profitable, clDiff=0.001 ETH) +8. Vault2 report → Vault2 mints 30 stETH to user3 +9. user4 deposits 25 ETH +10. Vault1 report → Vault1 burns 20 stETH +11. user1 requests withdrawal (30 ETH) +12. user2 requests withdrawal (20 ETH) +13. Oracle report #3 (profitable + finalizes withdrawals) +14. user5 deposits 500 ETH +15. Transfer user3→user4 (50 ETH) +16. Oracle report #4 (zero rewards, clDiff=0) +17. Vault2 report → Vault2 mints 100 stETH +18. user1 requests withdrawal (50 ETH) +19. Oracle report #5 (negative rewards, clDiff=-0.0001 ETH) +20. Transfer user4→user1 (near full balance) +21. Vault1 report → Vault1 mints 75 stETH +22. user2 deposits 80 ETH +23. Vault2 report → Vault2 burns 50 stETH +24. user3 requests withdrawal (40 ETH) +25. Oracle report #6 (profitable + batch finalization) +26. user1 deposits 30 ETH (referral=user5) +27. Transfer user1→user3 (15 ETH) +28. Vault1 report → Vault1 burns 30 stETH +29. user5 requests withdrawal (100 ETH) +30. Oracle report #7 (profitable, clDiff=0.002 ETH) +31. Transfer user2→user5 (25 ETH) +32. Vault2 report → Vault2 burns 30 stETH + +### Validation Approach + +Each transaction is processed through `GraphSimulator.processTransaction()` which parses events and updates entities. Validation helpers check: + +- **`validateSubmission`**: Verifies `LidoSubmission` entity fields (`sender`, `amount`, `referral`, `shares > 0`) +- **`validateTransfer`**: Verifies `LidoTransfer` entity fields and share balance arithmetic: + - `sharesBeforeDecrease - sharesAfterDecrease == shares` + - `sharesAfterIncrease - sharesBeforeIncrease == shares` +- **`validateOracleReport`**: For profitable reports, verifies `TotalReward` fee distribution: + - `shares2mint == sharesToTreasury + sharesToOperators` + - `totalFee == treasuryFee + operatorsFee` + - For non-profitable (zero/negative), verifies no `TotalReward` is created +- **`validateGlobalConsistency`**: Compares simulator's `Totals` entity against on-chain `lido.getTotalPooledEther()` and `lido.getTotalShares()` + +## Specifics + +- This document does not describe legacy code written for pre-V2 upgrade. +- there are specific workarounds for specific networks for cases when an event does not exist ([Voting example](https://github.com/lidofinance/lido-subgraph/blob/6334a6a28ab6978b66d45220a27c3c2dc78be918/src/Voting.ts#L67)) + +## Entities + +Subgraph calculates and stores various data structures called entities. Some of them are iteratively modified (cumulative), e.g. total pooled ether. Some of them are immutable like stETH transfers. + +### Totals (cumulative) + +Notable fields: + +- totalPooledEther +- totalShares + +Events used when: + +1. User submits ether + +- `Lido.Submitted`: `amount` + +2. Oracle reports + +- `Lido.TokenRebased`: `postTotalShares`, `postTotalEther` +- `Lido.SharesBurnt.sharesAmount` + +3. StETH minted on VaultHub + +- `Lido.ExternalSharesMinted`: increases `totalShares` by `amountOfShares`, updates `totalPooledEther` via contract read + +4. External shares burnt (emitted by `VaultHub.burnShares`, `Lido.rebalanceExternalEtherToInternal()`, `Lido.internalizeExternalBadDebt`) + +- `Lido.ExternalSharesBurnt`: updates `totalPooledEther` via contract read + +### Shares (cumulative) + +Notable fields: + +- id (holder address as Bytes) +- shares + +When updated: + +1. User submits ether + +- `Lido.Transfer` (from 0x0 to user): increases user's shares +- `Lido.TransferShares` (from 0x0 to user): provides shares value + +2. User transfers stETH + +- `Lido.Transfer` (from user to recipient): decreases sender's shares, increases recipient's shares +- `Lido.TransferShares` (from user to recipient): provides shares value + +3. Oracle reports rewards + +- `Lido.Transfer` (from 0x0 to Treasury): increases Treasury's shares +- `Lido.Transfer` (from 0x0 to SR modules): increases SR module's shares + +4. Shares are burnt (withdrawal finalization) + +- `Lido.SharesBurnt`: decreases account's shares + +### LidoTransfer (immutable) + +Notable fields: + +- from +- to +- value +- shares +- sharesBeforeDecrease / sharesAfterDecrease +- sharesBeforeIncrease / sharesAfterIncrease +- totalPooledEther +- totalShares +- balanceAfterDecrease / balanceAfterIncrease + +When updated: + +1. User submits ether + +- `Lido.Submitted` event is handled first +- `Lido.Transfer` (from 0x0 to user): creates mint transfer entity +- `Lido.TransferShares` (from 0x0 to user): provides shares value + +2. User transfers stETH + +- `Lido.Transfer` (from user to recipient): creates transfer entity +- `Lido.TransferShares` (from user to recipient): provides shares value + +3. Oracle reports rewards + +- `Lido.ETHDistributed` and `Lido.TokenRebased` events are parsed together +- `Lido.Transfer` (from 0x0 to Treasury): creates mint transfer for treasury fees +- `Lido.TransferShares` (from 0x0 to Treasury): provides shares value +- `Lido.Transfer` (from 0x0 to SR modules): creates mint transfers for node operator fees +- `Lido.TransferShares` (from 0x0 to SR modules): provides shares value + +4. Shares are burnt (withdrawal finalization) + +- `Lido.SharesBurnt`: creates transfer entity (from account to 0x0), shares value taken directly from event + +Other entities used: + +- `Totals`: provides `totalPooledEther` and `totalShares` + - Used to calculate `balanceAfterIncrease`: `sharesAfterIncrease * totalPooledEther / totalShares` + - Used to calculate `balanceAfterDecrease`: `sharesAfterDecrease * totalPooledEther / totalShares` +- `Shares` (for from/to addresses): provides account share balances + - `sharesBeforeDecrease` = from address's current shares + - `sharesAfterDecrease` = from address's shares after subtraction + - `sharesBeforeIncrease` = to address's current shares + - `sharesAfterIncrease` = to address's shares after addition +- `TotalReward`: identifies oracle report transfers and provides fee distribution data + - Used to determine shares for treasury and node operator fee transfers + +### TotalReward (immutable) + +One per oracle report. + +Notable fields: + +- id (transaction hash) +- totalRewards / totalRewardsWithFees +- mevFee (execution layer rewards) +- feeBasis / treasuryFeeBasisPoints / operatorsFeeBasisPoints +- totalFee / treasuryFee / operatorsFee +- shares2mint / sharesToTreasury / sharesToOperators +- totalPooledEtherBefore / totalPooledEtherAfter +- totalSharesBefore / totalSharesAfter +- timeElapsed +- apr / aprRaw / aprBeforeFees + +When updated: + +1. Oracle report + +- `Lido.ETHDistributed`: creates entity; uses `preCLBalance`, `postCLBalance`, `withdrawalsWithdrawn`, `executionLayerRewardsWithdrawn` to calculate `totalRewards` and `mevFee` +- `Lido.TokenRebased`: provides values for `totalPooledEtherBefore/After`, `totalSharesBefore/After`, `shares2mint`, `timeElapsed` +- `Lido.Transfer` / `Lido.TransferShares` pairs (between ETHDistributed and TokenRebased): used to calculate fee distribution to treasury and SR modules + +### LidoSubmission (immutable) + +One per user submission. + +Notable fields: + +- sender +- amount +- referral +- shares / sharesBefore / sharesAfter +- totalPooledEtherBefore / totalPooledEtherAfter +- totalSharesBefore / totalSharesAfter +- balanceAfter + +When updated: + +1. User submits ether + +- `Lido.Submitted`: creates entity, provides `sender`, `amount`, `referral` +- `Lido.TransferShares`: provides `shares` value (parsed from next events in tx) + +Other entities used: + +- `Totals`: read before update, then updated with new amount/shares + - `totalPooledEtherBefore` / `totalPooledEtherAfter` + - `totalSharesBefore` / `totalSharesAfter` +- `Shares`: read sender's current shares + - `sharesBefore` = sender's shares before submission + - `sharesAfter` = sharesBefore + minted shares +- `balanceAfter` calculated as: `sharesAfter * totalPooledEtherAfter / totalSharesAfter` + +### SharesBurn (immutable) + +One per burn event. + +Notable fields: + +- account +- preRebaseTokenAmount +- postRebaseTokenAmount +- sharesAmount + +When updated: + +1. Withdrawal finalization (shares burnt from Burner contract) + +- `Lido.SharesBurnt`: creates entity, provides `account`, `preRebaseTokenAmount`, `postRebaseTokenAmount`, `sharesAmount` + +Side effects: + +- Updates `Totals`: decreases `totalShares` by `sharesAmount` +- Creates `LidoTransfer` entity (from account to 0x0) with shares value from event diff --git a/test/graph/edge-cases.integration.ts b/test/graph/edge-cases.integration.ts deleted file mode 100644 index 9ca18b3b12..0000000000 --- a/test/graph/edge-cases.integration.ts +++ /dev/null @@ -1,694 +0,0 @@ -import { expect } from "chai"; -import { ContractTransactionReceipt, ZeroAddress } from "ethers"; -import { ethers } from "hardhat"; - -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; - -import { advanceChainTime, ether, log, updateBalance } from "lib"; -import { - getProtocolContext, - norSdvtEnsureOperators, - OracleReportParams, - ProtocolContext, - removeStakingLimit, - report, - setStakingLimit, -} from "lib/protocol"; - -import { bailOnFailure, Snapshot } from "test/suite"; - -import { - calcAPR_v2, - calcAPR_v2Extended, - E27_PRECISION_BASE, - GraphSimulator, - MAX_APR_SCALED, - MIN_SHARE_RATE, - SECONDS_PER_YEAR, -} from "./simulator"; -import { captureChainState, capturePoolState, SimulatorInitialState } from "./utils"; - -/** - * Graph Simulator Edge Case Tests - * - * These tests verify correct handling of edge cases: - * - Zero rewards / non-profitable reports - * - Division by zero scenarios - * - Very small/large values for APR precision - * - SharesBurnt handling during withdrawal finalization - * - Totals state validation across multiple transactions - * - * Reference: test/graph/graph-tests-spec.md - */ -describe("Graph Simulator: Edge Cases", () => { - /** - * Unit tests for APR calculation edge cases - * These don't require chain interaction - */ - describe("APR Calculation Edge Cases", () => { - describe("Zero Time Elapsed", () => { - it("Should return 0 APR when timeElapsed is 0", () => { - const apr = calcAPR_v2( - ether("1000000"), // preTotalEther - ether("1001000"), // postTotalEther (0.1% increase) - ether("1000000"), // preTotalShares - ether("1000000"), // postTotalShares - 0n, // timeElapsed = 0 - ); - - expect(apr).to.equal(0); - }); - - it("Should return edge case info via extended function", () => { - const result = calcAPR_v2Extended(ether("1000000"), ether("1001000"), ether("1000000"), ether("1000000"), 0n); - - expect(result.apr).to.equal(0); - expect(result.edgeCase).to.equal("zero_time_elapsed"); - }); - }); - - describe("Zero Shares", () => { - it("Should return 0 APR when preTotalShares is 0", () => { - const apr = calcAPR_v2( - ether("1000000"), - ether("1001000"), - 0n, // preTotalShares = 0 - ether("1000000"), - 86400n, // 1 day - ); - - expect(apr).to.equal(0); - }); - - it("Should return edge case info for zero pre shares", () => { - const result = calcAPR_v2Extended(ether("1000000"), ether("1001000"), 0n, ether("1000000"), 86400n); - - expect(result.apr).to.equal(0); - expect(result.edgeCase).to.equal("zero_pre_shares"); - }); - - it("Should return 0 APR when postTotalShares is 0", () => { - const apr = calcAPR_v2( - ether("1000000"), - ether("1001000"), - ether("1000000"), - 0n, // postTotalShares = 0 - 86400n, - ); - - expect(apr).to.equal(0); - }); - - it("Should return edge case info for zero post shares", () => { - const result = calcAPR_v2Extended(ether("1000000"), ether("1001000"), ether("1000000"), 0n, 86400n); - - expect(result.apr).to.equal(0); - expect(result.edgeCase).to.equal("zero_post_shares"); - }); - }); - - describe("Zero Ether", () => { - it("Should return 0 APR when preTotalEther is 0", () => { - const apr = calcAPR_v2( - 0n, // preTotalEther = 0 - ether("1000"), - ether("1000000"), - ether("1000000"), - 86400n, - ); - - expect(apr).to.equal(0); - }); - - it("Should return edge case info for zero pre ether", () => { - const result = calcAPR_v2Extended(0n, ether("1000"), ether("1000000"), ether("1000000"), 86400n); - - expect(result.apr).to.equal(0); - expect(result.edgeCase).to.equal("zero_pre_ether"); - }); - }); - - describe("Zero Rate Change", () => { - it("Should return 0 APR when share rate is unchanged", () => { - // Same ether and shares = same rate - const apr = calcAPR_v2( - ether("1000000"), // preTotalEther - ether("1000000"), // postTotalEther (same) - ether("1000000"), // preTotalShares - ether("1000000"), // postTotalShares (same) - 86400n, - ); - - expect(apr).to.equal(0); - }); - - it("Should return 0 APR when rates are proportionally the same", () => { - // Double both ether and shares = same rate - const apr = calcAPR_v2( - ether("1000000"), - ether("2000000"), // 2x ether - ether("500000"), - ether("1000000"), // 2x shares (same rate) - 86400n, - ); - - expect(apr).to.equal(0); - }); - - it("Should return edge case info for zero rate change", () => { - const result = calcAPR_v2Extended( - ether("1000000"), - ether("1000000"), - ether("1000000"), - ether("1000000"), - 86400n, - ); - - expect(result.apr).to.equal(0); - expect(result.edgeCase).to.equal("zero_rate_change"); - }); - }); - - describe("Very Small Values", () => { - it("Should handle very small share amounts", () => { - // 1 wei of ether and shares - const apr = calcAPR_v2( - 1n, // 1 wei preTotalEther - 2n, // 2 wei postTotalEther - 1n, // 1 wei preTotalShares - 1n, // 1 wei postTotalShares - SECONDS_PER_YEAR, // 1 year - ); - - // 100% increase over 1 year = 100% APR - expect(apr).to.equal(100); - }); - - it("Should handle share rate at minimum threshold", () => { - // Very small ether relative to shares - const preShareRate = (1n * E27_PRECISION_BASE) / ether("1000000000"); - expect(preShareRate).to.be.lt(MIN_SHARE_RATE); - - const result = calcAPR_v2Extended( - 1n, // very small ether - 2n, - ether("1000000000"), // huge shares - ether("1000000000"), - 86400n, - ); - - expect(result.edgeCase).to.equal("share_rate_too_small"); - }); - }); - - describe("Very Large Values", () => { - it("Should cap extremely large APR to prevent overflow", () => { - // Massive increase in short time - const result = calcAPR_v2Extended( - 1n, // preTotalEther - ether("1000000000000"), // postTotalEther (massive increase) - 1n, // preTotalShares - 1n, // postTotalShares - 1n, // 1 second - ); - - // Should be capped - expect(result.apr).to.equal(Number(MAX_APR_SCALED) / 10000); - expect(result.edgeCase).to.equal("apr_overflow_positive"); - }); - - it("Should handle large but valid APR", () => { - // 100% increase over 1 hour - const apr = calcAPR_v2( - ether("1000000"), - ether("2000000"), // 100% increase - ether("1000000"), - ether("1000000"), - 3600n, // 1 hour - ); - - // APR should be approximately 100% * (365*24) = 876,000% - expect(apr).to.be.gt(800000); - expect(apr).to.be.lt(900000); - }); - }); - - describe("Negative Rate Change (Slashing)", () => { - it("Should calculate negative APR for slashing scenario", () => { - // Post ether less than pre ether (slashing) - const apr = calcAPR_v2( - ether("1000000"), // preTotalEther - ether("990000"), // postTotalEther (1% decrease) - ether("1000000"), // preTotalShares - ether("1000000"), // postTotalShares - SECONDS_PER_YEAR, // 1 year - ); - - // -1% over 1 year = -1% APR - expect(apr).to.equal(-1); - }); - - it("Should handle extreme negative APR", () => { - const result = calcAPR_v2Extended( - ether("1000000000000"), - 1n, // Massive decrease - 1n, - 1n, - 1n, - ); - - expect(result.apr).to.equal(-Number(MAX_APR_SCALED) / 10000); - expect(result.edgeCase).to.equal("apr_overflow_negative"); - }); - }); - - describe("Normal APR Calculation Sanity Checks", () => { - it("Should calculate approximately 5% APR for typical scenario", () => { - // 5% annual yield - const preTotalEther = ether("1000000"); - const postTotalEther = preTotalEther + (preTotalEther * 5n) / 100n; // 5% increase - - const apr = calcAPR_v2( - preTotalEther, - postTotalEther, - ether("1000000"), - ether("1000000"), - SECONDS_PER_YEAR, // 1 year - ); - - // Should be approximately 5% - expect(apr).to.be.approximately(5, 0.001); - }); - - it("Should correctly annualize from 1 day data", () => { - // 0.01% daily = ~3.65% annually - const preTotalEther = ether("1000000"); - const postTotalEther = preTotalEther + (preTotalEther * 1n) / 10000n; // 0.01% increase - - const apr = calcAPR_v2( - preTotalEther, - postTotalEther, - ether("1000000"), - ether("1000000"), - 86400n, // 1 day - ); - - // Should be approximately 365 * 0.01% = 3.65% - expect(apr).to.be.approximately(3.65, 0.01); - }); - }); - }); - - /** - * Integration tests for edge cases requiring chain interaction - */ - describe("Scenario: Non-Profitable Oracle Report", () => { - let ctx: ProtocolContext; - let snapshot: string; - let stEthHolder: HardhatEthersSigner; - let simulator: GraphSimulator; - let initialState: SimulatorInitialState; - - before(async () => { - ctx = await getProtocolContext(); - [stEthHolder] = await ethers.getSigners(); - await updateBalance(stEthHolder.address, ether("100000000")); - - snapshot = await Snapshot.take(); - - initialState = await captureChainState(ctx); - simulator = new GraphSimulator(initialState.treasuryAddress); - - // Initialize simulator with current chain state - simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); - - // Setup protocol - await removeStakingLimit(ctx); - await setStakingLimit(ctx, ether("200000"), ether("20")); - - // Submit some ETH - await ctx.contracts.lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("1000") }); - }); - - after(async () => await Snapshot.restore(snapshot)); - - beforeEach(bailOnFailure); - - it("Should handle zero CL rewards (non-profitable report)", async () => { - log("=== Zero Rewards Test ==="); - - // Execute oracle report with zero CL diff (no rewards) - const reportData: Partial = { - clDiff: 0n, // Zero rewards - }; - - log.info("Executing oracle report with zero CL diff"); - - await advanceChainTime(12n * 60n * 60n); - const { reportTx } = await report(ctx, reportData); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - - const block = await ethers.provider.getBlock(receipt.blockNumber); - const blockTimestamp = BigInt(block!.timestamp); - - // Process through simulator - const result = simulator.processTransaction(receipt, ctx, blockTimestamp); - - log.info("Non-profitable report result", { - "Had Profitable Report": result.hadProfitableReport, - "TotalReward Entities": result.totalRewards.size, - "Totals Updated": result.totalsUpdated, - "Warnings": result.warnings.length, - }); - - // Verify: No TotalReward entity should be created - expect(result.hadProfitableReport).to.be.false; - expect(result.totalRewards.size).to.equal(0); - - // But Totals should still be updated - expect(result.totalsUpdated).to.be.true; - expect(result.totals).to.not.be.null; - - log("Zero rewards test PASSED"); - }); - - it("Should handle negative CL diff (slashing scenario)", async () => { - log("=== Negative Rewards (Slashing) Test ==="); - - // Execute oracle report with negative CL diff - const reportData: Partial = { - clDiff: -ether("0.001"), // Small loss due to slashing - }; - - log.info("Executing oracle report with negative CL diff"); - - await advanceChainTime(12n * 60n * 60n); - const { reportTx } = await report(ctx, reportData); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - - const block = await ethers.provider.getBlock(receipt.blockNumber); - const blockTimestamp = BigInt(block!.timestamp); - - // Process through simulator - const result = simulator.processTransaction(receipt, ctx, blockTimestamp); - - log.info("Negative rewards result", { - "Had Profitable Report": result.hadProfitableReport, - "TotalReward Entities": result.totalRewards.size, - "Totals Updated": result.totalsUpdated, - }); - - // Verify: No TotalReward entity (non-profitable) - expect(result.hadProfitableReport).to.be.false; - expect(result.totalRewards.size).to.equal(0); - - // Totals should still be updated - expect(result.totalsUpdated).to.be.true; - - log("Negative rewards test PASSED"); - }); - }); - - describe("Scenario: Totals State Validation", () => { - let ctx: ProtocolContext; - let snapshot: string; - let stEthHolder: HardhatEthersSigner; - let simulator: GraphSimulator; - let initialState: SimulatorInitialState; - let depositCount: bigint; - - before(async () => { - ctx = await getProtocolContext(); - [stEthHolder] = await ethers.getSigners(); - await updateBalance(stEthHolder.address, ether("100000000")); - - snapshot = await Snapshot.take(); - - initialState = await captureChainState(ctx); - simulator = new GraphSimulator(initialState.treasuryAddress); - - // Initialize simulator with current chain state - simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); - - // Setup protocol - await removeStakingLimit(ctx); - await setStakingLimit(ctx, ether("200000"), ether("20")); - - // Ensure operators exist - await norSdvtEnsureOperators(ctx, ctx.contracts.nor, 3n, 5n); - - // Submit ETH - await ctx.contracts.lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("1600") }); - - // Make deposits - const { impersonate, ether: etherFn } = await import("lib"); - const dsmSigner = await impersonate(ctx.contracts.depositSecurityModule.address, etherFn("100")); - const depositTx = await ctx.contracts.lido.connect(dsmSigner).deposit(50n, 1n, new Uint8Array(32)); - const depositReceipt = (await depositTx.wait()) as ContractTransactionReceipt; - const unbufferedEvent = ctx.getEvents(depositReceipt, "Unbuffered")[0]; - const unbufferedAmount = unbufferedEvent?.args[0] || 0n; - depositCount = unbufferedAmount / ether("32"); - }); - - after(async () => await Snapshot.restore(snapshot)); - - beforeEach(bailOnFailure); - - it("Should validate Totals consistency across multiple reports", async () => { - log("=== Multi-Transaction Totals Validation ==="); - - // First report - const clDiff1 = ether("32") * depositCount + ether("0.001"); - await advanceChainTime(12n * 60n * 60n); - const { reportTx: reportTx1 } = await report(ctx, { clDiff: clDiff1, clAppearedValidators: depositCount }); - const receipt1 = (await reportTx1!.wait()) as ContractTransactionReceipt; - const block1 = await ethers.provider.getBlock(receipt1.blockNumber); - - const result1 = simulator.processTransaction(receipt1, ctx, BigInt(block1!.timestamp)); - - log.info("First report result", { - "Totals Updated": result1.totalsUpdated, - "Warnings": result1.warnings.length, - }); - - // Check for no state mismatch warnings (initialized correctly) - const stateMismatchWarnings1 = result1.warnings.filter((w) => w.type === "totals_state_mismatch"); - expect(stateMismatchWarnings1.length).to.equal(0, "Should have no state mismatch on first report"); - - // Get state after first report - const stateAfter1 = await capturePoolState(ctx); - const totalsAfter1 = simulator.getTotals(); - - // Verify simulator Totals match on-chain state - expect(totalsAfter1!.totalPooledEther).to.equal( - stateAfter1.totalPooledEther, - "Totals.totalPooledEther should match chain", - ); - expect(totalsAfter1!.totalShares).to.equal(stateAfter1.totalShares, "Totals.totalShares should match chain"); - - // Second report (simulator state should persist) - const clDiff2 = ether("0.002"); - await advanceChainTime(12n * 60n * 60n); - const { reportTx: reportTx2 } = await report(ctx, { clDiff: clDiff2 }); - const receipt2 = (await reportTx2!.wait()) as ContractTransactionReceipt; - const block2 = await ethers.provider.getBlock(receipt2.blockNumber); - - const result2 = simulator.processTransaction(receipt2, ctx, BigInt(block2!.timestamp)); - - log.info("Second report result", { - "Totals Updated": result2.totalsUpdated, - "Warnings": result2.warnings.length, - }); - - // Check for state consistency (should have no warnings since state was carried over) - const stateMismatchWarnings2 = result2.warnings.filter((w) => w.type === "totals_state_mismatch"); - expect(stateMismatchWarnings2.length).to.equal(0, "Should have no state mismatch on second report"); - - // Final verification - const stateAfter2 = await capturePoolState(ctx); - const totalsAfter2 = simulator.getTotals(); - - expect(totalsAfter2!.totalPooledEther).to.equal(stateAfter2.totalPooledEther); - expect(totalsAfter2!.totalShares).to.equal(stateAfter2.totalShares); - - log("Multi-transaction Totals validation PASSED"); - }); - - it("Should detect Totals state mismatch when not initialized", async () => { - log("=== Totals Mismatch Detection ==="); - - // Create a fresh simulator WITHOUT initializing Totals - const freshSimulator = new GraphSimulator(initialState.treasuryAddress); - // Deliberately initialize with wrong values - freshSimulator.initializeTotals(1n, 1n); // Wrong values - - // Execute a report - const clDiff = ether("0.001"); - await advanceChainTime(12n * 60n * 60n); - const { reportTx } = await report(ctx, { clDiff }); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - const block = await ethers.provider.getBlock(receipt.blockNumber); - - const result = freshSimulator.processTransaction(receipt, ctx, BigInt(block!.timestamp)); - - log.info("Mismatch detection result", { - "Warnings Count": result.warnings.length, - "Warnings": result.warnings.map((w) => w.type).join(", "), - }); - - // Should have state mismatch warnings - const stateMismatchWarnings = result.warnings.filter((w) => w.type === "totals_state_mismatch"); - expect(stateMismatchWarnings.length).to.be.gt(0, "Should detect state mismatch"); - - log("Totals mismatch detection PASSED"); - }); - }); - - describe("shares2mint Validation", () => { - let ctx: ProtocolContext; - let snapshot: string; - let stEthHolder: HardhatEthersSigner; - let simulator: GraphSimulator; - let initialState: SimulatorInitialState; - let depositCount: bigint; - - before(async () => { - ctx = await getProtocolContext(); - [stEthHolder] = await ethers.getSigners(); - await updateBalance(stEthHolder.address, ether("100000000")); - - snapshot = await Snapshot.take(); - - initialState = await captureChainState(ctx); - simulator = new GraphSimulator(initialState.treasuryAddress); - simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); - - // Setup - await removeStakingLimit(ctx); - await setStakingLimit(ctx, ether("200000"), ether("20")); - await norSdvtEnsureOperators(ctx, ctx.contracts.nor, 3n, 5n); - await ctx.contracts.lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("1600") }); - - // Deposits - const { impersonate, ether: etherFn } = await import("lib"); - const dsmSigner = await impersonate(ctx.contracts.depositSecurityModule.address, etherFn("100")); - const depositTx = await ctx.contracts.lido.connect(dsmSigner).deposit(50n, 1n, new Uint8Array(32)); - const depositReceipt = (await depositTx.wait()) as ContractTransactionReceipt; - const unbufferedEvent = ctx.getEvents(depositReceipt, "Unbuffered")[0]; - depositCount = (unbufferedEvent?.args[0] || 0n) / ether("32"); - }); - - after(async () => await Snapshot.restore(snapshot)); - - beforeEach(bailOnFailure); - - it("Should validate shares2mint matches actual minted shares", async () => { - log("=== shares2mint Validation Test ==="); - - const clDiff = ether("32") * depositCount + ether("0.001"); - await advanceChainTime(12n * 60n * 60n); - const { reportTx } = await report(ctx, { clDiff, clAppearedValidators: depositCount }); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - const block = await ethers.provider.getBlock(receipt.blockNumber); - - const result = simulator.processTransaction(receipt, ctx, BigInt(block!.timestamp)); - - // Check for shares2mint validation warnings - const shares2mintWarnings = result.warnings.filter((w) => w.type === "shares2mint_mismatch"); - - log.info("shares2mint validation result", { - "Had Profitable Report": result.hadProfitableReport, - "shares2mint Warnings": shares2mintWarnings.length, - }); - - // In a correctly functioning protocol, there should be no mismatch - expect(shares2mintWarnings.length).to.equal(0, "shares2mint should match minted shares"); - - if (result.hadProfitableReport) { - const entity = result.totalRewards.values().next().value; - if (entity) { - // Verify the sanity check relationship - const totalSharesMinted = entity.sharesToTreasury + entity.sharesToOperators; - expect(entity.shares2mint).to.equal(totalSharesMinted, "shares2mint consistency check"); - - log.info("shares2mint details", { - "shares2mint (from event)": entity.shares2mint.toString(), - "sharesToTreasury": entity.sharesToTreasury.toString(), - "sharesToOperators": entity.sharesToOperators.toString(), - "Sum": totalSharesMinted.toString(), - }); - } - } - - log("shares2mint validation PASSED"); - }); - }); - - describe("Very Small Reward Precision", () => { - let ctx: ProtocolContext; - let snapshot: string; - let stEthHolder: HardhatEthersSigner; - let simulator: GraphSimulator; - let initialState: SimulatorInitialState; - - before(async () => { - ctx = await getProtocolContext(); - [stEthHolder] = await ethers.getSigners(); - await updateBalance(stEthHolder.address, ether("100000000")); - - snapshot = await Snapshot.take(); - - initialState = await captureChainState(ctx); - simulator = new GraphSimulator(initialState.treasuryAddress); - simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); - - await removeStakingLimit(ctx); - await setStakingLimit(ctx, ether("200000"), ether("20")); - await ctx.contracts.lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("1000") }); - }); - - after(async () => await Snapshot.restore(snapshot)); - - beforeEach(bailOnFailure); - - it("Should handle very small rewards (1 wei)", async () => { - log("=== Very Small Rewards Test ==="); - - // Very small but positive reward - const clDiff = 1n; // 1 wei - - await advanceChainTime(12n * 60n * 60n); - const { reportTx } = await report(ctx, { clDiff }); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - const block = await ethers.provider.getBlock(receipt.blockNumber); - - const result = simulator.processTransaction(receipt, ctx, BigInt(block!.timestamp)); - - log.info("Very small rewards result", { - "Had Profitable Report": result.hadProfitableReport, - "TotalReward Entities": result.totalRewards.size, - }); - - // Even 1 wei should be considered profitable (postCL > preCL) - expect(result.hadProfitableReport).to.be.true; - - if (result.hadProfitableReport) { - const entity = result.totalRewards.values().next().value; - if (entity) { - log.info("Small reward entity details", { - "Total Rewards With Fees": entity.totalRewardsWithFees.toString(), - "APR": entity.apr.toString(), - }); - - // Total rewards should be very small - expect(entity.totalRewardsWithFees).to.be.gt(0n); - - // APR calculation should still work (no division by zero) - expect(Number.isFinite(entity.apr)).to.be.true; - } - } - - log("Very small rewards test PASSED"); - }); - }); -}); diff --git a/test/graph/entities-scenario.integration.ts b/test/graph/entities-scenario.integration.ts new file mode 100644 index 0000000000..93eb65319c --- /dev/null +++ b/test/graph/entities-scenario.integration.ts @@ -0,0 +1,891 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, formatEther, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Dashboard, StakingVault } from "typechain-types"; + +import { advanceChainTime, ether, log, mEqual, updateBalance } from "lib"; +import { + createVaultWithDashboard, + finalizeWQViaElVault, + getProtocolContext, + norSdvtEnsureOperators, + OracleReportParams, + ProtocolContext, + removeStakingLimit, + report, + reportVaultDataWithProof, + setStakingLimit, + setupLidoForVaults, +} from "lib/protocol"; + +import { bailOnFailure, Snapshot } from "test/suite"; + +import { deriveExpectedTotalReward, GraphSimulator, makeLidoSubmissionId, makeLidoTransferId } from "./simulator"; +import { captureChainState, capturePoolState, SimulatorInitialState } from "./utils"; +import { extractAllLogs } from "./utils/event-extraction"; + +const INTERVAL_12_HOURS = 12n * 60n * 60n; + +/** + * Comprehensive Graph Entity Integration Test Scenario + * + * Tests all entity types with interleaved actions: + * - Deposits (submits): 6+ + * - Transfers: 5+ + * - Oracle reports: 7 (profitable, zero, negative, MEV-heavy) + * - Withdrawal requests + finalizations: 5+ + * - V3 external shares mint: 5+ + * - V3 external shares burn: 5+ + * + * Reference: test/graph/INTRO.md + */ +describe("Comprehensive Mixed Scenario", () => { + let ctx: ProtocolContext; + let snapshot: string; + + // Users + let user1: HardhatEthersSigner; + let user2: HardhatEthersSigner; + let user3: HardhatEthersSigner; + let user4: HardhatEthersSigner; + let user5: HardhatEthersSigner; + + // V3 Vaults + let vault1: StakingVault; + let vault2: StakingVault; + let dashboard1: Dashboard; + let dashboard2: Dashboard; + + // Simulator + let simulator: GraphSimulator; + let initialState: SimulatorInitialState; + + // Counters for statistics + let depositCount = 0; + let transferCount = 0; + let reportCount = 0; + let profitableReportCount = 0; + let withdrawalRequestCount = 0; + let v3MintCount = 0; + let v3BurnCount = 0; + + // Track pending withdrawal request IDs + const pendingWithdrawalRequestIds: bigint[] = []; + + before(async () => { + ctx = await getProtocolContext(); + + // Get signers for 5 users + const signers = await ethers.getSigners(); + [user1, user2, user3, user4, user5] = signers.slice(0, 5); + + // Fund all users + for (const user of [user1, user2, user3, user4, user5]) { + await updateBalance(user.address, ether("10000000")); + } + + snapshot = await Snapshot.take(); + + // Setup protocol state FIRST (before initializing simulator) + await removeStakingLimit(ctx); + await setStakingLimit(ctx, ether("500000"), ether("50")); + + // Ensure node operators exist (for fee distribution) + await norSdvtEnsureOperators(ctx, ctx.contracts.nor, 3n, 5n); + await norSdvtEnsureOperators(ctx, ctx.contracts.sdvt, 3n, 5n); + + // Setup Lido for vaults (V3) - this calls report(ctx) internally + await setupLidoForVaults(ctx); + + // Create 2 vaults with dashboards for user3 + const vaultResult1 = await createVaultWithDashboard(ctx, ctx.contracts.stakingVaultFactory, user3, user3, user3); + vault1 = vaultResult1.stakingVault; + dashboard1 = vaultResult1.dashboard.connect(user3); + + const vaultResult2 = await createVaultWithDashboard(ctx, ctx.contracts.stakingVaultFactory, user3, user3, user3); + vault2 = vaultResult2.stakingVault; + dashboard2 = vaultResult2.dashboard.connect(user3); + + // Fund both vaults + await dashboard1.fund({ value: ether("500") }); + await dashboard2.fund({ value: ether("500") }); + + // Finalize any pending withdrawals + await finalizeWQViaElVault(ctx); + + // NOW capture chain state and initialize simulator AFTER all setup is done + initialState = await captureChainState(ctx); + simulator = new GraphSimulator(initialState.treasuryAddress); + simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); + + log.info("Setup complete", { + "Vault1": await vault1.getAddress(), + "Vault2": await vault2.getAddress(), + "Total Pooled Ether": formatEther(initialState.totalPooledEther), + "Total Shares": initialState.totalShares.toString(), + }); + }); + + after(async () => await Snapshot.restore(snapshot)); + + beforeEach(bailOnFailure); + + // ============================================================================ + // Helper Functions + // ============================================================================ + + async function processAndValidate(receipt: ContractTransactionReceipt, description: string) { + const block = await ethers.provider.getBlock(receipt.blockNumber); + const blockTimestamp = BigInt(block!.timestamp); + + // Use processTransactionWithV3 to handle V3 events (ExternalSharesMinted, ExternalSharesBurnt) + // which require async contract reads to sync totalPooledEther with the chain + const result = await simulator.processTransactionWithV3(receipt, ctx, blockTimestamp); + + log.debug(`Processed: ${description}`, { + "Block": receipt.blockNumber, + "Totals Updated": result.totalsUpdated, + "Submissions": result.lidoSubmissions.size, + "Transfers": result.lidoTransfers.size, + "TotalRewards": result.totalRewards.size, + "SharesBurns": result.sharesBurns.size, + "Warnings": result.warnings.length, + }); + + return result; + } + + async function validateSubmission( + receipt: ContractTransactionReceipt, + expectedSender: string, + expectedAmount: bigint, + expectedReferral: string = ZeroAddress, + ) { + const logs = extractAllLogs(receipt, ctx); + const submittedEvent = logs.find((l) => l.name === "Submitted"); + expect(submittedEvent, "Submitted event not found").to.not.be.undefined; + + const submissionId = makeLidoSubmissionId(receipt.hash, submittedEvent!.logIndex); + const submission = simulator.getLidoSubmission(submissionId); + + expect(submission, "LidoSubmission entity not found").to.not.be.undefined; + expect(submission!.sender.toLowerCase()).to.equal(expectedSender.toLowerCase()); + expect(submission!.amount).to.equal(expectedAmount); + expect(submission!.referral.toLowerCase()).to.equal(expectedReferral.toLowerCase()); + expect(submission!.shares).to.be.gt(0n); + + return submission!; + } + + async function validateTransfer(receipt: ContractTransactionReceipt, expectedFrom: string, expectedTo: string) { + const logs = extractAllLogs(receipt, ctx); + const transferEvent = logs.find( + (l) => + l.name === "Transfer" && + l.args?.from?.toLowerCase() === expectedFrom.toLowerCase() && + l.args?.to?.toLowerCase() === expectedTo.toLowerCase(), + ); + expect(transferEvent, "Transfer event not found").to.not.be.undefined; + + const transferId = makeLidoTransferId(receipt.hash, transferEvent!.logIndex); + const transfer = simulator.getLidoTransfer(transferId); + + expect(transfer, "LidoTransfer entity not found").to.not.be.undefined; + expect(transfer!.from.toLowerCase()).to.equal(expectedFrom.toLowerCase()); + expect(transfer!.to.toLowerCase()).to.equal(expectedTo.toLowerCase()); + expect(transfer!.shares).to.be.gt(0n); + + // Validate share balance changes + expect(transfer!.sharesBeforeDecrease - transfer!.sharesAfterDecrease).to.equal(transfer!.shares); + expect(transfer!.sharesAfterIncrease - transfer!.sharesBeforeIncrease).to.equal(transfer!.shares); + + return transfer!; + } + + async function validateOracleReport(receipt: ContractTransactionReceipt, expectProfitable: boolean) { + const block = await ethers.provider.getBlock(receipt.blockNumber); + const blockTimestamp = BigInt(block!.timestamp); + const result = simulator.processTransaction(receipt, ctx, blockTimestamp); + + if (expectProfitable) { + expect(result.hadProfitableReport, "Expected profitable report").to.be.true; + expect(result.totalRewards.size).to.be.gte(1); + + const computed = result.totalRewards.get(receipt.hash); + expect(computed, "TotalReward entity not found").to.not.be.undefined; + + // Derive expected values from events + const expected = deriveExpectedTotalReward(receipt, ctx, initialState.treasuryAddress); + expect(expected, "Failed to derive expected TotalReward from events").to.not.be.null; + + // Field-by-field validation against expected values + await mEqual([ + // Identity fields + [computed!.id.toLowerCase(), receipt.hash.toLowerCase()], + [computed!.block, BigInt(receipt.blockNumber)], + [computed!.blockTime, blockTimestamp], + [computed!.transactionHash.toLowerCase(), receipt.hash.toLowerCase()], + [computed!.transactionIndex, BigInt(receipt.index)], + [computed!.logIndex, expected!.logIndex], + // Pool state before/after + [computed!.totalPooledEtherBefore, expected!.totalPooledEtherBefore], + [computed!.totalPooledEtherAfter, expected!.totalPooledEtherAfter], + [computed!.totalSharesBefore, expected!.totalSharesBefore], + [computed!.totalSharesAfter, expected!.totalSharesAfter], + // Reward distribution + [computed!.shares2mint, expected!.shares2mint], + [computed!.timeElapsed, expected!.timeElapsed], + [computed!.mevFee, expected!.mevFee], + [computed!.totalRewardsWithFees, expected!.totalRewardsWithFees], + [computed!.totalRewards, expected!.totalRewards], + [computed!.totalFee, expected!.totalFee], + [computed!.treasuryFee, expected!.treasuryFee], + [computed!.operatorsFee, expected!.operatorsFee], + [computed!.sharesToTreasury, expected!.sharesToTreasury], + [computed!.sharesToOperators, expected!.sharesToOperators], + // APR fields + [computed!.apr, expected!.apr], + [computed!.aprRaw, expected!.aprRaw], + [computed!.aprBeforeFees, expected!.aprBeforeFees], + // Fee basis points + [computed!.feeBasis, expected!.feeBasis], + [computed!.treasuryFeeBasisPoints, expected!.treasuryFeeBasisPoints], + [computed!.operatorsFeeBasisPoints, expected!.operatorsFeeBasisPoints], + // Internal consistency checks + [computed!.shares2mint, computed!.sharesToTreasury + computed!.sharesToOperators], + [computed!.totalFee, computed!.treasuryFee + computed!.operatorsFee], + ]); + + profitableReportCount++; + return computed!; + } else { + expect(result.hadProfitableReport, "Expected non-profitable report").to.be.false; + expect(result.totalRewards.size).to.equal(0); + + // Totals should still be updated + expect(result.totalsUpdated).to.be.true; + return null; + } + } + + async function validateGlobalConsistency() { + const totals = simulator.getTotals(); + expect(totals, "Totals entity should exist").to.not.be.null; + + // Verify against on-chain state + const poolState = await capturePoolState(ctx); + expect(totals!.totalPooledEther).to.equal(poolState.totalPooledEther, "Totals.totalPooledEther should match chain"); + expect(totals!.totalShares).to.equal(poolState.totalShares, "Totals.totalShares should match chain"); + + log.debug("Global consistency check passed", { + "Total Pooled Ether": formatEther(totals!.totalPooledEther), + "Total Shares": totals!.totalShares.toString(), + }); + } + + // ============================================================================ + // Phase 1: Initial Deposits & First Report + // ============================================================================ + + describe("Phase 1: Initial Deposits & First Report", () => { + it("Action 1: user1 deposits 100 ETH (no referral)", async () => { + const { lido } = ctx.contracts; + const amount = ether("100"); + + const tx = await lido.connect(user1).submit(ZeroAddress, { value: amount }); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "user1 deposit 100 ETH"); + await validateSubmission(receipt, user1.address, amount, ZeroAddress); + + depositCount++; + }); + + it("Action 2: user2 deposits 50 ETH (with referral = user1)", async () => { + const { lido } = ctx.contracts; + const amount = ether("50"); + + const tx = await lido.connect(user2).submit(user1.address, { value: amount }); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "user2 deposit 50 ETH with referral"); + await validateSubmission(receipt, user2.address, amount, user1.address); + + depositCount++; + }); + + it("Action 3: Oracle report #1 - normal profitable", async () => { + await advanceChainTime(INTERVAL_12_HOURS); + + const reportData: Partial = { + clDiff: ether("0.01"), + }; + + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + await validateOracleReport(receipt, true); + await validateGlobalConsistency(); + + reportCount++; + }); + + it("Action 4: user3 deposits 200 ETH (large deposit)", async () => { + const { lido } = ctx.contracts; + const amount = ether("200"); + + const tx = await lido.connect(user3).submit(ZeroAddress, { value: amount }); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "user3 deposit 200 ETH"); + await validateSubmission(receipt, user3.address, amount); + + depositCount++; + }); + + it("Action 5: Transfer user1 -> user2, 10 ETH", async () => { + const { lido } = ctx.contracts; + const amount = ether("10"); + + const tx = await lido.connect(user1).transfer(user2.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Transfer user1 -> user2"); + await validateTransfer(receipt, user1.address, user2.address); + + transferCount++; + }); + }); + + // ============================================================================ + // Phase 2: V3 Vault Actions + Reports + // ============================================================================ + + describe("Phase 2: V3 Vault Actions + Reports", () => { + it("Action 6: Vault1 mint external shares (50 stETH to user3)", async () => { + const amount = ether("50"); + + // Report vault data to make it fresh and process through simulator + const vaultReportTx = await reportVaultDataWithProof(ctx, vault1); + const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; + await processAndValidate(vaultReportReceipt, "Vault1 report"); + + const tx = await dashboard1.mintStETH(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Vault1 mint 50 stETH"); + + // Verify ExternalSharesMinted event + const logs = extractAllLogs(receipt, ctx); + const extMintEvent = logs.find((l) => l.name === "ExternalSharesMinted"); + expect(extMintEvent, "ExternalSharesMinted event not found").to.not.be.undefined; + + // Verify shares were minted (event args) + expect(extMintEvent!.args!["amountOfShares"]).to.be.gt(0n); + + v3MintCount++; + }); + + it("Action 7: Oracle report #2 - small profitable", async () => { + await advanceChainTime(INTERVAL_12_HOURS); + + const reportData: Partial = { + clDiff: ether("0.001"), + }; + + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + await validateOracleReport(receipt, true); + await validateGlobalConsistency(); + + reportCount++; + }); + + it("Action 8: Vault2 mint external shares (30 stETH to user3)", async () => { + const amount = ether("30"); + + // Report vault data to make it fresh and process through simulator + const vaultReportTx = await reportVaultDataWithProof(ctx, vault2); + const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; + await processAndValidate(vaultReportReceipt, "Vault2 report"); + + const tx = await dashboard2.mintStETH(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Vault2 mint 30 stETH"); + + const logs = extractAllLogs(receipt, ctx); + const extMintEvent = logs.find((l) => l.name === "ExternalSharesMinted"); + expect(extMintEvent, "ExternalSharesMinted event not found").to.not.be.undefined; + + v3MintCount++; + }); + + it("Action 9: user4 deposits 25 ETH", async () => { + const { lido } = ctx.contracts; + const amount = ether("25"); + + const tx = await lido.connect(user4).submit(ZeroAddress, { value: amount }); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "user4 deposit 25 ETH"); + await validateSubmission(receipt, user4.address, amount); + + depositCount++; + }); + + it("Action 10: Vault1 burn external shares (20 stETH)", async () => { + const { lido } = ctx.contracts; + const amount = ether("20"); + + // Report vault data to make it fresh and process through simulator + const vaultReportTx = await reportVaultDataWithProof(ctx, vault1); + const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; + await processAndValidate(vaultReportReceipt, "Vault1 report"); + + // Approve stETH for burning + await lido.connect(user3).approve(await dashboard1.getAddress(), amount); + + const tx = await dashboard1.burnStETH(amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Vault1 burn 20 stETH"); + + // Verify SharesBurnt event + const logs = extractAllLogs(receipt, ctx); + const sharesBurntEvent = logs.find((l) => l.name === "SharesBurnt"); + expect(sharesBurntEvent, "SharesBurnt event not found").to.not.be.undefined; + + v3BurnCount++; + }); + }); + + // ============================================================================ + // Phase 3: Withdrawal Flow + // ============================================================================ + + describe("Phase 3: Withdrawal Flow", () => { + it("Action 11: user1 requests withdrawal (30 ETH)", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const amount = ether("30"); + + await lido.connect(user1).approve(await withdrawalQueue.getAddress(), amount); + const tx = await withdrawalQueue.connect(user1).requestWithdrawals([amount], user1.address); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; + const requestId = withdrawalRequestedEvent?.args?.requestId; + expect(requestId).to.not.be.undefined; + + pendingWithdrawalRequestIds.push(requestId); + withdrawalRequestCount++; + + log.debug("Withdrawal request created", { requestId: requestId.toString() }); + }); + + it("Action 12: user2 requests withdrawal (20 ETH)", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const amount = ether("20"); + + await lido.connect(user2).approve(await withdrawalQueue.getAddress(), amount); + const tx = await withdrawalQueue.connect(user2).requestWithdrawals([amount], user2.address); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; + const requestId = withdrawalRequestedEvent?.args?.requestId; + expect(requestId).to.not.be.undefined; + + pendingWithdrawalRequestIds.push(requestId); + withdrawalRequestCount++; + + log.debug("Withdrawal request created", { requestId: requestId.toString() }); + }); + + it("Action 13: Oracle report #3 - profitable + finalizes withdrawals", async () => { + await advanceChainTime(INTERVAL_12_HOURS); + + const reportData: Partial = { + clDiff: ether("0.01"), + }; + + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + // Check for SharesBurnt events (withdrawal finalization) + const logs = extractAllLogs(receipt, ctx); + const sharesBurntEvents = logs.filter((l) => l.name === "SharesBurnt"); + + log.debug("Oracle report with withdrawals", { + "SharesBurnt events": sharesBurntEvents.length, + }); + + await validateOracleReport(receipt, true); + await validateGlobalConsistency(); + + reportCount++; + }); + + it("Action 14: user5 deposits 500 ETH (large)", async () => { + const { lido } = ctx.contracts; + const amount = ether("500"); + + const tx = await lido.connect(user5).submit(ZeroAddress, { value: amount }); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "user5 deposit 500 ETH"); + await validateSubmission(receipt, user5.address, amount); + + depositCount++; + }); + + it("Action 15: Transfer user3 -> user4, partial balance", async () => { + const { lido } = ctx.contracts; + const amount = ether("50"); + + const tx = await lido.connect(user3).transfer(user4.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Transfer user3 -> user4"); + await validateTransfer(receipt, user3.address, user4.address); + + transferCount++; + }); + }); + + // ============================================================================ + // Phase 4: Edge Case Reports + // ============================================================================ + + describe("Phase 4: Edge Case Reports", () => { + it("Action 16: Oracle report #4 - zero rewards (non-profitable)", async () => { + await advanceChainTime(INTERVAL_12_HOURS); + + const reportData: Partial = { + clDiff: 0n, + }; + + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + // Should NOT create TotalReward entity + await validateOracleReport(receipt, false); + await validateGlobalConsistency(); + + reportCount++; + }); + + it("Action 17: Vault2 mint external shares (100 stETH)", async () => { + const amount = ether("100"); + + // Report vault data to make it fresh and process through simulator + const vaultReportTx = await reportVaultDataWithProof(ctx, vault2); + const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; + await processAndValidate(vaultReportReceipt, "Vault2 report"); + + const tx = await dashboard2.mintStETH(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Vault2 mint 100 stETH"); + + v3MintCount++; + }); + + it("Action 18: user1 requests withdrawal (50 ETH)", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const amount = ether("50"); + + await lido.connect(user1).approve(await withdrawalQueue.getAddress(), amount); + const tx = await withdrawalQueue.connect(user1).requestWithdrawals([amount], user1.address); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; + const requestId = withdrawalRequestedEvent?.args?.requestId; + expect(requestId).to.not.be.undefined; + + pendingWithdrawalRequestIds.push(requestId); + withdrawalRequestCount++; + }); + + it("Action 19: Oracle report #5 - negative rewards (slashing scenario)", async () => { + await advanceChainTime(INTERVAL_12_HOURS); + + const reportData: Partial = { + clDiff: -ether("0.0001"), + }; + + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + // Should NOT create TotalReward entity (negative/slashing) + await validateOracleReport(receipt, false); + await validateGlobalConsistency(); + + reportCount++; + }); + + it("Action 20: Transfer user4 -> user1, full balance", async () => { + const { lido } = ctx.contracts; + const balance = await lido.balanceOf(user4.address); + + // Transfer almost full balance (leave 1 wei to avoid edge cases) + const amount = balance - 1n; + expect(amount).to.be.gt(0n); + + const tx = await lido.connect(user4).transfer(user1.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Transfer user4 -> user1 (near full balance)"); + await validateTransfer(receipt, user4.address, user1.address); + + transferCount++; + }); + }); + + // ============================================================================ + // Phase 5: More V3 + Withdrawals + // ============================================================================ + + describe("Phase 5: More V3 + Withdrawals", () => { + it("Action 21: Vault1 mint external shares (75 stETH)", async () => { + const amount = ether("75"); + + // Report vault data to make it fresh and process through simulator + const vaultReportTx = await reportVaultDataWithProof(ctx, vault1); + const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; + await processAndValidate(vaultReportReceipt, "Vault1 report"); + + const tx = await dashboard1.mintStETH(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Vault1 mint 75 stETH"); + + v3MintCount++; + }); + + it("Action 22: user2 deposits 80 ETH", async () => { + const { lido } = ctx.contracts; + const amount = ether("80"); + + const tx = await lido.connect(user2).submit(ZeroAddress, { value: amount }); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "user2 deposit 80 ETH"); + await validateSubmission(receipt, user2.address, amount); + + depositCount++; + }); + + it("Action 23: Vault2 burn external shares (50 stETH)", async () => { + const { lido } = ctx.contracts; + const amount = ether("50"); + + // Report vault data to make it fresh and process through simulator + const vaultReportTx = await reportVaultDataWithProof(ctx, vault2); + const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; + await processAndValidate(vaultReportReceipt, "Vault2 report"); + + await lido.connect(user3).approve(await dashboard2.getAddress(), amount); + + const tx = await dashboard2.burnStETH(amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Vault2 burn 50 stETH"); + + v3BurnCount++; + }); + + it("Action 24: user3 requests withdrawal (40 ETH)", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const amount = ether("40"); + + await lido.connect(user3).approve(await withdrawalQueue.getAddress(), amount); + const tx = await withdrawalQueue.connect(user3).requestWithdrawals([amount], user3.address); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; + const requestId = withdrawalRequestedEvent?.args?.requestId; + expect(requestId).to.not.be.undefined; + + pendingWithdrawalRequestIds.push(requestId); + withdrawalRequestCount++; + }); + + it("Action 25: Oracle report #6 - profitable, finalizes batch", async () => { + await advanceChainTime(INTERVAL_12_HOURS); + + const reportData: Partial = { + clDiff: ether("0.005"), + }; + + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + const logs = extractAllLogs(receipt, ctx); + const sharesBurntEvents = logs.filter((l) => l.name === "SharesBurnt"); + + log.debug("Oracle report #6 with batch finalization", { + "SharesBurnt events": sharesBurntEvents.length, + }); + + await validateOracleReport(receipt, true); + await validateGlobalConsistency(); + + reportCount++; + }); + }); + + // ============================================================================ + // Phase 6: Final Mixed Actions + Summary + // ============================================================================ + + describe("Phase 6: Final Mixed Actions + Summary", () => { + it("Action 26: user1 deposits 30 ETH (with referral = user5)", async () => { + const { lido } = ctx.contracts; + const amount = ether("30"); + + const tx = await lido.connect(user1).submit(user5.address, { value: amount }); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "user1 deposit 30 ETH with referral"); + await validateSubmission(receipt, user1.address, amount, user5.address); + + depositCount++; + }); + + it("Action 27: Transfer user1 -> user3, 15 ETH", async () => { + const { lido } = ctx.contracts; + const amount = ether("15"); + + const tx = await lido.connect(user1).transfer(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Transfer user1 -> user3"); + await validateTransfer(receipt, user1.address, user3.address); + + transferCount++; + }); + + it("Action 28: Vault1 burn external shares (30 stETH)", async () => { + const { lido } = ctx.contracts; + const amount = ether("30"); + + // Report vault data to make it fresh and process through simulator + const vaultReportTx = await reportVaultDataWithProof(ctx, vault1); + const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; + await processAndValidate(vaultReportReceipt, "Vault1 report"); + + await lido.connect(user3).approve(await dashboard1.getAddress(), amount); + + const tx = await dashboard1.burnStETH(amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Vault1 burn 30 stETH"); + + v3BurnCount++; + }); + + it("Action 29: user5 requests withdrawal (100 ETH)", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const amount = ether("100"); + + await lido.connect(user5).approve(await withdrawalQueue.getAddress(), amount); + const tx = await withdrawalQueue.connect(user5).requestWithdrawals([amount], user5.address); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; + const requestId = withdrawalRequestedEvent?.args?.requestId; + expect(requestId).to.not.be.undefined; + + pendingWithdrawalRequestIds.push(requestId); + withdrawalRequestCount++; + }); + + it("Action 30: Oracle report #7 - profitable with MEV", async () => { + await advanceChainTime(INTERVAL_12_HOURS); + + const reportData: Partial = { + clDiff: ether("0.002"), + }; + + const { reportTx } = await report(ctx, reportData); + const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; + + await validateOracleReport(receipt, true); + await validateGlobalConsistency(); + + reportCount++; + }); + + it("Action 31: Transfer user2 -> user5, 25 ETH", async () => { + const { lido } = ctx.contracts; + const amount = ether("25"); + + const tx = await lido.connect(user2).transfer(user5.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Transfer user2 -> user5"); + await validateTransfer(receipt, user2.address, user5.address); + + transferCount++; + }); + + it("Action 32: Vault2 burn external shares (30 stETH)", async () => { + const { lido } = ctx.contracts; + const amount = ether("30"); + + // Report vault data to make it fresh and process through simulator + const vaultReportTx = await reportVaultDataWithProof(ctx, vault2); + const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; + await processAndValidate(vaultReportReceipt, "Vault2 report"); + + await lido.connect(user3).approve(await dashboard2.getAddress(), amount); + + const tx = await dashboard2.burnStETH(amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processAndValidate(receipt, "Vault2 burn 30 stETH"); + + v3BurnCount++; + }); + + it("Should have correct entity counts and pass final validation", async () => { + // Validate global consistency - simulator state should match chain state + await validateGlobalConsistency(); + + const totalRewardCount = simulator.countTotalRewards(0n); + const allShares = simulator.getAllShares(); + const allTransfers = simulator.getAllLidoTransfers(); + const allSubmissions = simulator.getAllLidoSubmissions(); + + log.info("=== Scenario Summary ===", { + "Deposits (submits)": depositCount, + "Transfers": transferCount, + "Oracle Reports": reportCount, + "Profitable Reports": profitableReportCount, + "Withdrawal Requests": withdrawalRequestCount, + "V3 Mints": v3MintCount, + "V3 Burns": v3BurnCount, + "TotalReward Entities": totalRewardCount, + "Shares Entities": allShares.size, + "LidoTransfer Entities": allTransfers.size, + "LidoSubmission Entities": allSubmissions.size, + }); + + // Verify minimum counts + expect(depositCount).to.be.gte(6, "Should have at least 6 deposits"); + expect(transferCount).to.be.gte(5, "Should have at least 5 transfers"); + expect(reportCount).to.be.gte(7, "Should have at least 7 oracle reports"); + expect(withdrawalRequestCount).to.be.gte(5, "Should have at least 5 withdrawal requests"); + expect(v3MintCount).to.be.gte(4, "Should have at least 4 V3 mints"); + expect(v3BurnCount).to.be.gte(4, "Should have at least 4 V3 burns"); + + // Verify entity creation + expect(totalRewardCount).to.be.gte(profitableReportCount, "TotalReward entities match profitable reports"); + expect(allSubmissions.size).to.be.gte(depositCount, "LidoSubmission entities match deposits"); + }); + }); +}); diff --git a/test/graph/graph-tests-spec.md b/test/graph/graph-tests-spec.md deleted file mode 100644 index 35c848099b..0000000000 --- a/test/graph/graph-tests-spec.md +++ /dev/null @@ -1,662 +0,0 @@ -# Graph Indexer Integration Tests Specification - -## Purpose - -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. - -## Limitations - -### Historical Data - -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 - -### OracleCompleted History - -The legacy `OracleCompleted` entity tracking is skipped since V3 uses `TokenRebased.timeElapsed` directly. - -### Legacy V1 Fields - -The following fields exist in the real Graph schema but are **intentionally omitted** from the simulator as they are legacy V1 fields not used in V2+ oracle reports: - -| Field | Purpose | Why Omitted | -| ------------------------- | -------------------------------- | --------------------------- | -| `insuranceFee` | ETH minted to insurance fund | No insurance fund since V2 | -| `insuranceFeeBasisPoints` | Insurance fee as basis points | No insurance fund since V2 | -| `sharesToInsuranceFund` | Shares minted to insurance fund | No insurance fund since V2 | -| `dust` | Rounding dust ETH to treasury | V2 handles dust differently | -| `dustSharesToTreasury` | Rounding dust shares to treasury | V2 handles dust differently | - -These fields are initialized to zero in the real Graph but never populated for V2+ reports. - -**Note:** The `TotalRewardEntity` interface in `entities.ts` documents these omissions inline for developer reference. - ---- - -## Architecture - -### Location - -Standalone module in `test/graph/` importable by integration tests. - -### Language & Types - -- TypeScript implementation mimicking Graph handler logic -- Native `bigint` for all numeric values (no precision loss, exact matching) -- Custom entity type definitions matching Graph schema - -### File Structure - -``` -test/graph/ -├── graph-tests-spec.md # This specification -├── index.ts # Re-exports for external use -├── simulator/ -│ ├── index.ts # Main entry point, GraphSimulator class, processTransaction() -│ ├── entities.ts # Entity type definitions (TotalRewardEntity, TotalsEntity) -│ ├── store.ts # In-memory entity store with Totals tracking -│ ├── query.ts # Query methods (filtering, pagination, ordering) -│ ├── handlers/ -│ │ ├── lido.ts # handleETHDistributed, handleSharesBurnt, _processTokenRebase -│ │ └── index.ts # Handler registry, processTransactionEvents() -│ └── helpers.ts # APR calculation (calcAPR_v2), basis point utilities -├── utils/ -│ ├── index.ts # Re-exports -│ ├── state-capture.ts # captureChainState(), capturePoolState() -│ └── event-extraction.ts # extractAllLogs(), findTransferSharesPairs() -├── total-reward.integration.ts # Integration test for TotalReward entity -└── edge-cases.integration.ts # Edge case tests (zero rewards, division by zero, etc.) -``` - -The simulator structure mirrors `lido-subgraph/src/` where practical. - ---- - -## Simulator Design - -### Initial State - -The simulator requires initial state captured from on-chain before processing events: - -```typescript -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). - -### Entity Store - -In-memory store mimicking Graph's database: - -```typescript -interface EntityStore { - /** Totals singleton entity (pool state) */ - totals: TotalsEntity | null; - - /** TotalReward entities keyed by transaction hash */ - totalRewards: Map; - - // Future: other entities (NodeOperatorFees, OracleReport, etc.) -} -``` - -### Totals State Tracking - -The `TotalsEntity` tracks cumulative pool state across transactions: - -```typescript -interface TotalsEntity { - id: string; // Singleton ID (always "") - totalPooledEther: bigint; - totalShares: bigint; -} -``` - -**Key Behaviors:** - -- Updated during every `handleETHDistributed` call (even for non-profitable reports) -- Updated when `SharesBurnt` events are processed (withdrawal finalization) -- Validated against event params to detect state inconsistencies - -### Transaction Processing - -```typescript -interface ProcessTransactionResult { - /** TotalReward entities created/updated (keyed by tx hash) */ - totalRewards: Map; - /** Number of events processed */ - eventsProcessed: number; - /** Whether any profitable oracle report was found */ - hadProfitableReport: boolean; - /** Whether Totals entity was updated */ - totalsUpdated: boolean; - /** The current state of the Totals entity after processing */ - totals: TotalsEntity | null; - /** SharesBurnt events processed during withdrawal finalization */ - sharesBurnt: SharesBurntResult[]; - /** Validation warnings from sanity checks */ - warnings: ValidationWarning[]; -} - -function processTransaction( - receipt: ContractTransactionReceipt, - ctx: ProtocolContext, - store: EntityStore, - blockTimestamp?: bigint, - treasuryAddress?: string, -): ProcessTransactionResult; -``` - -- Extracts and parses all logs from the transaction receipt -- Logs are processed in `logIndex` order -- Handlers can "look ahead" in the logs array (matches Graph behavior) -- Returns result with created entities, processing metadata, and validation warnings - -### Event Extraction - -Custom utilities in `utils/event-extraction.ts`: - -- `extractAllLogs()` - Parse all logs from receipt using protocol interfaces -- `findEventByName()` - Find event by name with optional start index (for look-ahead) -- `findAllEventsByName()` - Find all events by name within a range -- `findTransferSharesPairs()` - Extract paired Transfer/TransferShares events in range -- `getEventArg()` - Type-safe event argument extraction - ---- - -## Validation and Sanity Checks - -### shares2mint Validation - -The simulator validates that `TokenRebased.sharesMintedAsFees` equals the sum of shares actually minted to treasury and operators: - -```typescript -const totalSharesMinted = sharesToTreasury + sharesToOperators; -if (sharesMintedAsFees !== totalSharesMinted) { - warnings.push({ - type: "shares2mint_mismatch", - message: `shares2mint mismatch: expected ${sharesMintedAsFees}, got ${totalSharesMinted}`, - expected: sharesMintedAsFees, - actual: totalSharesMinted, - }); -} -``` - -**Reference:** lido-subgraph/src/Lido.ts lines 664-667 - -### Totals State Validation - -When processing `ETHDistributed`, the simulator validates that the current `Totals` state matches the event's `preTotalEther` and `preTotalShares`: - -```typescript -if (totals.totalPooledEther !== 0n && totals.totalPooledEther !== preTotalEther) { - warnings.push({ - type: "totals_state_mismatch", - message: `Totals.totalPooledEther mismatch`, - expected: preTotalEther, - actual: totals.totalPooledEther, - }); -} -``` - -This catches cases where the simulator state gets out of sync with the actual chain state. - -### Validation Warning Types - -```typescript -type ValidationWarningType = - | "shares2mint_mismatch" // TokenRebased.sharesMintedAsFees != actual minted - | "totals_state_mismatch"; // Totals state doesn't match event params -``` - ---- - -## SharesBurnt Handling - -### Overview - -When withdrawal finalization occurs during an oracle report, `SharesBurnt` events are emitted that reduce `totalShares`. The simulator now handles these events. - -### Event Processing Order - -``` -1. ETHDistributed ← Creates TotalReward, updates totalPooledEther -2. SharesBurnt (optional) ← Burns shares during withdrawal finalization -3. Transfer (fee mints) ← Mint shares to treasury/operators -4. TransferShares ← Paired with Transfer -5. TokenRebased ← Final pool state -``` - -### Handler Implementation - -```typescript -function handleSharesBurnt( - event: LogDescriptionWithMeta, - store: EntityStore -): SharesBurntResult { - const sharesAmount = getEventArg(event, "sharesAmount"); - - // Decrease totalShares - totals.totalShares = totals.totalShares - sharesAmount; - saveTotals(store, totals); - - return { sharesBurnt: sharesAmount, ... }; -} -``` - -**Reference:** lido-subgraph/src/Lido.ts handleSharesBurnt() lines 444-476 - -### Integration with handleETHDistributed - -`SharesBurnt` events between `ETHDistributed` and `TokenRebased` are automatically processed: - -```typescript -// Step 2: Handle SharesBurnt if present (for withdrawal finalization) -const sharesBurntEvents = findAllEventsByName(allLogs, "SharesBurnt", event.logIndex, tokenRebasedEvent.logIndex); - -for (const sharesBurntEvent of sharesBurntEvents) { - handleSharesBurnt(sharesBurntEvent, store); -} -``` - ---- - -## Test Structure - -### Scenario Tests - -For Scenario tests (state persists across `it` blocks), initialize simulator at suite level: - -```typescript -describe("Scenario: Graph TotalReward Validation", () => { - let ctx: ProtocolContext; - let simulator: GraphSimulator; - let initialState: SimulatorInitialState; - - before(async () => { - ctx = await getProtocolContext(); - initialState = await captureChainState(ctx); - simulator = new GraphSimulator(initialState.treasuryAddress); - - // Initialize Totals with current chain state - simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); - }); - - 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(); - const blockTimestamp = BigInt((await ethers.provider.getBlock(receipt.blockNumber))!.timestamp); - - // 3. Process through simulator - const result = simulator.processTransaction(receipt, ctx, blockTimestamp); - - // 4. Check for validation warnings - expect(result.warnings.length).to.equal(0); - - // 5. Verify entity fields - const computed = result.totalRewards.get(receipt.hash); - // ... verify all fields ... - }); -}); -``` - -### Single Transaction Tests - -For one-off tests, use `processTransaction()` with a fresh store: - -```typescript -it("Should compute TotalReward correctly", async () => { - const ctx = await getProtocolContext(); - const initialState = await captureChainState(ctx); - const store = createEntityStore(); - - // Execute transaction... - const result = processTransaction(receipt, ctx, store, blockTimestamp, initialState.treasuryAddress); - - // Verify... -}); -``` - -### Edge Case Tests - -The `edge-cases.integration.ts` file tests: - -1. **APR Calculation Edge Cases** (unit tests) - - - Zero time elapsed - - Zero shares (pre/post) - - Zero ether - - Zero rate change - - Very small values - - Very large values (overflow protection) - - Negative rate change (slashing) - -2. **Non-Profitable Reports** (integration) - - - Zero CL rewards - - Negative CL diff (slashing) - -3. **Totals State Validation** (integration) - - - Multi-transaction consistency - - Mismatch detection - -4. **shares2mint Validation** (integration) - - - Verify minted shares match event param - -5. **Very Small Rewards** (integration) - - 1 wei rewards - - APR precision with tiny amounts - ---- - -## TotalReward Entity Fields - -### Implemented Fields - -#### Tier 1 - Direct Event Metadata ✅ - -| Field | Source | Verification | Status | -| ------------------ | ------------------------- | ------------------- | ------ | -| `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 | ✅ | - -#### Tier 2 - Pool State ✅ - -| Field | Source | Verification | Status | -| ------------------------ | ----------------------------------------------- | -------------------------------------- | ------ | -| `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 + validation | ✅ | -| `timeElapsed` | `TokenRebased.timeElapsed` | Event param | ✅ | -| `mevFee` | `ETHDistributed.executionLayerRewardsWithdrawn` | Event param | ✅ | - -#### Tier 2 - Fee Distribution ✅ - -| Field | Source | Verification | Status | -| ---------------------- | ----------------------------------------- | --------------------- | ------ | -| `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 | Transfer events | ✅ | -| `operatorsFee` | Sum of mints to staking modules | Transfer events | ✅ | -| `sharesToTreasury` | From TransferShares to treasury | TransferShares events | ✅ | -| `sharesToOperators` | From TransferShares to modules | TransferShares events | ✅ | - -#### Tier 3 - Calculated Fields ✅ - -| Field | Calculation | Verification | Status | -| ------------------------- | ----------------------------------------- | ----------------------- | ------ | -| `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 | ✅ | - -### Omitted Legacy Fields (V1 only) - -These fields exist in the real Graph schema but are **not implemented** in the simulator: - -| Field | Reason for Omission | -| ------------------------- | ---------------------------------------------- | -| `insuranceFee` | No insurance fund since V2 | -| `insuranceFeeBasisPoints` | No insurance fund since V2 | -| `sharesToInsuranceFund` | No insurance fund since V2 | -| `dust` | Legacy rounding dust handling, not used in V2+ | -| `dustSharesToTreasury` | Legacy rounding dust handling, not used in V2+ | - ---- - -## APR Calculation - -### Formula - -```typescript -// Share rate calculation -preShareRate = (preTotalEther * E27) / preTotalShares; -postShareRate = (postTotalEther * E27) / postTotalShares; - -// APR = annualized percentage change in share rate -apr = (secondsPerYear * (postShareRate - preShareRate) * 100) / preShareRate / timeElapsed; -``` - -### Edge Case Handling - -The `calcAPR_v2` function handles these edge cases: - -| Edge Case | Behavior | -| ------------------------------- | ------------------------------- | -| `timeElapsed = 0` | Returns 0 | -| `preTotalShares = 0` | Returns 0 | -| `postTotalShares = 0` | Returns 0 | -| `preTotalEther = 0` | Returns 0 | -| `preShareRate < MIN_SHARE_RATE` | Returns 0 | -| `rateChange = 0` | Returns 0 | -| `apr > MAX_APR_SCALED` | Capped to prevent overflow | -| `apr < -MAX_APR_SCALED` | Capped to prevent underflow | -| Negative rate change | Returns negative APR (slashing) | - -### Extended APR Function - -For debugging, use `calcAPR_v2Extended` which returns edge case information: - -```typescript -interface APRResult { - apr: number; - edgeCase: APREdgeCase | null; -} - -type APREdgeCase = - | "zero_time_elapsed" - | "zero_pre_shares" - | "zero_post_shares" - | "zero_pre_ether" - | "share_rate_too_small" - | "zero_rate_change" - | "apr_overflow_positive" - | "apr_overflow_negative"; -``` - ---- - -## Event Processing Order - -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. SharesBurnt (optional) ← Lido contract (withdrawal finalization) -4. Transfer (fee mints) ← Lido contract (multiple, from 0x0) -5. TransferShares ← Lido contract (multiple, paired with Transfer) -6. TokenRebased ← Lido contract (pool state, accessed via look-ahead) -7. ExtraDataSubmitted ← AccountingOracle (links NodeOperator entities) -``` - -The `handleETHDistributed` handler uses "look-ahead" to access `TokenRebased` event data before it's formally processed. - ---- - -## Test Environment - -### Network - -Tests run on **Hoodi testnet** via forking (see `.github/workflows/tests-integration-hoodi.yml`). - -Configuration: - -```bash -RPC_URL: ${{ secrets.HOODI_RPC_URL }} -NETWORK_STATE_FILE: deployed-hoodi.json -``` - -### Test Command - -```bash -yarn test:integration # Runs on Hoodi fork -``` - -### Dependencies - -Uses existing test infrastructure: - -- `lib/protocol/` - Protocol context, oracle reporting helpers -- `lib/event.ts` - Event extraction utilities -- `test/suite/` - Test utilities (Snapshot, etc.) - ---- - -## Success Criteria - -**Exact match** of all implemented fields between: - -1. Simulator-computed entity values -2. Expected values derived from on-chain state - -No tolerance for rounding differences: - -- All integer values use `bigint` for exact matching -- APR values use `number` with scaled integer arithmetic to maintain precision - -**Additional validation:** - -- No `shares2mint_mismatch` warnings in normal operation -- No `totals_state_mismatch` warnings when simulator is properly initialized -- Edge cases handled gracefully without errors - ---- - -## Implementation Status - -### Iteration 1 ✅ Complete - -**Scope:** - -- `TotalReward` entity only -- Tier 1 fields (event metadata) -- Tier 2 fields (pool state from TokenRebased) - -**Deliverables:** - -- Simulator module with `handleETHDistributed` handler -- Entity store implementation -- State capture utilities (`captureChainState`, `capturePoolState`) -- Event extraction utilities (`extractAllLogs`, `findTransferSharesPairs`) - -### Iteration 2 ✅ Complete - -**Scope:** - -- Tier 2 fields (fee distribution tracking) -- Tier 3 fields (APR calculations, basis points) -- Fee distribution to treasury and staking modules -- Transfer/TransferShares pair extraction - -**Deliverables:** - -- `_processTokenRebase` with full fee tracking -- `calcAPR_v2` implementation -- Basis point calculations -- Query functionality (filtering, pagination, ordering) -- Integration tests with multiple oracle reports - -### Iteration 2.1 ✅ Complete - -**Scope:** - -- SharesBurnt event handling for withdrawal finalization -- shares2mint validation sanity check -- Totals state tracking and validation -- Edge case tests -- Documentation updates - -**Deliverables:** - -- `handleSharesBurnt` handler implementation -- `ValidationWarning` types and reporting -- `calcAPR_v2Extended` with edge case info -- `edge-cases.integration.ts` test suite -- Updated spec documentation - -### Iteration 3 (Future) - -**Scope:** - -- Related entities: `NodeOperatorFees`, `NodeOperatorsShares`, `OracleReport` -- `handleProcessingStarted` from AccountingOracle -- `handleExtraDataSubmitted` from AccountingOracle - ---- - -## Future Considerations - -### Edge Cases to Monitor - -| Scenario | Current Status | Notes | -| ------------------------ | -------------- | ----------------------------- | -| Non-profitable report | ✅ Tested | Returns `isProfitable: false` | -| Withdrawal finalization | ✅ Implemented | `handleSharesBurnt` called | -| Slashing penalties | ✅ APR handles | Returns negative APR | -| Multiple staking modules | ✅ Tested | NOR + SDVT + CSM support | -| Zero rewards | ✅ Tested | No entity created | -| Very small rewards | ✅ Tested | 1 wei handled | -| APR overflow | ✅ Protected | Capped at MAX_APR_SCALED | - -### Relationship to Actual Graph Code - -#### Current Approach - -- Manual TypeScript port of relevant handler logic -- Comments referencing original `lido-subgraph/src/` file locations -- Focus on correctness over exact code mirroring - -#### Key Differences from Real Graph - -| Aspect | Real Graph | Simulator | -| ---------------------- | ----------------------------------- | -------------------------------- | -| State management | Persistent `Totals` entity | ✅ Tracks `Totals` entity | -| SharesBurnt handling | Manual call in handleETHDistributed | ✅ Implemented | -| APR arithmetic | `BigDecimal` (arbitrary precision) | `bigint` with scaling → `number` | -| Division by zero | Graph's implicit handling | Explicit defensive checks | -| shares2mint validation | Critical log on mismatch | ✅ Validation warnings | -| State consistency | Assertions | ✅ Warning-based validation | - -#### Maintenance - -- When Graph code changes, tests serve as validation -- Discrepancies indicate either bug in Graph or test update needed -- Detailed comparison available in `data/temp/total-rewards-comparison.md` - -#### Reference Files - -Key Graph source files to mirror: - -- `lido-subgraph/src/Lido.ts` - `handleETHDistributed`, `handleSharesBurnt`, `_processTokenRebase` -- `lido-subgraph/src/helpers.ts` - `_calcAPR_v2`, entity loaders -- `lido-subgraph/src/AccountingOracle.ts` - `handleProcessingStarted` -- `lido-subgraph/src/constants.ts` - Calculation units, addresses diff --git a/test/graph/simulator/entities.ts b/test/graph/simulator/entities.ts index 85c5a34835..852593f16b 100644 --- a/test/graph/simulator/entities.ts +++ b/test/graph/simulator/entities.ts @@ -211,3 +211,279 @@ export function createTotalRewardEntity(id: string): TotalRewardEntity { operatorsFeeBasisPoints: 0n, }; } + +// ============================================================================ +// Shares Entity +// ============================================================================ + +/** + * Shares entity tracking per-holder share balance + * + * This entity tracks the share balance for each unique address. + * Updated on transfers, submissions, and burns. + * + * Reference: lido-subgraph/schema.graphql - Shares entity + * Reference: lido-subgraph/src/helpers.ts _loadSharesEntity() + */ +export interface SharesEntity { + /** Holder address (lowercase hex string) */ + id: string; + + /** Current share balance */ + shares: bigint; +} + +/** + * Create a new Shares entity with default values + * + * @param id - Holder address + * @returns New SharesEntity with zero shares + */ +export function createSharesEntity(id: string): SharesEntity { + return { + id: id.toLowerCase(), + shares: 0n, + }; +} + +// ============================================================================ +// LidoTransfer Entity +// ============================================================================ + +/** + * LidoTransfer entity representing a stETH transfer event + * + * This is an immutable entity created for each Transfer event. + * Tracks the transfer details including before/after share balances. + * + * Reference: lido-subgraph/schema.graphql - LidoTransfer entity + * Reference: lido-subgraph/src/helpers.ts _loadLidoTransferEntity() + */ +export interface LidoTransferEntity { + /** Entity ID: txHash-logIndex */ + id: string; + + /** Sender address (0x0 for mints) */ + from: string; + + /** Recipient address (0x0 for burns) */ + to: string; + + /** Transfer value in wei */ + value: bigint; + + /** Shares transferred (from paired TransferShares event) */ + shares: bigint; + + /** Sender's shares before the transfer */ + sharesBeforeDecrease: bigint; + + /** Sender's shares after the transfer */ + sharesAfterDecrease: bigint; + + /** Recipient's shares before the transfer */ + sharesBeforeIncrease: bigint; + + /** Recipient's shares after the transfer */ + sharesAfterIncrease: bigint; + + /** Total pooled ether at time of transfer */ + totalPooledEther: bigint; + + /** Total shares at time of transfer */ + totalShares: bigint; + + /** Sender's balance after transfer: sharesAfterDecrease * totalPooledEther / totalShares */ + balanceAfterDecrease: bigint; + + /** Recipient's balance after transfer: sharesAfterIncrease * totalPooledEther / totalShares */ + balanceAfterIncrease: bigint; + + // ========== Event Metadata ========== + + /** Block number */ + block: bigint; + + /** Block timestamp (Unix seconds) */ + blockTime: bigint; + + /** Transaction hash */ + transactionHash: string; + + /** Transaction index within the block */ + transactionIndex: bigint; + + /** Log index within the transaction */ + logIndex: bigint; +} + +/** + * Create a new LidoTransfer entity with default values + * + * @param id - Entity ID (txHash-logIndex) + * @returns New LidoTransferEntity with zero/empty default values + */ +export function createLidoTransferEntity(id: string): LidoTransferEntity { + return { + id, + from: "", + to: "", + value: 0n, + shares: 0n, + sharesBeforeDecrease: 0n, + sharesAfterDecrease: 0n, + sharesBeforeIncrease: 0n, + sharesAfterIncrease: 0n, + totalPooledEther: 0n, + totalShares: 0n, + balanceAfterDecrease: 0n, + balanceAfterIncrease: 0n, + block: 0n, + blockTime: 0n, + transactionHash: "", + transactionIndex: 0n, + logIndex: 0n, + }; +} + +// ============================================================================ +// LidoSubmission Entity +// ============================================================================ + +/** + * LidoSubmission entity representing a user stake submission + * + * This is an immutable entity created for each Submitted event. + * Tracks the submission details including pool state before/after. + * + * Reference: lido-subgraph/schema.graphql - LidoSubmission entity + * Reference: lido-subgraph/src/Lido.ts handleSubmitted() + */ +export interface LidoSubmissionEntity { + /** Entity ID: txHash-logIndex */ + id: string; + + /** Sender address */ + sender: string; + + /** Amount of ETH submitted */ + amount: bigint; + + /** Referral address */ + referral: string; + + /** Shares minted to sender (from paired TransferShares event) */ + shares: bigint; + + /** Sender's shares before submission */ + sharesBefore: bigint; + + /** Sender's shares after submission */ + sharesAfter: bigint; + + /** Total pooled ether before submission */ + totalPooledEtherBefore: bigint; + + /** Total pooled ether after submission */ + totalPooledEtherAfter: bigint; + + /** Total shares before submission */ + totalSharesBefore: bigint; + + /** Total shares after submission */ + totalSharesAfter: bigint; + + /** Sender's balance after submission: sharesAfter * totalPooledEtherAfter / totalSharesAfter */ + balanceAfter: bigint; + + // ========== Event Metadata ========== + + /** Block number */ + block: bigint; + + /** Block timestamp (Unix seconds) */ + blockTime: bigint; + + /** Transaction hash */ + transactionHash: string; + + /** Transaction index within the block */ + transactionIndex: bigint; + + /** Log index within the transaction */ + logIndex: bigint; +} + +/** + * Create a new LidoSubmission entity with default values + * + * @param id - Entity ID (txHash-logIndex) + * @returns New LidoSubmissionEntity with zero/empty default values + */ +export function createLidoSubmissionEntity(id: string): LidoSubmissionEntity { + return { + id, + sender: "", + amount: 0n, + referral: "", + shares: 0n, + sharesBefore: 0n, + sharesAfter: 0n, + totalPooledEtherBefore: 0n, + totalPooledEtherAfter: 0n, + totalSharesBefore: 0n, + totalSharesAfter: 0n, + balanceAfter: 0n, + block: 0n, + blockTime: 0n, + transactionHash: "", + transactionIndex: 0n, + logIndex: 0n, + }; +} + +// ============================================================================ +// SharesBurn Entity +// ============================================================================ + +/** + * SharesBurn entity representing a share burning event + * + * This is an immutable entity created for each SharesBurnt event. + * Occurs during withdrawal finalization. + * + * Reference: lido-subgraph/schema.graphql - SharesBurn entity + * Reference: lido-subgraph/src/Lido.ts handleSharesBurnt() + */ +export interface SharesBurnEntity { + /** Entity ID: txHash-logIndex */ + id: string; + + /** Account whose shares were burnt */ + account: string; + + /** Token amount before rebase */ + preRebaseTokenAmount: bigint; + + /** Token amount after rebase */ + postRebaseTokenAmount: bigint; + + /** Amount of shares burnt */ + sharesAmount: bigint; +} + +/** + * Create a new SharesBurn entity with default values + * + * @param id - Entity ID (txHash-logIndex) + * @returns New SharesBurnEntity with zero/empty default values + */ +export function createSharesBurnEntity(id: string): SharesBurnEntity { + return { + id, + account: "", + preRebaseTokenAmount: 0n, + postRebaseTokenAmount: 0n, + sharesAmount: 0n, + }; +} diff --git a/test/graph/simulator/handlers/index.ts b/test/graph/simulator/handlers/index.ts index 6032f097f0..470a232b88 100644 --- a/test/graph/simulator/handlers/index.ts +++ b/test/graph/simulator/handlers/index.ts @@ -5,22 +5,49 @@ * event processing across all handlers. */ +import { ProtocolContext } from "lib/protocol"; + import { LogDescriptionWithMeta } from "../../utils/event-extraction"; -import { TotalRewardEntity, TotalsEntity } from "../entities"; +import { + LidoSubmissionEntity, + LidoTransferEntity, + SharesBurnEntity, + TotalRewardEntity, + TotalsEntity, +} from "../entities"; import { EntityStore } from "../store"; import { + ExternalSharesBurntResult, + ExternalSharesMintedResult, handleETHDistributed, + handleExternalSharesBurnt, + handleExternalSharesMinted, HandlerContext, - handleSharesBurnt, + handleSharesBurntWithEntity, + handleSubmitted, + handleTransfer, isETHDistributedEvent, + isExternalSharesBurntEvent, + isExternalSharesMintedEvent, isSharesBurntEvent, + isSubmittedEvent, + isTransferEvent, SharesBurntResult, ValidationWarning, } from "./lido"; // Re-export for convenience -export { HandlerContext, ValidationWarning, SharesBurntResult } from "./lido"; +export { + HandlerContext, + ValidationWarning, + SharesBurntResult, + SharesBurntWithEntityResult, + SubmittedResult, + TransferResult, + ExternalSharesMintedResult, + ExternalSharesBurntResult, +} from "./lido"; /** * Result of processing a transaction's events @@ -29,6 +56,15 @@ export interface ProcessTransactionResult { /** TotalReward entities created/updated (keyed by tx hash) */ totalRewards: Map; + /** LidoSubmission entities created (keyed by entity id) */ + lidoSubmissions: Map; + + /** LidoTransfer entities created (keyed by entity id) */ + lidoTransfers: Map; + + /** SharesBurn entities created (keyed by entity id) */ + sharesBurns: Map; + /** Number of events processed */ eventsProcessed: number; @@ -41,7 +77,7 @@ export interface ProcessTransactionResult { /** The current state of the Totals entity after processing */ totals: TotalsEntity | null; - /** SharesBurnt events processed during withdrawal finalization */ + /** SharesBurnt events processed during withdrawal finalization (legacy format) */ sharesBurnt: SharesBurntResult[]; /** Validation warnings from sanity checks */ @@ -70,6 +106,9 @@ export function processTransactionEvents( ): ProcessTransactionResult { const result: ProcessTransactionResult = { totalRewards: new Map(), + lidoSubmissions: new Map(), + lidoTransfers: new Map(), + sharesBurns: new Map(), eventsProcessed: 0, hadProfitableReport: false, totalsUpdated: false, @@ -78,14 +117,31 @@ export function processTransactionEvents( warnings: [], }; - // Track which SharesBurnt events were already processed by handleETHDistributed + // Track which events were already processed by other handlers const processedSharesBurntIndices = new Set(); + const processedTransferIndices = new Set(); // Process events in logIndex order for (const log of logs) { result.eventsProcessed++; - // Route to appropriate handler based on event name + // ========== Submitted Event ========== + if (isSubmittedEvent(log)) { + const submittedResult = handleSubmitted(log, logs, store, ctx); + + result.lidoSubmissions.set(submittedResult.submission.id, submittedResult.submission); + result.lidoTransfers.set(submittedResult.transfer.id, submittedResult.transfer); + result.totalsUpdated = true; + result.totals = submittedResult.totals; + + // Mark the associated Transfer event as processed + const transferEvent = logs.find((l) => l.name === "Transfer" && l.logIndex > log.logIndex); + if (transferEvent) { + processedTransferIndices.add(transferEvent.logIndex); + } + } + + // ========== ETHDistributed Event (Oracle Report) ========== if (isETHDistributedEvent(log)) { const ethDistributedResult = handleETHDistributed(log, logs, store, ctx); @@ -102,30 +158,45 @@ export function processTransactionEvents( } // Mark SharesBurnt events that were processed as part of this ETHDistributed handler - // (they occur between ETHDistributed and TokenRebased) + // (they occur between ETHDistributed and TokenRebased and are handled via handleSharesBurnt) + // Note: Transfer events are NOT marked - they still need handleTransfer to create LidoTransfer entities + // and update Shares. In the real Graph, handleTransfer runs for ALL Transfer events independently. const tokenRebasedIdx = logs.findIndex((l) => l.name === "TokenRebased" && l.logIndex > log.logIndex); if (tokenRebasedIdx >= 0) { const tokenRebasedLogIndex = logs[tokenRebasedIdx].logIndex; for (const l of logs) { - if (l.name === "SharesBurnt" && l.logIndex > log.logIndex && l.logIndex < tokenRebasedLogIndex) { - processedSharesBurntIndices.add(l.logIndex); + if (l.logIndex > log.logIndex && l.logIndex < tokenRebasedLogIndex) { + if (l.name === "SharesBurnt") { + processedSharesBurntIndices.add(l.logIndex); + } } } } } - // Handle standalone SharesBurnt events (not part of oracle report) + // ========== Transfer Event (Standalone) ========== + if (isTransferEvent(log) && !processedTransferIndices.has(log.logIndex)) { + const transferResult = handleTransfer(log, logs, store, ctx); + + result.lidoTransfers.set(transferResult.transfer.id, transferResult.transfer); + result.totalsUpdated = true; + result.totals = store.totals; + } + + // ========== SharesBurnt Event (Standalone) ========== if (isSharesBurntEvent(log) && !processedSharesBurntIndices.has(log.logIndex)) { - const sharesBurntResult = handleSharesBurnt(log, store); + const sharesBurntResult = handleSharesBurntWithEntity(log, logs, store, ctx); + result.sharesBurnt.push(sharesBurntResult); + result.sharesBurns.set(sharesBurntResult.entity.id, sharesBurntResult.entity); + result.lidoTransfers.set(sharesBurntResult.transfer.id, sharesBurntResult.transfer); result.totalsUpdated = true; result.totals = sharesBurntResult.totals; } - // Future handlers can be added here: - // - handleProcessingStarted (AccountingOracle) - // - handleExtraDataSubmitted (AccountingOracle) - // - handleTransfer (Lido) - for fee distribution tracking + // ========== V3 VaultHub Events ========== + // Note: These require protocolContext for contract reads and are async + // They should be handled separately via processV3Event function } // Get final Totals state from store if not already set @@ -135,3 +206,29 @@ export function processTransactionEvents( return result; } + +/** + * Process a V3 VaultHub event (requires async contract reads) + * + * @param log - The event log + * @param store - Entity store + * @param ctx - Handler context + * @param protocolContext - Protocol context for contract reads + * @returns Result of processing the V3 event + */ +export async function processV3Event( + log: LogDescriptionWithMeta, + store: EntityStore, + ctx: HandlerContext, + protocolContext: ProtocolContext, +): Promise { + if (isExternalSharesMintedEvent(log)) { + return handleExternalSharesMinted(log, store, ctx, protocolContext); + } + + if (isExternalSharesBurntEvent(log)) { + return handleExternalSharesBurnt(log, store, ctx, protocolContext); + } + + return null; +} diff --git a/test/graph/simulator/handlers/lido.ts b/test/graph/simulator/handlers/lido.ts index fa2f3aa95f..3388e3e8c9 100644 --- a/test/graph/simulator/handlers/lido.ts +++ b/test/graph/simulator/handlers/lido.ts @@ -9,6 +9,8 @@ * Reference: lido-subgraph/src/Lido.ts lines 477-690 */ +import { ProtocolContext } from "lib/protocol"; + import { findAllEventsByName, findEventByName, @@ -17,9 +19,32 @@ import { LogDescriptionWithMeta, ZERO_ADDRESS, } from "../../utils/event-extraction"; -import { createTotalRewardEntity, TotalRewardEntity, TotalsEntity } from "../entities"; +import { + createTotalRewardEntity, + LidoSubmissionEntity, + LidoTransferEntity, + SharesBurnEntity, + TotalRewardEntity, + TotalsEntity, +} from "../entities"; import { calcAPR_v2, CALCULATION_UNIT } from "../helpers"; -import { EntityStore, loadTotalsEntity, saveTotalReward, saveTotals } from "../store"; +import { + EntityStore, + loadLidoSubmissionEntity, + loadLidoTransferEntity, + loadSharesBurnEntity, + loadSharesEntity, + loadTotalsEntity, + makeLidoSubmissionId, + makeLidoTransferId, + makeSharesBurnId, + saveLidoSubmission, + saveLidoTransfer, + saveShares, + saveSharesBurn, + saveTotalReward, + saveTotals, +} from "../store"; /** * Context passed to handlers containing transaction metadata @@ -446,3 +471,575 @@ export function _processTokenRebase( export function isETHDistributedEvent(event: LogDescriptionWithMeta): boolean { return event.name === "ETHDistributed"; } + +// ============================================================================ +// Submitted Event Handler +// ============================================================================ + +/** + * Result of processing a Submitted event + */ +export interface SubmittedResult { + /** The created LidoSubmission entity */ + submission: LidoSubmissionEntity; + + /** The created LidoTransfer entity (mint transfer) */ + transfer: LidoTransferEntity; + + /** The updated Totals entity */ + totals: TotalsEntity; +} + +/** + * Handle Submitted event - creates LidoSubmission entity and updates Totals/Shares + * + * Reference: lido-subgraph/src/Lido.ts handleSubmitted() lines 72-164 + * + * @param event - The Submitted event + * @param allLogs - All parsed logs from the transaction (for TransferShares look-ahead) + * @param store - Entity store + * @param ctx - Handler context with transaction metadata + * @returns Result containing the created entities and updated state + */ +export function handleSubmitted( + event: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + store: EntityStore, + ctx: HandlerContext, +): SubmittedResult { + // Extract Submitted event params + // event Submitted(address indexed sender, uint256 amount, address referral) + const sender = getEventArg(event, "sender"); + const amount = getEventArg(event, "amount"); + const referral = getEventArg(event, "referral"); + + // Find the paired TransferShares event to get the shares value (V2+: always present) + // The TransferShares event comes right after the Transfer event which follows Submitted + const transferSharesEvent = findEventByName(allLogs, "TransferShares", event.logIndex); + if (!transferSharesEvent) { + throw new Error(`TransferShares event not found after Submitted in tx ${ctx.transactionHash}`); + } + const shares = getEventArg(transferSharesEvent, "sharesValue"); + + // Load Totals entity and capture state before update + const totals = loadTotalsEntity(store, true)!; + const totalPooledEtherBefore = totals.totalPooledEther; + const totalSharesBefore = totals.totalShares; + + // Update Totals with the new submission + totals.totalPooledEther = totals.totalPooledEther + amount; + totals.totalShares = totals.totalShares + shares; + saveTotals(store, totals); + + // Load/create Shares entity for sender + const sharesEntity = loadSharesEntity(store, sender, true)!; + const sharesBefore = sharesEntity.shares; + sharesEntity.shares = sharesEntity.shares + shares; + const sharesAfter = sharesEntity.shares; + saveShares(store, sharesEntity); + + // Calculate balance after submission + const balanceAfter = totals.totalShares > 0n ? (sharesAfter * totals.totalPooledEther) / totals.totalShares : 0n; + + // Create LidoSubmission entity + const submissionId = makeLidoSubmissionId(ctx.transactionHash, event.logIndex); + const submission = loadLidoSubmissionEntity(store, submissionId, true)!; + + submission.sender = sender.toLowerCase(); + submission.amount = amount; + submission.referral = referral.toLowerCase(); + submission.shares = shares; + submission.sharesBefore = sharesBefore; + submission.sharesAfter = sharesAfter; + submission.totalPooledEtherBefore = totalPooledEtherBefore; + submission.totalPooledEtherAfter = totals.totalPooledEther; + submission.totalSharesBefore = totalSharesBefore; + submission.totalSharesAfter = totals.totalShares; + submission.balanceAfter = balanceAfter; + submission.block = ctx.blockNumber; + submission.blockTime = ctx.blockTimestamp; + submission.transactionHash = ctx.transactionHash; + submission.transactionIndex = BigInt(ctx.transactionIndex); + submission.logIndex = BigInt(event.logIndex); + + saveLidoSubmission(store, submission); + + // Create the mint transfer entity (handled by handleTransfer, but we create it here for completeness) + // Find the Transfer event that comes after Submitted + const transferEvent = findEventByName(allLogs, "Transfer", event.logIndex); + let transfer: LidoTransferEntity; + + if (transferEvent) { + transfer = _createTransferEntity( + transferEvent, + allLogs, + store, + ctx, + totals.totalPooledEther, + totals.totalShares, + true, // Skip shares update since we already did it above + ); + } else { + // Fallback: create a minimal transfer entity + const transferId = makeLidoTransferId(ctx.transactionHash, event.logIndex); + transfer = loadLidoTransferEntity(store, transferId, true)!; + transfer.from = ZERO_ADDRESS; + transfer.to = sender.toLowerCase(); + transfer.value = amount; + transfer.shares = shares; + transfer.totalPooledEther = totals.totalPooledEther; + transfer.totalShares = totals.totalShares; + transfer.block = ctx.blockNumber; + transfer.blockTime = ctx.blockTimestamp; + transfer.transactionHash = ctx.transactionHash; + transfer.transactionIndex = BigInt(ctx.transactionIndex); + transfer.logIndex = BigInt(event.logIndex); + saveLidoTransfer(store, transfer); + } + + return { + submission, + transfer, + totals, + }; +} + +/** + * Check if an event is a Submitted event + * + * @param event - The event to check + * @returns true if this is a Submitted event + */ +export function isSubmittedEvent(event: LogDescriptionWithMeta): boolean { + return event.name === "Submitted"; +} + +// ============================================================================ +// Transfer Event Handler +// ============================================================================ + +/** + * Result of processing a Transfer event + */ +export interface TransferResult { + /** The created LidoTransfer entity */ + transfer: LidoTransferEntity; + + /** Whether this was a mint transfer (from = 0x0) */ + isMint: boolean; + + /** Whether this was a burn transfer (to = 0x0) */ + isBurn: boolean; +} + +/** + * Handle Transfer event - creates LidoTransfer entity and updates Shares + * + * Reference: lido-subgraph/src/Lido.ts handleTransfer() lines 166-373 + * + * @param event - The Transfer event + * @param allLogs - All parsed logs from the transaction (for TransferShares look-ahead) + * @param store - Entity store + * @param ctx - Handler context with transaction metadata + * @param skipSharesUpdate - Skip shares update if already handled by caller (e.g., Submitted handler) + * @returns Result containing the created entity + */ +export function handleTransfer( + event: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + store: EntityStore, + ctx: HandlerContext, + skipSharesUpdate: boolean = false, +): TransferResult { + // Load current Totals state + const totals = loadTotalsEntity(store, true)!; + + const transfer = _createTransferEntity( + event, + allLogs, + store, + ctx, + totals.totalPooledEther, + totals.totalShares, + skipSharesUpdate, + ); + + const from = transfer.from.toLowerCase(); + const to = transfer.to.toLowerCase(); + + return { + transfer, + isMint: from === ZERO_ADDRESS.toLowerCase(), + isBurn: to === ZERO_ADDRESS.toLowerCase(), + }; +} + +/** + * Internal helper to create a LidoTransfer entity + * + * @param event - The Transfer event + * @param allLogs - All parsed logs from the transaction + * @param store - Entity store + * @param ctx - Handler context + * @param totalPooledEther - Current total pooled ether + * @param totalShares - Current total shares + * @param skipSharesUpdate - Skip shares update if already handled + * @returns The created LidoTransfer entity + */ +function _createTransferEntity( + event: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + store: EntityStore, + ctx: HandlerContext, + totalPooledEther: bigint, + totalShares: bigint, + skipSharesUpdate: boolean, +): LidoTransferEntity { + // Extract Transfer event params + // event Transfer(address indexed from, address indexed to, uint256 value) + const from = getEventArg(event, "from"); + const to = getEventArg(event, "to"); + const value = getEventArg(event, "value"); + + // Find the paired TransferShares event (V2+: always present, comes right after Transfer) + // Reference: lido-subgraph/src/Lido.ts lines 178-196 + const transferSharesEvent = findEventByName(allLogs, "TransferShares", event.logIndex); + const shares = transferSharesEvent ? getEventArg(transferSharesEvent, "sharesValue") : 0n; + + // Create LidoTransfer entity + const transferId = makeLidoTransferId(ctx.transactionHash, event.logIndex); + const transfer = loadLidoTransferEntity(store, transferId, true)!; + + transfer.from = from.toLowerCase(); + transfer.to = to.toLowerCase(); + transfer.value = value; + transfer.shares = shares; + transfer.totalPooledEther = totalPooledEther; + transfer.totalShares = totalShares; + transfer.block = ctx.blockNumber; + transfer.blockTime = ctx.blockTimestamp; + transfer.transactionHash = ctx.transactionHash; + transfer.transactionIndex = BigInt(ctx.transactionIndex); + transfer.logIndex = BigInt(event.logIndex); + + // Update shares and track before/after balances + // Reference: lido-subgraph/src/helpers.ts _updateTransferShares() lines 197-238 + if (!skipSharesUpdate) { + _updateTransferShares(transfer, store); + } else { + // Just capture current state without updating + const fromShares = loadSharesEntity(store, from, false); + const toShares = loadSharesEntity(store, to, false); + if (fromShares) { + transfer.sharesBeforeDecrease = fromShares.shares; + transfer.sharesAfterDecrease = fromShares.shares; + } + if (toShares) { + transfer.sharesBeforeIncrease = toShares.shares; + transfer.sharesAfterIncrease = toShares.shares; + } + } + + // Calculate balances after transfer + // Reference: lido-subgraph/src/helpers.ts _updateTransferBalances() lines 183-195 + _updateTransferBalances(transfer); + + saveLidoTransfer(store, transfer); + + return transfer; +} + +/** + * Update shares for from/to addresses based on transfer + * + * Reference: lido-subgraph/src/helpers.ts _updateTransferShares() lines 197-238 + * + * @param entity - The LidoTransfer entity to update + * @param store - Entity store + */ +function _updateTransferShares(entity: LidoTransferEntity, store: EntityStore): void { + const fromLower = entity.from.toLowerCase(); + const toLower = entity.to.toLowerCase(); + const zeroLower = ZERO_ADDRESS.toLowerCase(); + + // Decreasing from address shares (skip if from is zero address - mint) + if (fromLower !== zeroLower) { + const sharesFromEntity = loadSharesEntity(store, entity.from, true)!; + entity.sharesBeforeDecrease = sharesFromEntity.shares; + + if (fromLower !== toLower && entity.shares > 0n) { + sharesFromEntity.shares = sharesFromEntity.shares - entity.shares; + saveShares(store, sharesFromEntity); + } + entity.sharesAfterDecrease = sharesFromEntity.shares; + } + + // Increasing to address shares (skip if to is zero address - burn) + if (toLower !== zeroLower) { + const sharesToEntity = loadSharesEntity(store, entity.to, true)!; + entity.sharesBeforeIncrease = sharesToEntity.shares; + + if (toLower !== fromLower && entity.shares > 0n) { + sharesToEntity.shares = sharesToEntity.shares + entity.shares; + saveShares(store, sharesToEntity); + } + entity.sharesAfterIncrease = sharesToEntity.shares; + } +} + +/** + * Calculate balances after transfer based on current totals + * + * Reference: lido-subgraph/src/helpers.ts _updateTransferBalances() lines 183-195 + * + * @param entity - The LidoTransfer entity to update + */ +function _updateTransferBalances(entity: LidoTransferEntity): void { + if (entity.totalShares === 0n) { + entity.balanceAfterIncrease = entity.value; + entity.balanceAfterDecrease = 0n; + } else { + entity.balanceAfterIncrease = (entity.sharesAfterIncrease * entity.totalPooledEther) / entity.totalShares; + entity.balanceAfterDecrease = (entity.sharesAfterDecrease * entity.totalPooledEther) / entity.totalShares; + } +} + +/** + * Check if an event is a Transfer event + * + * @param event - The event to check + * @returns true if this is a Transfer event + */ +export function isTransferEvent(event: LogDescriptionWithMeta): boolean { + return event.name === "Transfer"; +} + +// ============================================================================ +// SharesBurn Entity Creation (Enhanced) +// ============================================================================ + +/** + * Enhanced result of processing a SharesBurnt event with entity + */ +export interface SharesBurntWithEntityResult extends SharesBurntResult { + /** The created SharesBurn entity */ + entity: SharesBurnEntity; + + /** The created burn transfer entity */ + transfer: LidoTransferEntity; +} + +/** + * Handle SharesBurnt event with entity creation + * + * This extends handleSharesBurnt to also create the SharesBurn entity. + * + * Reference: lido-subgraph/src/Lido.ts handleSharesBurnt() lines 375-471 + * + * @param event - The SharesBurnt event + * @param allLogs - All logs (for potential paired events) + * @param store - Entity store + * @param ctx - Handler context + * @returns Result containing the entity and updated state + */ +export function handleSharesBurntWithEntity( + event: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + store: EntityStore, + ctx: HandlerContext, +): SharesBurntWithEntityResult { + // Extract SharesBurnt event params + const account = getEventArg(event, "account"); + const preRebaseTokenAmount = getEventArg(event, "preRebaseTokenAmount"); + const postRebaseTokenAmount = getEventArg(event, "postRebaseTokenAmount"); + const sharesAmount = getEventArg(event, "sharesAmount"); + + // Create SharesBurn entity + const entityId = makeSharesBurnId(ctx.transactionHash, event.logIndex); + const entity = loadSharesBurnEntity(store, entityId, true)!; + + entity.account = account.toLowerCase(); + entity.preRebaseTokenAmount = preRebaseTokenAmount; + entity.postRebaseTokenAmount = postRebaseTokenAmount; + entity.sharesAmount = sharesAmount; + + saveSharesBurn(store, entity); + + // Update Totals + const totals = loadTotalsEntity(store, true)!; + totals.totalShares = totals.totalShares - sharesAmount; + saveTotals(store, totals); + + // Update account shares + const accountShares = loadSharesEntity(store, account, true)!; + const sharesBeforeDecrease = accountShares.shares; + accountShares.shares = accountShares.shares - sharesAmount; + saveShares(store, accountShares); + + // Create burn transfer entity (from account to 0x0) + const transferId = makeLidoTransferId(ctx.transactionHash, event.logIndex); + const transfer = loadLidoTransferEntity(store, transferId, true)!; + + transfer.from = account.toLowerCase(); + transfer.to = ZERO_ADDRESS; + transfer.value = postRebaseTokenAmount; + transfer.shares = sharesAmount; + transfer.sharesBeforeDecrease = sharesBeforeDecrease; + transfer.sharesAfterDecrease = accountShares.shares; + transfer.sharesBeforeIncrease = 0n; + transfer.sharesAfterIncrease = 0n; + transfer.totalPooledEther = totals.totalPooledEther; + transfer.totalShares = totals.totalShares; + transfer.block = ctx.blockNumber; + transfer.blockTime = ctx.blockTimestamp; + transfer.transactionHash = ctx.transactionHash; + transfer.transactionIndex = BigInt(ctx.transactionIndex); + transfer.logIndex = BigInt(event.logIndex); + + _updateTransferBalances(transfer); + saveLidoTransfer(store, transfer); + + return { + sharesBurnt: sharesAmount, + account: account.toLowerCase(), + preRebaseTokenAmount, + postRebaseTokenAmount, + totals, + entity, + transfer, + }; +} + +// ============================================================================ +// V3 VaultHub Event Handlers +// ============================================================================ + +/** + * Result of processing an ExternalSharesMinted event + */ +export interface ExternalSharesMintedResult { + /** Amount of shares minted */ + amountOfShares: bigint; + + /** Receiver address */ + receiver: string; + + /** The updated Totals entity */ + totals: TotalsEntity; +} + +/** + * Handle ExternalSharesMinted event (V3) - updates Totals when VaultHub mints external shares + * + * Reference: lido-subgraph/src/LidoV3.ts handleExternalSharesMinted() lines 8-16 + * + * @param event - The ExternalSharesMinted event + * @param store - Entity store + * @param ctx - Handler context + * @param protocolContext - Protocol context for contract reads + * @returns Result containing updated state + */ +export async function handleExternalSharesMinted( + event: LogDescriptionWithMeta, + store: EntityStore, + ctx: HandlerContext, + protocolContext: ProtocolContext, +): Promise { + // Extract ExternalSharesMinted event params + // event ExternalSharesMinted(address indexed receiver, uint256 amountOfShares) + const receiver = getEventArg(event, "receiver"); + const amountOfShares = getEventArg(event, "amountOfShares"); + + // Load Totals entity + const totals = loadTotalsEntity(store, true)!; + + // Update totalShares by adding minted shares + totals.totalShares = totals.totalShares + amountOfShares; + + // Read totalPooledEther from contract (as done in real subgraph) + const totalPooledEther = await protocolContext.contracts.lido.getTotalPooledEther(); + totals.totalPooledEther = totalPooledEther; + + saveTotals(store, totals); + + // Update receiver's shares + const receiverShares = loadSharesEntity(store, receiver, true)!; + receiverShares.shares = receiverShares.shares + amountOfShares; + saveShares(store, receiverShares); + + return { + amountOfShares, + receiver: receiver.toLowerCase(), + totals, + }; +} + +/** + * Check if an event is an ExternalSharesMinted event + * + * @param event - The event to check + * @returns true if this is an ExternalSharesMinted event + */ +export function isExternalSharesMintedEvent(event: LogDescriptionWithMeta): boolean { + return event.name === "ExternalSharesMinted"; +} + +/** + * Result of processing an ExternalSharesBurnt event + */ +export interface ExternalSharesBurntResult { + /** Amount of shares burnt */ + amountOfShares: bigint; + + /** The updated Totals entity */ + totals: TotalsEntity; +} + +/** + * Handle ExternalSharesBurnt event (V3) - updates Totals when external shares are burnt + * + * Note: totalShares is not directly updated here as it's handled by the SharesBurnt event. + * This handler only updates totalPooledEther from contract. + * + * Reference: lido-subgraph/src/LidoV3.ts handleExternalSharesBurnt() lines 18-24 + * + * @param event - The ExternalSharesBurnt event + * @param store - Entity store + * @param ctx - Handler context + * @param protocolContext - Protocol context for contract reads + * @returns Result containing updated state + */ +export async function handleExternalSharesBurnt( + event: LogDescriptionWithMeta, + store: EntityStore, + ctx: HandlerContext, + protocolContext: ProtocolContext, +): Promise { + // Extract ExternalSharesBurnt event params + // event ExternalSharesBurnt(uint256 amountOfShares) + const amountOfShares = getEventArg(event, "amountOfShares"); + + // Load Totals entity + const totals = loadTotalsEntity(store, true)!; + + // Read totalPooledEther from contract (as done in real subgraph) + const totalPooledEther = await protocolContext.contracts.lido.getTotalPooledEther(); + totals.totalPooledEther = totalPooledEther; + + saveTotals(store, totals); + + return { + amountOfShares, + totals, + }; +} + +/** + * Check if an event is an ExternalSharesBurnt event + * + * @param event - The event to check + * @returns true if this is an ExternalSharesBurnt event + */ +export function isExternalSharesBurntEvent(event: LogDescriptionWithMeta): boolean { + return event.name === "ExternalSharesBurnt"; +} diff --git a/test/graph/simulator/index.ts b/test/graph/simulator/index.ts index c89ee0f515..d4f68b36b3 100644 --- a/test/graph/simulator/index.ts +++ b/test/graph/simulator/index.ts @@ -21,8 +21,16 @@ import { ProtocolContext } from "lib/protocol"; import { extractAllLogs, findTransferSharesPairs, ZERO_ADDRESS } from "../utils/event-extraction"; -import { TotalRewardEntity, TotalsEntity } from "./entities"; -import { HandlerContext, processTransactionEvents, ProcessTransactionResult } from "./handlers"; +import { + LidoSubmissionEntity, + LidoTransferEntity, + SharesBurnEntity, + SharesEntity, + TotalRewardEntity, + TotalsEntity, +} from "./entities"; +import { HandlerContext, processTransactionEvents, ProcessTransactionResult, processV3Event } from "./handlers"; +import { isExternalSharesBurntEvent, isExternalSharesMintedEvent } from "./handlers/lido"; import { calcAPR_v2, CALCULATION_UNIT } from "./helpers"; import { countTotalRewards, @@ -33,13 +41,64 @@ import { TotalRewardsQueryParamsExtended, TotalRewardsQueryResult, } from "./query"; -import { createEntityStore, EntityStore, loadTotalsEntity, saveTotals } from "./store"; +import { + createEntityStore, + EntityStore, + getLidoSubmission, + getLidoTransfer, + getShares, + getSharesBurn, + loadSharesEntity, + loadTotalsEntity, + saveShares, + saveTotals, +} from "./store"; // Re-export types and utilities -export { TotalRewardEntity, createTotalRewardEntity, TotalsEntity, createTotalsEntity } from "./entities"; -export { EntityStore, createEntityStore, getTotalReward, saveTotalReward, loadTotalsEntity, saveTotals } from "./store"; +export { + TotalRewardEntity, + createTotalRewardEntity, + TotalsEntity, + createTotalsEntity, + SharesEntity, + createSharesEntity, + LidoTransferEntity, + createLidoTransferEntity, + LidoSubmissionEntity, + createLidoSubmissionEntity, + SharesBurnEntity, + createSharesBurnEntity, +} from "./entities"; +export { + EntityStore, + createEntityStore, + getTotalReward, + saveTotalReward, + loadTotalsEntity, + saveTotals, + getShares, + saveShares, + loadSharesEntity, + getLidoTransfer, + getLidoSubmission, + getSharesBurn, + makeLidoTransferId, + makeLidoSubmissionId, + makeSharesBurnId, +} from "./store"; export { SimulatorInitialState, PoolState, captureChainState, capturePoolState } from "../utils/state-capture"; -export { ProcessTransactionResult, ValidationWarning, SharesBurntResult } from "./handlers"; +export { + HandlerContext, + ProcessTransactionResult, + ValidationWarning, + SharesBurntResult, + SharesBurntWithEntityResult, + SubmittedResult, + TransferResult, + ExternalSharesMintedResult, + ExternalSharesBurntResult, + processV3Event, +} from "./handlers"; // Re-export query types and functions export { @@ -139,17 +198,74 @@ export class GraphSimulator { /** * Process a transaction and return the result * + * This method processes both regular Lido events (Submitted, Transfer, ETHDistributed, etc.) + * and V3 VaultHub events (ExternalSharesMinted, ExternalSharesBurnt). + * + * V3 events require async contract reads to sync totalPooledEther with the chain. + * * @param receipt - Transaction receipt * @param ctx - Protocol context * @param blockTimestamp - Optional block timestamp - * @returns Processing result + * @returns Processing result (note: V3 event results are included in totals update) */ processTransaction( receipt: ContractTransactionReceipt, ctx: ProtocolContext, blockTimestamp?: bigint, ): ProcessTransactionResult { - return processTransaction(receipt, ctx, this.store, blockTimestamp, this.treasuryAddress); + // Process regular Lido events (synchronous) + const result = processTransaction(receipt, ctx, this.store, blockTimestamp, this.treasuryAddress); + + // V3 events need to be processed asynchronously via processTransactionWithV3 + // For backward compatibility, this method is still synchronous but won't process V3 events + // Call processTransactionWithV3 for full V3 support + + return result; + } + + /** + * Process a transaction including V3 VaultHub events (async) + * + * This method processes all Lido events including V3 events that require + * async contract reads to sync totalPooledEther with the chain. + * + * @param receipt - Transaction receipt + * @param ctx - Protocol context + * @param blockTimestamp - Optional block timestamp + * @returns Processing result with V3 events processed + */ + async processTransactionWithV3( + receipt: ContractTransactionReceipt, + ctx: ProtocolContext, + blockTimestamp?: bigint, + ): Promise { + // Process regular Lido events (synchronous) + const result = processTransaction(receipt, ctx, this.store, blockTimestamp, this.treasuryAddress); + + // Extract logs and process V3 events + const logs = extractAllLogs(receipt, ctx); + const ts = blockTimestamp ?? BigInt(Math.floor(Date.now() / 1000)); + + const handlerCtx: HandlerContext = { + blockNumber: BigInt(receipt.blockNumber), + blockTimestamp: ts, + transactionHash: receipt.hash, + transactionIndex: receipt.index, + treasuryAddress: this.treasuryAddress, + }; + + // Process V3 events (async - requires contract reads) + for (const log of logs) { + if (isExternalSharesMintedEvent(log) || isExternalSharesBurntEvent(log)) { + const v3Result = await processV3Event(log, this.store, handlerCtx, ctx); + if (v3Result) { + result.totalsUpdated = true; + result.totals = v3Result.totals; + } + } + } + + return result; } /** @@ -267,6 +383,104 @@ export class GraphSimulator { getTotalRewardsInBlockRange(fromBlock: bigint, toBlock: bigint): TotalRewardEntity[] { return getTotalRewardsInBlockRange(this.store, fromBlock, toBlock); } + + // ========== Shares Entity Methods ========== + + /** + * Get a Shares entity by holder address + * + * @param address - Holder address + * @returns The entity if found + */ + getShares(address: string): SharesEntity | undefined { + return getShares(this.store, address); + } + + /** + * Initialize shares for an address + * + * Useful for setting up initial state before processing transactions. + * + * @param address - Holder address + * @param shares - Initial share balance + */ + initializeShares(address: string, shares: bigint): void { + const sharesEntity = loadSharesEntity(this.store, address, true)!; + sharesEntity.shares = shares; + saveShares(this.store, sharesEntity); + } + + /** + * Get all Shares entities + * + * @returns Map of all Shares entities keyed by address + */ + getAllShares(): Map { + return this.store.shares; + } + + // ========== LidoTransfer Entity Methods ========== + + /** + * Get a LidoTransfer entity by ID + * + * @param id - Entity ID (txHash-logIndex) + * @returns The entity if found + */ + getLidoTransfer(id: string): LidoTransferEntity | undefined { + return getLidoTransfer(this.store, id); + } + + /** + * Get all LidoTransfer entities + * + * @returns Map of all LidoTransfer entities keyed by ID + */ + getAllLidoTransfers(): Map { + return this.store.lidoTransfers; + } + + // ========== LidoSubmission Entity Methods ========== + + /** + * Get a LidoSubmission entity by ID + * + * @param id - Entity ID (txHash-logIndex) + * @returns The entity if found + */ + getLidoSubmission(id: string): LidoSubmissionEntity | undefined { + return getLidoSubmission(this.store, id); + } + + /** + * Get all LidoSubmission entities + * + * @returns Map of all LidoSubmission entities keyed by ID + */ + getAllLidoSubmissions(): Map { + return this.store.lidoSubmissions; + } + + // ========== SharesBurn Entity Methods ========== + + /** + * Get a SharesBurn entity by ID + * + * @param id - Entity ID (txHash-logIndex) + * @returns The entity if found + */ + getSharesBurn(id: string): SharesBurnEntity | undefined { + return getSharesBurn(this.store, id); + } + + /** + * Get all SharesBurn entities + * + * @returns Map of all SharesBurn entities keyed by ID + */ + getAllSharesBurns(): Map { + return this.store.sharesBurns; + } } /** diff --git a/test/graph/simulator/store.ts b/test/graph/simulator/store.ts index bf7929441e..7abfa706c7 100644 --- a/test/graph/simulator/store.ts +++ b/test/graph/simulator/store.ts @@ -7,13 +7,24 @@ * Reference: The Graph's store API provides load/save operations for entities */ -import { createTotalsEntity, TotalRewardEntity, TotalsEntity } from "./entities"; +import { + createLidoSubmissionEntity, + createLidoTransferEntity, + createSharesBurnEntity, + createSharesEntity, + createTotalsEntity, + LidoSubmissionEntity, + LidoTransferEntity, + SharesBurnEntity, + SharesEntity, + TotalRewardEntity, + TotalsEntity, +} from "./entities"; /** * Entity store interface containing all entity collections * * Each entity type has its own Map keyed by entity ID. - * Future iterations will add more entity types (NodeOperatorFees, etc.) */ export interface EntityStore { /** Totals singleton entity (pool state) */ @@ -22,10 +33,17 @@ export interface EntityStore { /** TotalReward entities keyed by transaction hash */ totalRewards: Map; - // Future entity collections: - // nodeOperatorFees: Map; - // nodeOperatorsShares: Map; - // oracleReports: Map; + /** Shares entities keyed by holder address (lowercase) */ + shares: Map; + + /** LidoTransfer entities keyed by txHash-logIndex */ + lidoTransfers: Map; + + /** LidoSubmission entities keyed by txHash-logIndex */ + lidoSubmissions: Map; + + /** SharesBurn entities keyed by txHash-logIndex */ + sharesBurns: Map; } /** @@ -37,6 +55,10 @@ export function createEntityStore(): EntityStore { return { totals: null, totalRewards: new Map(), + shares: new Map(), + lidoTransfers: new Map(), + lidoSubmissions: new Map(), + sharesBurns: new Map(), }; } @@ -50,6 +72,10 @@ export function createEntityStore(): EntityStore { export function clearStore(store: EntityStore): void { store.totals = null; store.totalRewards.clear(); + store.shares.clear(); + store.lidoTransfers.clear(); + store.lidoSubmissions.clear(); + store.sharesBurns.clear(); } /** @@ -109,3 +135,218 @@ export function saveTotalReward(store: EntityStore, entity: TotalRewardEntity): export function hasTotalReward(store: EntityStore, id: string): boolean { return store.totalRewards.has(id.toLowerCase()); } + +// ============================================================================ +// Shares Entity Functions +// ============================================================================ + +/** + * Load or create a Shares entity + * + * Mimics _loadSharesEntity from lido-subgraph/src/helpers.ts + * + * @param store - The entity store + * @param id - Holder address + * @param create - Whether to create if not exists + * @returns The Shares entity or null if not exists and create=false + */ +export function loadSharesEntity(store: EntityStore, id: string, create: boolean = false): SharesEntity | null { + const normalizedId = id.toLowerCase(); + let entity = store.shares.get(normalizedId); + if (!entity && create) { + entity = createSharesEntity(normalizedId); + store.shares.set(normalizedId, entity); + } + return entity ?? null; +} + +/** + * Save a Shares entity to the store + * + * @param store - The entity store + * @param entity - The entity to save + */ +export function saveShares(store: EntityStore, entity: SharesEntity): void { + store.shares.set(entity.id.toLowerCase(), entity); +} + +/** + * Get a Shares entity by ID (holder address) + * + * @param store - The entity store + * @param id - Holder address + * @returns The entity if found, undefined otherwise + */ +export function getShares(store: EntityStore, id: string): SharesEntity | undefined { + return store.shares.get(id.toLowerCase()); +} + +// ============================================================================ +// LidoTransfer Entity Functions +// ============================================================================ + +/** + * Generate entity ID for LidoTransfer (txHash-logIndex) + * + * @param txHash - Transaction hash + * @param logIndex - Log index + * @returns Entity ID + */ +export function makeLidoTransferId(txHash: string, logIndex: number | bigint): string { + return `${txHash.toLowerCase()}-${logIndex.toString()}`; +} + +/** + * Load or create a LidoTransfer entity + * + * @param store - The entity store + * @param id - Entity ID (txHash-logIndex) + * @param create - Whether to create if not exists + * @returns The LidoTransfer entity or null if not exists and create=false + */ +export function loadLidoTransferEntity( + store: EntityStore, + id: string, + create: boolean = false, +): LidoTransferEntity | null { + const normalizedId = id.toLowerCase(); + let entity = store.lidoTransfers.get(normalizedId); + if (!entity && create) { + entity = createLidoTransferEntity(normalizedId); + store.lidoTransfers.set(normalizedId, entity); + } + return entity ?? null; +} + +/** + * Save a LidoTransfer entity to the store + * + * @param store - The entity store + * @param entity - The entity to save + */ +export function saveLidoTransfer(store: EntityStore, entity: LidoTransferEntity): void { + store.lidoTransfers.set(entity.id.toLowerCase(), entity); +} + +/** + * Get a LidoTransfer entity by ID + * + * @param store - The entity store + * @param id - Entity ID (txHash-logIndex) + * @returns The entity if found, undefined otherwise + */ +export function getLidoTransfer(store: EntityStore, id: string): LidoTransferEntity | undefined { + return store.lidoTransfers.get(id.toLowerCase()); +} + +// ============================================================================ +// LidoSubmission Entity Functions +// ============================================================================ + +/** + * Generate entity ID for LidoSubmission (txHash-logIndex) + * + * @param txHash - Transaction hash + * @param logIndex - Log index + * @returns Entity ID + */ +export function makeLidoSubmissionId(txHash: string, logIndex: number | bigint): string { + return `${txHash.toLowerCase()}-${logIndex.toString()}`; +} + +/** + * Load or create a LidoSubmission entity + * + * @param store - The entity store + * @param id - Entity ID (txHash-logIndex) + * @param create - Whether to create if not exists + * @returns The LidoSubmission entity or null if not exists and create=false + */ +export function loadLidoSubmissionEntity( + store: EntityStore, + id: string, + create: boolean = false, +): LidoSubmissionEntity | null { + const normalizedId = id.toLowerCase(); + let entity = store.lidoSubmissions.get(normalizedId); + if (!entity && create) { + entity = createLidoSubmissionEntity(normalizedId); + store.lidoSubmissions.set(normalizedId, entity); + } + return entity ?? null; +} + +/** + * Save a LidoSubmission entity to the store + * + * @param store - The entity store + * @param entity - The entity to save + */ +export function saveLidoSubmission(store: EntityStore, entity: LidoSubmissionEntity): void { + store.lidoSubmissions.set(entity.id.toLowerCase(), entity); +} + +/** + * Get a LidoSubmission entity by ID + * + * @param store - The entity store + * @param id - Entity ID (txHash-logIndex) + * @returns The entity if found, undefined otherwise + */ +export function getLidoSubmission(store: EntityStore, id: string): LidoSubmissionEntity | undefined { + return store.lidoSubmissions.get(id.toLowerCase()); +} + +// ============================================================================ +// SharesBurn Entity Functions +// ============================================================================ + +/** + * Generate entity ID for SharesBurn (txHash-logIndex) + * + * @param txHash - Transaction hash + * @param logIndex - Log index + * @returns Entity ID + */ +export function makeSharesBurnId(txHash: string, logIndex: number | bigint): string { + return `${txHash.toLowerCase()}-${logIndex.toString()}`; +} + +/** + * Load or create a SharesBurn entity + * + * @param store - The entity store + * @param id - Entity ID (txHash-logIndex) + * @param create - Whether to create if not exists + * @returns The SharesBurn entity or null if not exists and create=false + */ +export function loadSharesBurnEntity(store: EntityStore, id: string, create: boolean = false): SharesBurnEntity | null { + const normalizedId = id.toLowerCase(); + let entity = store.sharesBurns.get(normalizedId); + if (!entity && create) { + entity = createSharesBurnEntity(normalizedId); + store.sharesBurns.set(normalizedId, entity); + } + return entity ?? null; +} + +/** + * Save a SharesBurn entity to the store + * + * @param store - The entity store + * @param entity - The entity to save + */ +export function saveSharesBurn(store: EntityStore, entity: SharesBurnEntity): void { + store.sharesBurns.set(entity.id.toLowerCase(), entity); +} + +/** + * Get a SharesBurn entity by ID + * + * @param store - The entity store + * @param id - Entity ID (txHash-logIndex) + * @returns The entity if found, undefined otherwise + */ +export function getSharesBurn(store: EntityStore, id: string): SharesBurnEntity | undefined { + return store.sharesBurns.get(id.toLowerCase()); +} diff --git a/test/integration/core/total-reward.integration.ts b/test/integration/core/total-reward.integration.ts deleted file mode 100644 index 4a86ee36fc..0000000000 --- a/test/integration/core/total-reward.integration.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { expect } from "chai"; -import { ContractTransactionReceipt, formatEther, ZeroAddress } from "ethers"; -import { ethers } from "hardhat"; - -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; - -import { advanceChainTime, ether, impersonate, log, mEqual, updateBalance } from "lib"; -import { - finalizeWQViaElVault, - getProtocolContext, - norSdvtEnsureOperators, - OracleReportParams, - ProtocolContext, - removeStakingLimit, - report, - setStakingLimit, -} from "lib/protocol"; - -import { bailOnFailure, Snapshot } from "test/suite"; - -import { - createEntityStore, - deriveExpectedTotalReward, - GraphSimulator, - processTransaction, -} from "../../graph/simulator"; -import { captureChainState, capturePoolState, SimulatorInitialState } from "../../graph/utils"; -import { extractAllLogs } from "../../graph/utils/event-extraction"; - -const INTERVAL_12_HOURS = 12n * 60n * 60n; - -/** - * Graph TotalReward Entity Integration Tests - * - * These tests validate that the Graph simulator correctly computes TotalReward - * entity fields when processing oracle report transactions. - * - * Test Strategy: - * 1. Execute an oracle report transaction - * 2. Process the transaction events through the simulator - * 3. Compare simulator output against expected values derived from events - * - * All comparisons use exact bigint matching (no tolerance). - * - * Reference: test/graph/graph-tests-spec.md - */ -describe("Scenario: Graph TotalReward Validation", () => { - let ctx: ProtocolContext; - let snapshot: string; - - let stEthHolder: HardhatEthersSigner; - let stranger: HardhatEthersSigner; - - let simulator: GraphSimulator; - let initialState: SimulatorInitialState; - let depositCount: bigint; - - before(async () => { - ctx = await getProtocolContext(); - - [stEthHolder, stranger] = await ethers.getSigners(); - await updateBalance(stranger.address, ether("100000000")); - await updateBalance(stEthHolder.address, ether("100000000")); - - snapshot = await Snapshot.take(); - - // Capture initial chain state first - initialState = await captureChainState(ctx); - - // Initialize simulator with treasury address - simulator = new GraphSimulator(initialState.treasuryAddress); - - log.debug("Graph Simulator initialized", { - "Total Pooled Ether": formatEther(initialState.totalPooledEther), - "Total Shares": initialState.totalShares.toString(), - "Treasury Address": initialState.treasuryAddress, - "Staking Modules": initialState.stakingModuleAddresses.length, - }); - - // Setup protocol state - await removeStakingLimit(ctx); - await setStakingLimit(ctx, ether("200000"), ether("20")); - }); - - after(async () => await Snapshot.restore(snapshot)); - - beforeEach(bailOnFailure); - - it("Should finalize withdrawal queue and prepare protocol", async () => { - const { lido } = ctx.contracts; - - // Deposit some ETH to have stETH for testing - const stEthHolderAmount = ether("1000"); - await lido.connect(stEthHolder).submit(ZeroAddress, { value: stEthHolderAmount }); - - const stEthHolderBalance = await lido.balanceOf(stEthHolder.address); - expect(stEthHolderBalance).to.approximately(stEthHolderAmount, 10n, "stETH balance increased"); - - await finalizeWQViaElVault(ctx); - }); - - it("Should have at least 3 node operators in every module", async () => { - await norSdvtEnsureOperators(ctx, ctx.contracts.nor, 3n, 5n); - expect(await ctx.contracts.nor.getNodeOperatorsCount()).to.be.at.least(3n); - - await norSdvtEnsureOperators(ctx, ctx.contracts.sdvt, 3n, 5n); - expect(await ctx.contracts.sdvt.getNodeOperatorsCount()).to.be.at.least(3n); - }); - - it("Should deposit ETH and stake to modules", async () => { - const { lido, stakingRouter, depositSecurityModule } = ctx.contracts; - - // Submit more ETH for deposits - await lido.connect(stEthHolder).submit(ZeroAddress, { value: ether("3200") }); - - const dsmSigner = await impersonate(depositSecurityModule.address, ether("100")); - const stakingModules = (await stakingRouter.getStakingModules()).filter((m) => m.id === 1n); - depositCount = 0n; - - const MAX_DEPOSIT = 150n; - const ZERO_HASH = new Uint8Array(32).fill(0); - - for (const module of stakingModules) { - const depositTx = await lido.connect(dsmSigner).deposit(MAX_DEPOSIT, module.id, ZERO_HASH); - const depositReceipt = (await depositTx.wait()) as ContractTransactionReceipt; - const unbufferedEvent = ctx.getEvents(depositReceipt, "Unbuffered")[0]; - const unbufferedAmount = unbufferedEvent?.args[0] || 0n; - const deposits = unbufferedAmount / ether("32"); - - depositCount += deposits; - } - - expect(depositCount).to.be.gt(0n, "No deposits applied"); - }); - - it("Should compute TotalReward correctly for first oracle report", async () => { - const stateBefore = await capturePoolState(ctx); - - const clDiff = ether("32") * depositCount + ether("0.001"); - const reportData: Partial = { - clDiff, - clAppearedValidators: depositCount, - }; - - await advanceChainTime(INTERVAL_12_HOURS); - - const { reportTx } = await report(ctx, reportData); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - - const block = await ethers.provider.getBlock(receipt.blockNumber); - const blockTimestamp = BigInt(block!.timestamp); - - const store = createEntityStore(); - const result = processTransaction(receipt, ctx, store, blockTimestamp, initialState.treasuryAddress); - - const stateAfter = await capturePoolState(ctx); - - expect(result.hadProfitableReport).to.be.true; - expect(result.totalRewards.size).to.equal(1); - - const computed = result.totalRewards.get(receipt.hash); - expect(computed).to.not.be.undefined; - - const expected = deriveExpectedTotalReward(receipt, ctx, initialState.treasuryAddress); - expect(expected).to.not.be.null; - - await mEqual([ - [computed!.id.toLowerCase(), receipt.hash.toLowerCase()], - [computed!.block, BigInt(receipt.blockNumber)], - [computed!.blockTime, blockTimestamp], - [computed!.transactionHash.toLowerCase(), receipt.hash.toLowerCase()], - [computed!.transactionIndex, BigInt(receipt.index)], - [computed!.logIndex, expected!.logIndex], - [computed!.totalPooledEtherBefore, expected!.totalPooledEtherBefore], - [computed!.totalPooledEtherAfter, expected!.totalPooledEtherAfter], - [computed!.totalSharesBefore, expected!.totalSharesBefore], - [computed!.totalSharesAfter, expected!.totalSharesAfter], - [computed!.shares2mint, expected!.shares2mint], - [computed!.timeElapsed, expected!.timeElapsed], - [computed!.mevFee, expected!.mevFee], - [computed!.totalRewardsWithFees, expected!.totalRewardsWithFees], - [computed!.totalRewards, expected!.totalRewards], - [computed!.totalFee, expected!.totalFee], - [computed!.treasuryFee, expected!.treasuryFee], - [computed!.operatorsFee, expected!.operatorsFee], - [computed!.sharesToTreasury, expected!.sharesToTreasury], - [computed!.sharesToOperators, expected!.sharesToOperators], - [computed!.apr, expected!.apr], - [computed!.aprRaw, expected!.aprRaw], - [computed!.aprBeforeFees, expected!.aprBeforeFees], - [computed!.feeBasis, expected!.feeBasis], - [computed!.treasuryFeeBasisPoints, expected!.treasuryFeeBasisPoints], - [computed!.operatorsFeeBasisPoints, expected!.operatorsFeeBasisPoints], - [computed!.shares2mint, computed!.sharesToTreasury + computed!.sharesToOperators], - [computed!.totalFee, computed!.treasuryFee + computed!.operatorsFee], - [computed!.totalPooledEtherBefore, stateBefore.totalPooledEther], - [computed!.totalSharesBefore, stateBefore.totalShares], - [computed!.totalPooledEtherAfter, stateAfter.totalPooledEther], - [computed!.totalSharesAfter, stateAfter.totalShares], - ]); - }); - - it("Should compute TotalReward correctly for second oracle report", async () => { - const stateBefore = await capturePoolState(ctx); - - const clDiff = ether("0.005"); - const reportData: Partial = { clDiff }; - - await advanceChainTime(INTERVAL_12_HOURS); - - const { reportTx } = await report(ctx, reportData); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - - const block = await ethers.provider.getBlock(receipt.blockNumber); - const blockTimestamp = BigInt(block!.timestamp); - const result = simulator.processTransaction(receipt, ctx, blockTimestamp); - - const stateAfter = await capturePoolState(ctx); - - await mEqual([ - [result.hadProfitableReport, true], - [result.totalRewards.size, 1], - ]); - - const computed = result.totalRewards.get(receipt.hash); - expect(computed).to.not.be.undefined; - - const expected = deriveExpectedTotalReward(receipt, ctx, initialState.treasuryAddress); - expect(expected).to.not.be.null; - - await mEqual([ - [computed!.id.toLowerCase(), receipt.hash.toLowerCase()], - [computed!.block, BigInt(receipt.blockNumber)], - [computed!.blockTime, blockTimestamp], - [computed!.transactionHash.toLowerCase(), receipt.hash.toLowerCase()], - [computed!.transactionIndex, BigInt(receipt.index)], - [computed!.logIndex, expected!.logIndex], - [computed!.totalPooledEtherBefore, expected!.totalPooledEtherBefore], - [computed!.totalPooledEtherAfter, expected!.totalPooledEtherAfter], - [computed!.totalSharesBefore, expected!.totalSharesBefore], - [computed!.totalSharesAfter, expected!.totalSharesAfter], - [computed!.shares2mint, expected!.shares2mint], - [computed!.timeElapsed, expected!.timeElapsed], - [computed!.mevFee, expected!.mevFee], - [computed!.totalRewardsWithFees, expected!.totalRewardsWithFees], - [computed!.totalRewards, expected!.totalRewards], - [computed!.totalFee, expected!.totalFee], - [computed!.treasuryFee, expected!.treasuryFee], - [computed!.operatorsFee, expected!.operatorsFee], - [computed!.sharesToTreasury, expected!.sharesToTreasury], - [computed!.sharesToOperators, expected!.sharesToOperators], - [computed!.apr, expected!.apr], - [computed!.aprRaw, expected!.aprRaw], - [computed!.aprBeforeFees, expected!.aprBeforeFees], - [computed!.feeBasis, expected!.feeBasis], - [computed!.treasuryFeeBasisPoints, expected!.treasuryFeeBasisPoints], - [computed!.operatorsFeeBasisPoints, expected!.operatorsFeeBasisPoints], - [computed!.shares2mint, computed!.sharesToTreasury + computed!.sharesToOperators], - [computed!.totalFee, computed!.treasuryFee + computed!.operatorsFee], - [computed!.totalPooledEtherBefore, stateBefore.totalPooledEther], - [computed!.totalSharesBefore, stateBefore.totalShares], - [computed!.totalPooledEtherAfter, stateAfter.totalPooledEther], - [computed!.totalSharesAfter, stateAfter.totalShares], - ]); - }); - - it("Should verify event processing order", async () => { - // This test validates that events are processed in the correct order - // by examining the logs from the last oracle report - const clDiff = ether("0.002"); - const reportData: Partial = { clDiff }; - - await advanceChainTime(INTERVAL_12_HOURS); - - const { reportTx } = await report(ctx, reportData); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - - const logs = extractAllLogs(receipt, ctx); - - const ethDistributedIdx = logs.findIndex((l) => l.name === "ETHDistributed"); - const tokenRebasedIdx = logs.findIndex((l) => l.name === "TokenRebased"); - - expect(ethDistributedIdx).to.be.gte(0, "ETHDistributed event not found"); - expect(tokenRebasedIdx).to.be.gte(0, "TokenRebased event not found"); - expect(ethDistributedIdx).to.be.lt(tokenRebasedIdx, "ETHDistributed should come before TokenRebased"); - - const transferEvents = logs.filter( - (l) => l.name === "Transfer" && l.logIndex > ethDistributedIdx && l.logIndex < tokenRebasedIdx, - ); - const transferSharesEvents = logs.filter( - (l) => l.name === "TransferShares" && l.logIndex > ethDistributedIdx && l.logIndex < tokenRebasedIdx, - ); - - // There should be at least some transfer events for fee distribution - expect(transferEvents.length).to.be.gte(0, "Expected Transfer events for fee distribution"); - expect(transferSharesEvents.length).to.be.gte(0, "Expected TransferShares events for fee distribution"); - }); - - it("Should query TotalRewards with filtering and pagination", async () => { - // Execute another oracle report to have more data - const clDiff = ether("0.003"); - const reportData: Partial = { clDiff }; - - await advanceChainTime(INTERVAL_12_HOURS); - - const { reportTx } = await report(ctx, reportData); - const receipt = (await reportTx!.wait()) as ContractTransactionReceipt; - - const block = await ethers.provider.getBlock(receipt.blockNumber); - const blockTimestamp = BigInt(block!.timestamp); - - simulator.processTransaction(receipt, ctx, blockTimestamp); - - const totalCount = simulator.countTotalRewards(0n); - expect(totalCount).to.be.gte(2, "Should have at least 2 TotalReward entities"); - - const queryResult = simulator.queryTotalRewards({ - skip: 0, - limit: 10, - blockFrom: 0n, - orderBy: "blockTime", - orderDirection: "asc", - }); - expect(queryResult.length).to.be.gte(2); - - for (let i = 1; i < queryResult.length; i++) { - expect(queryResult[i].blockTime).to.be.gte( - queryResult[i - 1].blockTime, - "Results should be ordered by blockTime ascending", - ); - } - - const firstBlock = queryResult[0].block; - const filteredResult = simulator.queryTotalRewards({ - skip: 0, - limit: 10, - blockFrom: firstBlock, // Only get entities AFTER the first block - orderBy: "blockTime", - orderDirection: "asc", - }); - - for (const result of filteredResult) { - expect(result.block).to.be.gt(firstBlock, "Filtered results should have block > blockFrom"); - } - - const latest = simulator.getLatestTotalReward(); - - expect(latest).to.not.be.null; - expect(latest!.blockTime).to.equal(queryResult[queryResult.length - 1].blockTime); - - const byId = simulator.getTotalRewardById(receipt.hash); - expect(byId).to.not.be.null; - expect(byId!.id.toLowerCase()).to.equal(receipt.hash.toLowerCase()); - }); -}); From 989a9970bed7f805baaaefb7467706f0c29b453b Mon Sep 17 00:00:00 2001 From: Artyom Veremeenko Date: Thu, 18 Dec 2025 14:48:27 +0300 Subject: [PATCH 07/10] test(graph): support mainnet --- package.json | 7 +- test/graph/README.md | 120 ++++++++------- test/graph/entities-scenario.integration.ts | 153 ++++++++++++++++---- test/graph/simulator/handlers/lido.ts | 11 +- test/graph/utils/state-capture.ts | 24 ++- 5 files changed, 218 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index 6954238c56..85cc3b2fee 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,12 @@ "test:trace": "hardhat test test/**/*.test.ts --trace --disabletracer", "test:fulltrace": "hardhat test test/**/*.test.ts --fulltrace --disabletracer", "test:watch": "SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true hardhat watch test", - "test:integration": "MODE=forking hardhat test test/integration/**/*.ts", + "test:integration": "yarn test:integration:helper test/integration/**/*.ts", + "test:integration:helper": "SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true SKIP_INTERFACES_CHECK=true SKIP_GAS_REPORT=true MODE=forking hardhat test ", "test:integration:trace": "MODE=forking hardhat test test/integration/**/*.ts --trace --disabletracer", "test:integration:fulltrace": "MODE=forking hardhat test test/integration/**/*.ts --fulltrace --disabletracer", - "test:integration:upgrade": "GAS_LIMIT=16000000 STEPS_FILE=upgrade/steps-mock-voting.json yarn test:integration:upgrade:helper test/integration/**/*.ts", - "test:integration:upgrade:helper": "NETWORK_STATE_FILE=deployed-mainnet.json MODE=forking UPGRADE=true GAS_PRIORITY_FEE=1 GAS_MAX_FEE=100 DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 hardhat test --trace --disabletracer", + "test:integration:upgrade": "GAS_LIMIT=16000000 yarn test:integration:upgrade:helper test/integration/**/*.ts", + "test:integration:upgrade:helper": "NETWORK_STATE_FILE=deployed-mainnet.json STEPS_FILE=upgrade/steps-mock-voting.json MODE=forking SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true SKIP_INTERFACES_CHECK=true SKIP_GAS_REPORT=true UPGRADE=true GAS_PRIORITY_FEE=1 GAS_MAX_FEE=100 DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 hardhat test --trace --disabletracer", "test:integration:upgrade-template": "cp deployed-mainnet.json deployed-mainnet-upgrade.json && NETWORK_STATE_FILE=deployed-mainnet-upgrade.json UPGRADE_PARAMETERS_FILE=scripts/upgrade/upgrade-params-mainnet.toml MODE=forking TEMPLATE_TEST=true GAS_PRIORITY_FEE=1 GAS_MAX_FEE=100 DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 hardhat test test/integration/upgrade/*.ts --fulltrace --disabletracer", "test:integration:scratch": "DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 SKIP_INTERFACES_CHECK=true SKIP_CONTRACT_SIZE=true SKIP_GAS_REPORT=true GENESIS_TIME=1639659600 GAS_PRIORITY_FEE=1 GAS_MAX_FEE=100 yarn test:integration:scratch:helper test/integration/**/*.ts", "test:integration:scratch:helper": "MODE=scratch hardhat test", diff --git a/test/graph/README.md b/test/graph/README.md index 0e7b842064..6624a65a4e 100644 --- a/test/graph/README.md +++ b/test/graph/README.md @@ -1,24 +1,49 @@ # Graph tests intro -These graph integration tests are intended to simulate calculations done by the Graph based on -(mostly) events and compare with the actual on-chain state after a number of transactions. +## Problem frame -## Scope & Limitations +Subgraph mappings are event-driven and can silently drift from on-chain truth (ordering, missing events, legacy branches, rounding, network quirks). These integration tests provide a deterministic way to replay a multi-transaction scenario, simulate the subgraph entity updates from events, and prove (or falsify) that the resulting entity state matches on-chain state at the same block. -- **V3 only**: Tests only V3 (post-V2) code paths; historical sync is skipped -- **Initial state from chain**: Simulator initializes from current on-chain state at test start -- **Legacy fields omitted**: V1 fields (`insuranceFee`, `dust`, `sharesToInsuranceFund`, etc.) are not implemented as they're unused since V2 -- **OracleCompleted skipped**: Legacy entity tracking replaced by `TokenRebased.timeElapsed` +## Objective + +Detect mismatches between: + +- Simulated entity state (derived from events via GraphSimulator.processTransaction()), and +- On-chain reads at the corresponding post-transaction block. + +The goal is bug discovery. + +## Scope and non-goals + +**In scope** + +- V3 (post-V2) code paths only. +- Simulation starts from **current fork state** at test start (no historical indexing). +- Entity correctness for: submissions, transfers, oracle reports, external share mints/burns, withdrawals finalization. + +**Out of scope** + +- Pre-V2 / legacy entities and fields (`insuranceFee`, `dust`, `sharesToInsuranceFund`, etc.). +- `OracleCompleted` legacy tracking (replaced by `TokenRebased.timeElapsed`). ## Test Environment +- Mainnet via forking - Hoodi testnet via forking - Uses `lib/protocol/` helpers and `test/suite/` utilities +## How to + +To run graph integration tests (assuming localhost fork is running) do: + +- Mainnet: `RPC_URL=http://localhost:9122 yarn test:integration:upgrade:helper test/graph/*.ts` +- Hoodi: `RPC_URL=http://localhost:9123 NETWORK_STATE_FILE=deployed-hoodi.json yarn test:integration:helper test/graph/*.ts` + ## Success Criteria +- **Totals consistency**: Simulator's `Totals` must match on-chain `lido.getTotalPooledEther()` and `lido.getTotalShares()` +- **Shares consistency**: Simulator's `Shares` entity for each address must match on-chain `lido.sharesOf(address)` (delta from initial state) - **Exact match**: All `bigint` values must match exactly (no tolerance for rounding) -- **Entity consistency**: Simulator's `Totals` must match on-chain `lido.getTotalPooledEther()` and `lido.getTotalShares()` - **No validation warnings**: `shares2mint_mismatch` and `totals_state_mismatch` warnings indicate bugs ## Transactions Scenario @@ -64,7 +89,7 @@ The test performs 32 interleaved actions across 6 phases: ### Validation Approach -Each transaction is processed through `GraphSimulator.processTransaction()` which parses events and updates entities. Validation helpers check: +Each transaction is processed through `GraphSimulator.processTransactionWithV3()` which parses events and updates entities. Validation helpers check: - **`validateSubmission`**: Verifies `LidoSubmission` entity fields (`sender`, `amount`, `referral`, `shares > 0`) - **`validateTransfer`**: Verifies `LidoTransfer` entity fields and share balance arithmetic: @@ -74,7 +99,23 @@ Each transaction is processed through `GraphSimulator.processTransaction()` whic - `shares2mint == sharesToTreasury + sharesToOperators` - `totalFee == treasuryFee + operatorsFee` - For non-profitable (zero/negative), verifies no `TotalReward` is created -- **`validateGlobalConsistency`**: Compares simulator's `Totals` entity against on-chain `lido.getTotalPooledEther()` and `lido.getTotalShares()` +- **`validateGlobalConsistency`**: Compares simulator state against on-chain state: + - `Totals.totalPooledEther` vs `lido.getTotalPooledEther()` + - `Totals.totalShares` vs `lido.getTotalShares()` + - For each `Shares` entity: `simulatorDelta + initialShares` vs `lido.sharesOf(address)` + +### Address Pre-capture + +At test setup, initial share balances are captured for all addresses that may receive shares during the test: + +- Treasury address +- Staking module addresses (from `stakingRouter.getStakingModules()`) +- Staking reward recipients (from `stakingRouter.getStakingRewardsDistribution()`) +- CSM address (Community Staking Module on Hoodi) +- Protocol contracts: Burner, WithdrawalQueue, Accounting, StakingRouter, VaultHub +- Test user addresses (user1-5) + +This allows strict validation of Shares entities by computing: `expectedShares = simulatorDelta + initialShares` ## Specifics @@ -87,57 +128,30 @@ Subgraph calculates and stores various data structures called entities. Some of ### Totals (cumulative) -Notable fields: - -- totalPooledEther -- totalShares - -Events used when: - -1. User submits ether - -- `Lido.Submitted`: `amount` - -2. Oracle reports - -- `Lido.TokenRebased`: `postTotalShares`, `postTotalEther` -- `Lido.SharesBurnt.sharesAmount` - -3. StETH minted on VaultHub - -- `Lido.ExternalSharesMinted`: increases `totalShares` by `amountOfShares`, updates `totalPooledEther` via contract read +**Fields**: `totalPooledEther`, `totalShares` -4. External shares burnt (emitted by `VaultHub.burnShares`, `Lido.rebalanceExternalEtherToInternal()`, `Lido.internalizeExternalBadDebt`) +**Update sources** -- `Lido.ExternalSharesBurnt`: updates `totalPooledEther` via contract read +- Submission: `Lido.Submitted.amount` +- Oracle report: `Lido.TokenRebased.postTotalShares`, `postTotalEther`, plus `Lido.SharesBurnt.sharesAmount` +- VaultHub mint: `Lido.ExternalSharesMinted` (shares delta + pooled ether via contract read) +- External burn: `Lido.ExternalSharesBurnt` (pooled ether via contract read) ### Shares (cumulative) -Notable fields: +**Fields**: `id` (holder), `shares` -- id (holder address as Bytes) -- shares +**Update sources** -When updated: - -1. User submits ether +- Submission mint: `Lido.Transfer` (0x0→user) + `Lido.TransferShares` +- User transfer: `Lido.Transfer` + `Lido.TransferShares` +- Oracle fee mints: `Lido.Transfer` (0x0→Treasury / SR modules) + `Lido.TransferShares` +- Burn finalization: `Lido.SharesBurnt` +- V3 external mints: `Lido.Transfer` (0x0→receiver) triggered by `ExternalSharesMinted` -- `Lido.Transfer` (from 0x0 to user): increases user's shares -- `Lido.TransferShares` (from 0x0 to user): provides shares value - -2. User transfers stETH - -- `Lido.Transfer` (from user to recipient): decreases sender's shares, increases recipient's shares -- `Lido.TransferShares` (from user to recipient): provides shares value - -3. Oracle reports rewards - -- `Lido.Transfer` (from 0x0 to Treasury): increases Treasury's shares -- `Lido.Transfer` (from 0x0 to SR modules): increases SR module's shares - -4. Shares are burnt (withdrawal finalization) +**Validation**: Simulator tracks share deltas from events. Final balance = `simulatorDelta + initialShares` must equal `lido.sharesOf(address)` -- `Lido.SharesBurnt`: decreases account's shares +**Note**: `ExternalSharesMinted` only updates `Totals`, not `Shares`. The accompanying `Transfer` event updates per-address shares. ### LidoTransfer (immutable) @@ -193,7 +207,7 @@ Other entities used: ### TotalReward (immutable) -One per oracle report. +One per oracle report. Created iff profitable. Notable fields: diff --git a/test/graph/entities-scenario.integration.ts b/test/graph/entities-scenario.integration.ts index 93eb65319c..beec0bb425 100644 --- a/test/graph/entities-scenario.integration.ts +++ b/test/graph/entities-scenario.integration.ts @@ -63,6 +63,9 @@ describe("Comprehensive Mixed Scenario", () => { let simulator: GraphSimulator; let initialState: SimulatorInitialState; + // Initial shares for addresses (captured at test start for validation) + const initialShares: Map = new Map(); + // Counters for statistics let depositCount = 0; let transferCount = 0; @@ -121,11 +124,65 @@ describe("Comprehensive Mixed Scenario", () => { simulator = new GraphSimulator(initialState.treasuryAddress); simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); + // Capture initial shares for all relevant addresses + // Include: treasury, staking modules, reward recipients, protocol contracts, users + const { lido, locator, withdrawalQueue, accounting, stakingRouter } = ctx.contracts; + const burnerAddress = await locator.burner(); + const wqAddress = await withdrawalQueue.getAddress(); + const accountingAddress = await accounting.getAddress(); + const stakingRouterAddress = await stakingRouter.getAddress(); + const vaultHubAddress = await locator.vaultHub(); + + // Get all staking modules including CSM if registered + const allModules = await stakingRouter.getStakingModules(); + const moduleAddresses = allModules.map((m) => m.stakingModuleAddress); + + // Some staking modules (like CSM) have a separate Fee Distributor contract that receives rewards + // We need to capture these addresses as they receive transfers during oracle reports + const feeDistributorAddresses: string[] = []; + for (const module of allModules) { + try { + const moduleContract = new ethers.Contract( + module.stakingModuleAddress, + ["function FEE_DISTRIBUTOR() view returns (address)"], + ethers.provider, + ); + const feeDistributor = await moduleContract.FEE_DISTRIBUTOR(); + if (feeDistributor && feeDistributor !== ZeroAddress) { + feeDistributorAddresses.push(feeDistributor); + } + } catch { + // Module doesn't have FEE_DISTRIBUTOR (e.g., NOR, SDVT) + } + } + + const addressesToCapture = [ + initialState.treasuryAddress, + ...initialState.stakingRelatedAddresses, + ...moduleAddresses, + ...feeDistributorAddresses, + burnerAddress, + wqAddress, + accountingAddress, + stakingRouterAddress, + vaultHubAddress, + user1.address, + user2.address, + user3.address, + user4.address, + user5.address, + ]; + for (const addr of addressesToCapture) { + const shares = await lido.sharesOf(addr); + initialShares.set(addr.toLowerCase(), shares); + } + log.info("Setup complete", { "Vault1": await vault1.getAddress(), "Vault2": await vault2.getAddress(), "Total Pooled Ether": formatEther(initialState.totalPooledEther), "Total Shares": initialState.totalShares.toString(), + "Addresses captured for Shares validation": addressesToCapture.length, }); }); @@ -137,7 +194,7 @@ describe("Comprehensive Mixed Scenario", () => { // Helper Functions // ============================================================================ - async function processAndValidate(receipt: ContractTransactionReceipt, description: string) { + async function processTx(receipt: ContractTransactionReceipt, description: string) { const block = await ethers.provider.getBlock(receipt.blockNumber); const blockTimestamp = BigInt(block!.timestamp); @@ -272,17 +329,38 @@ describe("Comprehensive Mixed Scenario", () => { } async function validateGlobalConsistency() { + const { lido } = ctx.contracts; const totals = simulator.getTotals(); expect(totals, "Totals entity should exist").to.not.be.null; - // Verify against on-chain state + // Verify Totals against on-chain state const poolState = await capturePoolState(ctx); expect(totals!.totalPooledEther).to.equal(poolState.totalPooledEther, "Totals.totalPooledEther should match chain"); expect(totals!.totalShares).to.equal(poolState.totalShares, "Totals.totalShares should match chain"); + // Verify all Shares entities against on-chain state + // The simulator tracks share deltas from events, so we need to add initial shares + // All addresses should have been pre-captured (treasury, reward recipients, users, burner, WQ) + const allShares = simulator.getAllShares(); + let validatedCount = 0; + for (const [address, sharesEntity] of allShares) { + const initialSharesForAddress = initialShares.get(address.toLowerCase()); + expect(initialSharesForAddress, `Address ${address} was not pre-captured - add it to addressesToCapture in setup`) + .to.not.be.undefined; + + const onChainShares = await lido.sharesOf(address); + const expectedShares = sharesEntity.shares + initialSharesForAddress!; + expect(expectedShares).to.equal( + onChainShares, + `Shares for ${address} should match chain (simulator: ${sharesEntity.shares}, initial: ${initialSharesForAddress}, expected: ${expectedShares}, on-chain: ${onChainShares})`, + ); + validatedCount++; + } + log.debug("Global consistency check passed", { "Total Pooled Ether": formatEther(totals!.totalPooledEther), "Total Shares": totals!.totalShares.toString(), + "Shares Entities Validated": validatedCount, }); } @@ -298,7 +376,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user1).submit(ZeroAddress, { value: amount }); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "user1 deposit 100 ETH"); + await processTx(receipt, "user1 deposit 100 ETH"); await validateSubmission(receipt, user1.address, amount, ZeroAddress); depositCount++; @@ -311,7 +389,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user2).submit(user1.address, { value: amount }); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "user2 deposit 50 ETH with referral"); + await processTx(receipt, "user2 deposit 50 ETH with referral"); await validateSubmission(receipt, user2.address, amount, user1.address); depositCount++; @@ -340,7 +418,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user3).submit(ZeroAddress, { value: amount }); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "user3 deposit 200 ETH"); + await processTx(receipt, "user3 deposit 200 ETH"); await validateSubmission(receipt, user3.address, amount); depositCount++; @@ -353,7 +431,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user1).transfer(user2.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Transfer user1 -> user2"); + await processTx(receipt, "Transfer user1 -> user2"); await validateTransfer(receipt, user1.address, user2.address); transferCount++; @@ -371,12 +449,12 @@ describe("Comprehensive Mixed Scenario", () => { // Report vault data to make it fresh and process through simulator const vaultReportTx = await reportVaultDataWithProof(ctx, vault1); const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; - await processAndValidate(vaultReportReceipt, "Vault1 report"); + await processTx(vaultReportReceipt, "Vault1 report"); const tx = await dashboard1.mintStETH(user3.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Vault1 mint 50 stETH"); + await processTx(receipt, "Vault1 mint 50 stETH"); // Verify ExternalSharesMinted event const logs = extractAllLogs(receipt, ctx); @@ -411,12 +489,12 @@ describe("Comprehensive Mixed Scenario", () => { // Report vault data to make it fresh and process through simulator const vaultReportTx = await reportVaultDataWithProof(ctx, vault2); const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; - await processAndValidate(vaultReportReceipt, "Vault2 report"); + await processTx(vaultReportReceipt, "Vault2 report"); const tx = await dashboard2.mintStETH(user3.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Vault2 mint 30 stETH"); + await processTx(receipt, "Vault2 mint 30 stETH"); const logs = extractAllLogs(receipt, ctx); const extMintEvent = logs.find((l) => l.name === "ExternalSharesMinted"); @@ -432,7 +510,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user4).submit(ZeroAddress, { value: amount }); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "user4 deposit 25 ETH"); + await processTx(receipt, "user4 deposit 25 ETH"); await validateSubmission(receipt, user4.address, amount); depositCount++; @@ -445,7 +523,7 @@ describe("Comprehensive Mixed Scenario", () => { // Report vault data to make it fresh and process through simulator const vaultReportTx = await reportVaultDataWithProof(ctx, vault1); const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; - await processAndValidate(vaultReportReceipt, "Vault1 report"); + await processTx(vaultReportReceipt, "Vault1 report"); // Approve stETH for burning await lido.connect(user3).approve(await dashboard1.getAddress(), amount); @@ -453,7 +531,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await dashboard1.burnStETH(amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Vault1 burn 20 stETH"); + await processTx(receipt, "Vault1 burn 20 stETH"); // Verify SharesBurnt event const logs = extractAllLogs(receipt, ctx); @@ -477,6 +555,9 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await withdrawalQueue.connect(user1).requestWithdrawals([amount], user1.address); const receipt = (await tx.wait()) as ContractTransactionReceipt; + // Process the withdrawal request transaction (includes Transfer from user to WQ) + await processTx(receipt, "user1 withdrawal request 30 ETH"); + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; const requestId = withdrawalRequestedEvent?.args?.requestId; expect(requestId).to.not.be.undefined; @@ -495,6 +576,9 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await withdrawalQueue.connect(user2).requestWithdrawals([amount], user2.address); const receipt = (await tx.wait()) as ContractTransactionReceipt; + // Process the withdrawal request transaction (includes Transfer from user to WQ) + await processTx(receipt, "user2 withdrawal request 20 ETH"); + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; const requestId = withdrawalRequestedEvent?.args?.requestId; expect(requestId).to.not.be.undefined; @@ -536,7 +620,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user5).submit(ZeroAddress, { value: amount }); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "user5 deposit 500 ETH"); + await processTx(receipt, "user5 deposit 500 ETH"); await validateSubmission(receipt, user5.address, amount); depositCount++; @@ -549,7 +633,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user3).transfer(user4.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Transfer user3 -> user4"); + await processTx(receipt, "Transfer user3 -> user4"); await validateTransfer(receipt, user3.address, user4.address); transferCount++; @@ -584,12 +668,12 @@ describe("Comprehensive Mixed Scenario", () => { // Report vault data to make it fresh and process through simulator const vaultReportTx = await reportVaultDataWithProof(ctx, vault2); const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; - await processAndValidate(vaultReportReceipt, "Vault2 report"); + await processTx(vaultReportReceipt, "Vault2 report"); const tx = await dashboard2.mintStETH(user3.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Vault2 mint 100 stETH"); + await processTx(receipt, "Vault2 mint 100 stETH"); v3MintCount++; }); @@ -602,6 +686,9 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await withdrawalQueue.connect(user1).requestWithdrawals([amount], user1.address); const receipt = (await tx.wait()) as ContractTransactionReceipt; + // Process the withdrawal request transaction (includes Transfer from user to WQ) + await processTx(receipt, "user1 withdrawal request 50 ETH"); + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; const requestId = withdrawalRequestedEvent?.args?.requestId; expect(requestId).to.not.be.undefined; @@ -638,7 +725,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user4).transfer(user1.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Transfer user4 -> user1 (near full balance)"); + await processTx(receipt, "Transfer user4 -> user1 (near full balance)"); await validateTransfer(receipt, user4.address, user1.address); transferCount++; @@ -656,12 +743,12 @@ describe("Comprehensive Mixed Scenario", () => { // Report vault data to make it fresh and process through simulator const vaultReportTx = await reportVaultDataWithProof(ctx, vault1); const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; - await processAndValidate(vaultReportReceipt, "Vault1 report"); + await processTx(vaultReportReceipt, "Vault1 report"); const tx = await dashboard1.mintStETH(user3.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Vault1 mint 75 stETH"); + await processTx(receipt, "Vault1 mint 75 stETH"); v3MintCount++; }); @@ -673,7 +760,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user2).submit(ZeroAddress, { value: amount }); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "user2 deposit 80 ETH"); + await processTx(receipt, "user2 deposit 80 ETH"); await validateSubmission(receipt, user2.address, amount); depositCount++; @@ -686,14 +773,14 @@ describe("Comprehensive Mixed Scenario", () => { // Report vault data to make it fresh and process through simulator const vaultReportTx = await reportVaultDataWithProof(ctx, vault2); const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; - await processAndValidate(vaultReportReceipt, "Vault2 report"); + await processTx(vaultReportReceipt, "Vault2 report"); await lido.connect(user3).approve(await dashboard2.getAddress(), amount); const tx = await dashboard2.burnStETH(amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Vault2 burn 50 stETH"); + await processTx(receipt, "Vault2 burn 50 stETH"); v3BurnCount++; }); @@ -706,6 +793,9 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await withdrawalQueue.connect(user3).requestWithdrawals([amount], user3.address); const receipt = (await tx.wait()) as ContractTransactionReceipt; + // Process the withdrawal request transaction (includes Transfer from user to WQ) + await processTx(receipt, "user3 withdrawal request 40 ETH"); + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; const requestId = withdrawalRequestedEvent?.args?.requestId; expect(requestId).to.not.be.undefined; @@ -750,7 +840,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user1).submit(user5.address, { value: amount }); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "user1 deposit 30 ETH with referral"); + await processTx(receipt, "user1 deposit 30 ETH with referral"); await validateSubmission(receipt, user1.address, amount, user5.address); depositCount++; @@ -763,7 +853,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user1).transfer(user3.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Transfer user1 -> user3"); + await processTx(receipt, "Transfer user1 -> user3"); await validateTransfer(receipt, user1.address, user3.address); transferCount++; @@ -776,14 +866,14 @@ describe("Comprehensive Mixed Scenario", () => { // Report vault data to make it fresh and process through simulator const vaultReportTx = await reportVaultDataWithProof(ctx, vault1); const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; - await processAndValidate(vaultReportReceipt, "Vault1 report"); + await processTx(vaultReportReceipt, "Vault1 report"); await lido.connect(user3).approve(await dashboard1.getAddress(), amount); const tx = await dashboard1.burnStETH(amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Vault1 burn 30 stETH"); + await processTx(receipt, "Vault1 burn 30 stETH"); v3BurnCount++; }); @@ -796,6 +886,9 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await withdrawalQueue.connect(user5).requestWithdrawals([amount], user5.address); const receipt = (await tx.wait()) as ContractTransactionReceipt; + // Process the withdrawal request transaction (includes Transfer from user to WQ) + await processTx(receipt, "user5 withdrawal request 100 ETH"); + const withdrawalRequestedEvent = ctx.getEvents(receipt, "WithdrawalRequested")[0]; const requestId = withdrawalRequestedEvent?.args?.requestId; expect(requestId).to.not.be.undefined; @@ -827,7 +920,7 @@ describe("Comprehensive Mixed Scenario", () => { const tx = await lido.connect(user2).transfer(user5.address, amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Transfer user2 -> user5"); + await processTx(receipt, "Transfer user2 -> user5"); await validateTransfer(receipt, user2.address, user5.address); transferCount++; @@ -840,14 +933,14 @@ describe("Comprehensive Mixed Scenario", () => { // Report vault data to make it fresh and process through simulator const vaultReportTx = await reportVaultDataWithProof(ctx, vault2); const vaultReportReceipt = (await vaultReportTx.wait()) as ContractTransactionReceipt; - await processAndValidate(vaultReportReceipt, "Vault2 report"); + await processTx(vaultReportReceipt, "Vault2 report"); await lido.connect(user3).approve(await dashboard2.getAddress(), amount); const tx = await dashboard2.burnStETH(amount); const receipt = (await tx.wait()) as ContractTransactionReceipt; - await processAndValidate(receipt, "Vault2 burn 30 stETH"); + await processTx(receipt, "Vault2 burn 30 stETH"); v3BurnCount++; }); diff --git a/test/graph/simulator/handlers/lido.ts b/test/graph/simulator/handlers/lido.ts index 3388e3e8c9..cdb4088136 100644 --- a/test/graph/simulator/handlers/lido.ts +++ b/test/graph/simulator/handlers/lido.ts @@ -931,6 +931,10 @@ export interface ExternalSharesMintedResult { /** * Handle ExternalSharesMinted event (V3) - updates Totals when VaultHub mints external shares * + * IMPORTANT: This handler only updates Totals (totalShares and totalPooledEther). + * The per-address Shares entity is updated by the accompanying Transfer event + * (from 0x0 to receiver) which is handled by handleTransfer. This avoids double-counting. + * * Reference: lido-subgraph/src/LidoV3.ts handleExternalSharesMinted() lines 8-16 * * @param event - The ExternalSharesMinted event @@ -962,10 +966,9 @@ export async function handleExternalSharesMinted( saveTotals(store, totals); - // Update receiver's shares - const receiverShares = loadSharesEntity(store, receiver, true)!; - receiverShares.shares = receiverShares.shares + amountOfShares; - saveShares(store, receiverShares); + // NOTE: Do NOT update receiver's Shares here! + // The accompanying Transfer(0x0 -> receiver) event will be processed by handleTransfer + // which correctly updates the per-address Shares entity. Updating here would double-count. return { amountOfShares, diff --git a/test/graph/utils/state-capture.ts b/test/graph/utils/state-capture.ts index ba096250ee..6d1cf8110f 100644 --- a/test/graph/utils/state-capture.ts +++ b/test/graph/utils/state-capture.ts @@ -25,8 +25,8 @@ export interface SimulatorInitialState { /** Treasury address for fee categorization */ treasuryAddress: string; - /** Staking module addresses from StakingRouter */ - stakingModuleAddresses: string[]; + /** All staking-related addresses that may receive shares (module addresses + reward recipients) */ + stakingRelatedAddresses: string[]; } /** @@ -58,19 +58,29 @@ export async function captureChainState(ctx: ProtocolContext): Promise(); + // Add all staking module contract addresses + const modules = await stakingRouter.getStakingModules(); for (const module of modules) { - stakingModuleAddresses.push(module.stakingModuleAddress); + addressSet.add(module.stakingModuleAddress); + } + + // Add reward distribution recipients (may be different from module addresses) + const [recipients] = await stakingRouter.getStakingRewardsDistribution(); + for (const recipient of recipients) { + addressSet.add(recipient); } return { totalPooledEther, totalShares, treasuryAddress, - stakingModuleAddresses, + stakingRelatedAddresses: Array.from(addressSet), }; } From 8537495b7a07da6d607b6db339f76454ca37e24f Mon Sep 17 00:00:00 2001 From: Artyom Veremeenko Date: Thu, 18 Dec 2025 15:24:46 +0300 Subject: [PATCH 08/10] test(graph): add graph entities NodeOperatorFees and NodeOperatorsShares --- test/graph/README.md | 49 +++++- test/graph/entities-scenario.integration.ts | 53 ++++++ test/graph/simulator/entities.ts | 96 +++++++++++ test/graph/simulator/handlers/lido.ts | 36 +++++ test/graph/simulator/index.ts | 82 ++++++++++ test/graph/simulator/store.ts | 169 ++++++++++++++++++++ 6 files changed, 484 insertions(+), 1 deletion(-) diff --git a/test/graph/README.md b/test/graph/README.md index 6624a65a4e..7f693d80d5 100644 --- a/test/graph/README.md +++ b/test/graph/README.md @@ -23,9 +23,26 @@ The goal is bug discovery. **Out of scope** +- EasyTrack related entities +- Voting related entities +- Config entities (`LidoConfig`, `OracleConfig`, etc.) - Pre-V2 / legacy entities and fields (`insuranceFee`, `dust`, `sharesToInsuranceFund`, etc.). - `OracleCompleted` legacy tracking (replaced by `TokenRebased.timeElapsed`). +## Current status + +Excludes out-of-scope graph parts. + +| Category | Implemented | Total | Coverage | +| ---------------- | ----------- | ----- | -------- | +| Entities | 8 | 30 | 27% | +| Lido Handlers | 7 | 19 | 37% | +| All Handlers | 7 | 78 | 9% | +| Core stETH Flow | Full | - | ✅ | +| Governance | None | - | ❌ | +| Node Operators | Partial | - | ⚠️ | +| Withdrawal Queue | None | - | ❌ | + ## Test Environment - Mainnet via forking @@ -98,6 +115,9 @@ Each transaction is processed through `GraphSimulator.processTransactionWithV3() - **`validateOracleReport`**: For profitable reports, verifies `TotalReward` fee distribution: - `shares2mint == sharesToTreasury + sharesToOperators` - `totalFee == treasuryFee + operatorsFee` + - Per-module fee distribution: `NodeOperatorFees` and `NodeOperatorsShares` entities are created + - Sum of `NodeOperatorFees.fee` equals `operatorsFee` + - Sum of `NodeOperatorsShares.shares` equals `sharesToOperators` - For non-profitable (zero/negative), verifies no `TotalReward` is created - **`validateGlobalConsistency`**: Compares simulator state against on-chain state: - `Totals.totalPooledEther` vs `lido.getTotalPooledEther()` @@ -111,7 +131,7 @@ At test setup, initial share balances are captured for all addresses that may re - Treasury address - Staking module addresses (from `stakingRouter.getStakingModules()`) - Staking reward recipients (from `stakingRouter.getStakingRewardsDistribution()`) -- CSM address (Community Staking Module on Hoodi) +- Fee distributor addresses (from `module.FEE_DISTRIBUTOR()` for modules that have one, e.g., CSM) - Protocol contracts: Burner, WithdrawalQueue, Accounting, StakingRouter, VaultHub - Test user addresses (user1-5) @@ -217,6 +237,7 @@ Notable fields: - feeBasis / treasuryFeeBasisPoints / operatorsFeeBasisPoints - totalFee / treasuryFee / operatorsFee - shares2mint / sharesToTreasury / sharesToOperators +- nodeOperatorFeesIds / nodeOperatorsSharesIds (references to per-module entities) - totalPooledEtherBefore / totalPooledEtherAfter - totalSharesBefore / totalSharesAfter - timeElapsed @@ -230,6 +251,32 @@ When updated: - `Lido.TokenRebased`: provides values for `totalPooledEtherBefore/After`, `totalSharesBefore/After`, `shares2mint`, `timeElapsed` - `Lido.Transfer` / `Lido.TransferShares` pairs (between ETHDistributed and TokenRebased): used to calculate fee distribution to treasury and SR modules +### NodeOperatorFees (immutable) + +One per staking module that receives fees during an oracle report. + +**Fields**: `id`, `totalRewardId`, `address`, `fee` + +**When created**: + +- During oracle report processing, for each `Lido.Transfer` from 0x0 to a staking module (NOR, SDVT, CSM) +- The `fee` field contains the ETH value transferred to that module + +**Validation**: Sum of all `NodeOperatorFees.fee` for a report must equal `TotalReward.operatorsFee` + +### NodeOperatorsShares (immutable) + +One per staking module that receives shares during an oracle report. + +**Fields**: `id`, `totalRewardId`, `address`, `shares` + +**When created**: + +- During oracle report processing, for each `Lido.TransferShares` from 0x0 to a staking module +- The `shares` field contains the shares minted to that module + +**Validation**: Sum of all `NodeOperatorsShares.shares` for a report must equal `TotalReward.sharesToOperators` + ### LidoSubmission (immutable) One per user submission. diff --git a/test/graph/entities-scenario.integration.ts b/test/graph/entities-scenario.integration.ts index beec0bb425..5146c6385f 100644 --- a/test/graph/entities-scenario.integration.ts +++ b/test/graph/entities-scenario.integration.ts @@ -316,6 +316,59 @@ describe("Comprehensive Mixed Scenario", () => { [computed!.totalFee, computed!.treasuryFee + computed!.operatorsFee], ]); + // ========== Per-Module Fee Distribution Validation ========== + // Verify NodeOperatorFees and NodeOperatorsShares entities + + // Get per-module fee entities from simulator + const nodeOpFees = simulator.getNodeOperatorFeesForReward(receipt.hash); + const nodeOpShares = simulator.getNodeOperatorsSharesForReward(receipt.hash); + + // Validate that entities were created when there are operator fees + if (computed!.operatorsFee > 0n) { + expect(nodeOpFees.length, "NodeOperatorFees entities should be created for operator fees").to.be.gte(1); + expect(nodeOpShares.length, "NodeOperatorsShares entities should be created for operator fees").to.be.gte(1); + + // Sum of all NodeOperatorFees should equal operatorsFee + const totalNodeOpFee = nodeOpFees.reduce((sum, e) => sum + e.fee, 0n); + expect(totalNodeOpFee).to.equal(computed!.operatorsFee, "Sum of NodeOperatorFees should equal operatorsFee"); + + // Sum of all NodeOperatorsShares should equal sharesToOperators + const totalNodeOpShares = nodeOpShares.reduce((sum, e) => sum + e.shares, 0n); + expect(totalNodeOpShares).to.equal( + computed!.sharesToOperators, + "Sum of NodeOperatorsShares should equal sharesToOperators", + ); + + // Verify each entity has correct totalRewardId + for (const entity of nodeOpFees) { + expect(entity.totalRewardId.toLowerCase()).to.equal( + receipt.hash.toLowerCase(), + "NodeOperatorFees.totalRewardId should match TotalReward.id", + ); + expect(entity.address).to.not.equal("", "NodeOperatorFees.address should be set"); + expect(entity.fee).to.be.gt(0n, "NodeOperatorFees.fee should be > 0"); + } + + for (const entity of nodeOpShares) { + expect(entity.totalRewardId.toLowerCase()).to.equal( + receipt.hash.toLowerCase(), + "NodeOperatorsShares.totalRewardId should match TotalReward.id", + ); + expect(entity.address).to.not.equal("", "NodeOperatorsShares.address should be set"); + expect(entity.shares).to.be.gt(0n, "NodeOperatorsShares.shares should be > 0"); + } + + // Verify nodeOperatorFeesIds and nodeOperatorsSharesIds arrays are populated + expect(computed!.nodeOperatorFeesIds.length).to.equal( + nodeOpFees.length, + "nodeOperatorFeesIds should match number of NodeOperatorFees entities", + ); + expect(computed!.nodeOperatorsSharesIds.length).to.equal( + nodeOpShares.length, + "nodeOperatorsSharesIds should match number of NodeOperatorsShares entities", + ); + } + profitableReportCount++; return computed!; } else { diff --git a/test/graph/simulator/entities.ts b/test/graph/simulator/entities.ts index 852593f16b..6d8a2fc66f 100644 --- a/test/graph/simulator/entities.ts +++ b/test/graph/simulator/entities.ts @@ -144,6 +144,15 @@ export interface TotalRewardEntity { /** Shares minted to staking router modules (operators) */ sharesToOperators: bigint; + // ========== Tier 2 - Per-Module Fee Distribution (V2+) ========== + // These track detailed per-module distribution during oracle reports + + /** IDs of NodeOperatorFees entities for this report */ + nodeOperatorFeesIds: string[]; + + /** IDs of NodeOperatorsShares entities for this report */ + nodeOperatorsSharesIds: string[]; + // ========== Tier 3 - Calculated Fields ========== /** @@ -202,6 +211,10 @@ export function createTotalRewardEntity(id: string): TotalRewardEntity { sharesToTreasury: 0n, sharesToOperators: 0n, + // Tier 2 - Per-Module Fee Distribution + nodeOperatorFeesIds: [], + nodeOperatorsSharesIds: [], + // Tier 3 apr: 0, aprRaw: 0, @@ -487,3 +500,86 @@ export function createSharesBurnEntity(id: string): SharesBurnEntity { sharesAmount: 0n, }; } + +// ============================================================================ +// NodeOperatorFees Entity +// ============================================================================ + +/** + * NodeOperatorFees entity tracking per-module/operator fee distribution + * + * This is an immutable entity created for each staking module that receives + * fees during an oracle report. Tracks the ETH value (fee) distributed. + * + * Reference: lido-subgraph/schema.graphql - NodeOperatorFees entity + * Reference: lido-subgraph/src/Lido.ts handleTransfer() for V1 logic + */ +export interface NodeOperatorFeesEntity { + /** Entity ID: txHash-logIndex */ + id: string; + + /** Reference to parent TotalReward entity (transaction hash) */ + totalRewardId: string; + + /** Recipient address (staking module or operator address) */ + address: string; + + /** ETH value of fee distributed */ + fee: bigint; +} + +/** + * Create a new NodeOperatorFees entity with default values + * + * @param id - Entity ID (txHash-logIndex) + * @returns New NodeOperatorFeesEntity with zero/empty default values + */ +export function createNodeOperatorFeesEntity(id: string): NodeOperatorFeesEntity { + return { + id, + totalRewardId: "", + address: "", + fee: 0n, + }; +} + +// ============================================================================ +// NodeOperatorsShares Entity +// ============================================================================ + +/** + * NodeOperatorsShares entity tracking per-module/operator shares distribution + * + * This is an immutable entity created for each staking module that receives + * shares during an oracle report. Tracks the shares minted. + * + * Reference: lido-subgraph/schema.graphql - NodeOperatorsShares entity + */ +export interface NodeOperatorsSharesEntity { + /** Entity ID: txHash-address */ + id: string; + + /** Reference to parent TotalReward entity (transaction hash) */ + totalRewardId: string; + + /** Recipient address (staking module or operator address) */ + address: string; + + /** Shares minted to this recipient */ + shares: bigint; +} + +/** + * Create a new NodeOperatorsShares entity with default values + * + * @param id - Entity ID (txHash-address) + * @returns New NodeOperatorsSharesEntity with zero/empty default values + */ +export function createNodeOperatorsSharesEntity(id: string): NodeOperatorsSharesEntity { + return { + id, + totalRewardId: "", + address: "", + shares: 0n, + }; +} diff --git a/test/graph/simulator/handlers/lido.ts b/test/graph/simulator/handlers/lido.ts index cdb4088136..71ecdd87bc 100644 --- a/test/graph/simulator/handlers/lido.ts +++ b/test/graph/simulator/handlers/lido.ts @@ -32,14 +32,20 @@ import { EntityStore, loadLidoSubmissionEntity, loadLidoTransferEntity, + loadNodeOperatorFeesEntity, + loadNodeOperatorsSharesEntity, loadSharesBurnEntity, loadSharesEntity, loadTotalsEntity, makeLidoSubmissionId, makeLidoTransferId, + makeNodeOperatorFeesId, + makeNodeOperatorsSharesId, makeSharesBurnId, saveLidoSubmission, saveLidoTransfer, + saveNodeOperatorFees, + saveNodeOperatorsShares, saveShares, saveSharesBurn, saveTotalReward, @@ -257,12 +263,14 @@ export function handleETHDistributed( // Process TokenRebased to fill in pool state and fee distribution // This will also set entity.totalRewards = totalRewardsWithFees - totalFee + // Also creates NodeOperatorFees and NodeOperatorsShares entities for each staking module const rebaseWarnings = _processTokenRebase( entity, tokenRebasedEvent, allLogs, event.logIndex, ctx.treasuryAddress, + store, sharesMintedAsFees, ); warnings.push(...rebaseWarnings); @@ -337,6 +345,7 @@ export function isSharesBurntEvent(event: LogDescriptionWithMeta): boolean { * @param allLogs - All parsed logs from the transaction (for Transfer/TransferShares extraction) * @param ethDistributedLogIndex - Log index of the ETHDistributed event * @param treasuryAddress - Treasury address for fee categorization + * @param store - Entity store for creating per-module fee entities * @param sharesMintedAsFees - Expected shares minted as fees from TokenRebased (for validation) * @returns Array of validation warnings encountered during processing */ @@ -346,6 +355,7 @@ export function _processTokenRebase( allLogs: LogDescriptionWithMeta[], ethDistributedLogIndex: number, treasuryAddress: string, + store: EntityStore, sharesMintedAsFees?: bigint, ): ValidationWarning[] { const warnings: ValidationWarning[] = []; @@ -390,6 +400,10 @@ export function _processTokenRebase( const treasuryAddressLower = treasuryAddress.toLowerCase(); + // Track per-module fee distribution entities + const nodeOperatorFeesIds: string[] = []; + const nodeOperatorsSharesIds: string[] = []; + for (const pair of transferPairs) { // Only process mint events (from = ZERO_ADDRESS) if (pair.transfer.from.toLowerCase() === ZERO_ADDRESS.toLowerCase()) { @@ -401,6 +415,24 @@ export function _processTokenRebase( // Mint to staking router module (operators) sharesToOperators += pair.transferShares.sharesValue; operatorsFee += pair.transfer.value; + + // Create NodeOperatorFees entity for this module + const nodeOpFeesId = makeNodeOperatorFeesId(entity.transactionHash, pair.transfer.logIndex); + const nodeOpFeesEntity = loadNodeOperatorFeesEntity(store, nodeOpFeesId, true)!; + nodeOpFeesEntity.totalRewardId = entity.id; + nodeOpFeesEntity.address = pair.transfer.to.toLowerCase(); + nodeOpFeesEntity.fee = pair.transfer.value; + saveNodeOperatorFees(store, nodeOpFeesEntity); + nodeOperatorFeesIds.push(nodeOpFeesId); + + // Create NodeOperatorsShares entity for this module + const nodeOpSharesId = makeNodeOperatorsSharesId(entity.transactionHash, pair.transfer.to); + const nodeOpSharesEntity = loadNodeOperatorsSharesEntity(store, nodeOpSharesId, true)!; + nodeOpSharesEntity.totalRewardId = entity.id; + nodeOpSharesEntity.address = pair.transfer.to.toLowerCase(); + nodeOpSharesEntity.shares = pair.transferShares.sharesValue; + saveNodeOperatorsShares(store, nodeOpSharesEntity); + nodeOperatorsSharesIds.push(nodeOpSharesId); } } } @@ -413,6 +445,10 @@ export function _processTokenRebase( entity.totalFee = treasuryFee + operatorsFee; entity.totalRewards = entity.totalRewardsWithFees - entity.totalFee; + // Set per-module fee distribution references + entity.nodeOperatorFeesIds = nodeOperatorFeesIds; + entity.nodeOperatorsSharesIds = nodeOperatorsSharesIds; + // ========== shares2mint Validation (Sanity Check) ========== // Reference: lido-subgraph/src/Lido.ts lines 664-667 // In the real graph, there's a critical log if shares2mint != sharesToTreasury + sharesToOperators diff --git a/test/graph/simulator/index.ts b/test/graph/simulator/index.ts index d4f68b36b3..b836643111 100644 --- a/test/graph/simulator/index.ts +++ b/test/graph/simulator/index.ts @@ -24,6 +24,8 @@ import { extractAllLogs, findTransferSharesPairs, ZERO_ADDRESS } from "../utils/ import { LidoSubmissionEntity, LidoTransferEntity, + NodeOperatorFeesEntity, + NodeOperatorsSharesEntity, SharesBurnEntity, SharesEntity, TotalRewardEntity, @@ -46,6 +48,10 @@ import { EntityStore, getLidoSubmission, getLidoTransfer, + getNodeOperatorFees, + getNodeOperatorFeesForReward, + getNodeOperatorsShares, + getNodeOperatorsSharesForReward, getShares, getSharesBurn, loadSharesEntity, @@ -68,6 +74,10 @@ export { createLidoSubmissionEntity, SharesBurnEntity, createSharesBurnEntity, + NodeOperatorFeesEntity, + createNodeOperatorFeesEntity, + NodeOperatorsSharesEntity, + createNodeOperatorsSharesEntity, } from "./entities"; export { EntityStore, @@ -85,6 +95,12 @@ export { makeLidoTransferId, makeLidoSubmissionId, makeSharesBurnId, + getNodeOperatorFees, + getNodeOperatorFeesForReward, + getNodeOperatorsShares, + getNodeOperatorsSharesForReward, + makeNodeOperatorFeesId, + makeNodeOperatorsSharesId, } from "./store"; export { SimulatorInitialState, PoolState, captureChainState, capturePoolState } from "../utils/state-capture"; export { @@ -481,6 +497,68 @@ export class GraphSimulator { getAllSharesBurns(): Map { return this.store.sharesBurns; } + + // ========== NodeOperatorFees Entity Methods ========== + + /** + * Get a NodeOperatorFees entity by ID + * + * @param id - Entity ID (txHash-logIndex) + * @returns The entity if found + */ + getNodeOperatorFees(id: string): NodeOperatorFeesEntity | undefined { + return getNodeOperatorFees(this.store, id); + } + + /** + * Get all NodeOperatorFees entities for a given TotalReward + * + * @param totalRewardId - TotalReward transaction hash + * @returns Array of NodeOperatorFees entities + */ + getNodeOperatorFeesForReward(totalRewardId: string): NodeOperatorFeesEntity[] { + return getNodeOperatorFeesForReward(this.store, totalRewardId); + } + + /** + * Get all NodeOperatorFees entities + * + * @returns Map of all NodeOperatorFees entities keyed by ID + */ + getAllNodeOperatorFees(): Map { + return this.store.nodeOperatorFees; + } + + // ========== NodeOperatorsShares Entity Methods ========== + + /** + * Get a NodeOperatorsShares entity by ID + * + * @param id - Entity ID (txHash-address) + * @returns The entity if found + */ + getNodeOperatorsShares(id: string): NodeOperatorsSharesEntity | undefined { + return getNodeOperatorsShares(this.store, id); + } + + /** + * Get all NodeOperatorsShares entities for a given TotalReward + * + * @param totalRewardId - TotalReward transaction hash + * @returns Array of NodeOperatorsShares entities + */ + getNodeOperatorsSharesForReward(totalRewardId: string): NodeOperatorsSharesEntity[] { + return getNodeOperatorsSharesForReward(this.store, totalRewardId); + } + + /** + * Get all NodeOperatorsShares entities + * + * @returns Map of all NodeOperatorsShares entities keyed by ID + */ + getAllNodeOperatorsShares(): Map { + return this.store.nodeOperatorsShares; + } } /** @@ -603,6 +681,10 @@ export function deriveExpectedTotalReward( sharesToTreasury, sharesToOperators, + // Tier 2 - Per-Module Fee Distribution (computed at runtime, not derived here) + nodeOperatorFeesIds: [], + nodeOperatorsSharesIds: [], + // Tier 3 - calculated apr, aprRaw: apr, diff --git a/test/graph/simulator/store.ts b/test/graph/simulator/store.ts index 7abfa706c7..228e59edc3 100644 --- a/test/graph/simulator/store.ts +++ b/test/graph/simulator/store.ts @@ -10,11 +10,15 @@ import { createLidoSubmissionEntity, createLidoTransferEntity, + createNodeOperatorFeesEntity, + createNodeOperatorsSharesEntity, createSharesBurnEntity, createSharesEntity, createTotalsEntity, LidoSubmissionEntity, LidoTransferEntity, + NodeOperatorFeesEntity, + NodeOperatorsSharesEntity, SharesBurnEntity, SharesEntity, TotalRewardEntity, @@ -44,6 +48,12 @@ export interface EntityStore { /** SharesBurn entities keyed by txHash-logIndex */ sharesBurns: Map; + + /** NodeOperatorFees entities keyed by txHash-logIndex */ + nodeOperatorFees: Map; + + /** NodeOperatorsShares entities keyed by txHash-address */ + nodeOperatorsShares: Map; } /** @@ -59,6 +69,8 @@ export function createEntityStore(): EntityStore { lidoTransfers: new Map(), lidoSubmissions: new Map(), sharesBurns: new Map(), + nodeOperatorFees: new Map(), + nodeOperatorsShares: new Map(), }; } @@ -76,6 +88,8 @@ export function clearStore(store: EntityStore): void { store.lidoTransfers.clear(); store.lidoSubmissions.clear(); store.sharesBurns.clear(); + store.nodeOperatorFees.clear(); + store.nodeOperatorsShares.clear(); } /** @@ -350,3 +364,158 @@ export function saveSharesBurn(store: EntityStore, entity: SharesBurnEntity): vo export function getSharesBurn(store: EntityStore, id: string): SharesBurnEntity | undefined { return store.sharesBurns.get(id.toLowerCase()); } + +// ============================================================================ +// NodeOperatorFees Entity Functions +// ============================================================================ + +/** + * Generate entity ID for NodeOperatorFees (txHash-logIndex) + * + * @param txHash - Transaction hash + * @param logIndex - Log index + * @returns Entity ID + */ +export function makeNodeOperatorFeesId(txHash: string, logIndex: number | bigint): string { + return `${txHash.toLowerCase()}-${logIndex.toString()}`; +} + +/** + * Load or create a NodeOperatorFees entity + * + * @param store - The entity store + * @param id - Entity ID (txHash-logIndex) + * @param create - Whether to create if not exists + * @returns The NodeOperatorFees entity or null if not exists and create=false + */ +export function loadNodeOperatorFeesEntity( + store: EntityStore, + id: string, + create: boolean = false, +): NodeOperatorFeesEntity | null { + const normalizedId = id.toLowerCase(); + let entity = store.nodeOperatorFees.get(normalizedId); + if (!entity && create) { + entity = createNodeOperatorFeesEntity(normalizedId); + store.nodeOperatorFees.set(normalizedId, entity); + } + return entity ?? null; +} + +/** + * Save a NodeOperatorFees entity to the store + * + * @param store - The entity store + * @param entity - The entity to save + */ +export function saveNodeOperatorFees(store: EntityStore, entity: NodeOperatorFeesEntity): void { + store.nodeOperatorFees.set(entity.id.toLowerCase(), entity); +} + +/** + * Get a NodeOperatorFees entity by ID + * + * @param store - The entity store + * @param id - Entity ID (txHash-logIndex) + * @returns The entity if found, undefined otherwise + */ +export function getNodeOperatorFees(store: EntityStore, id: string): NodeOperatorFeesEntity | undefined { + return store.nodeOperatorFees.get(id.toLowerCase()); +} + +/** + * Get all NodeOperatorFees entities for a given TotalReward + * + * @param store - The entity store + * @param totalRewardId - TotalReward transaction hash + * @returns Array of NodeOperatorFees entities + */ +export function getNodeOperatorFeesForReward(store: EntityStore, totalRewardId: string): NodeOperatorFeesEntity[] { + const result: NodeOperatorFeesEntity[] = []; + const normalizedId = totalRewardId.toLowerCase(); + for (const entity of store.nodeOperatorFees.values()) { + if (entity.totalRewardId.toLowerCase() === normalizedId) { + result.push(entity); + } + } + return result; +} + +// ============================================================================ +// NodeOperatorsShares Entity Functions +// ============================================================================ + +/** + * Generate entity ID for NodeOperatorsShares (txHash-address) + * + * @param txHash - Transaction hash + * @param address - Recipient address + * @returns Entity ID + */ +export function makeNodeOperatorsSharesId(txHash: string, address: string): string { + return `${txHash.toLowerCase()}-${address.toLowerCase()}`; +} + +/** + * Load or create a NodeOperatorsShares entity + * + * @param store - The entity store + * @param id - Entity ID (txHash-address) + * @param create - Whether to create if not exists + * @returns The NodeOperatorsShares entity or null if not exists and create=false + */ +export function loadNodeOperatorsSharesEntity( + store: EntityStore, + id: string, + create: boolean = false, +): NodeOperatorsSharesEntity | null { + const normalizedId = id.toLowerCase(); + let entity = store.nodeOperatorsShares.get(normalizedId); + if (!entity && create) { + entity = createNodeOperatorsSharesEntity(normalizedId); + store.nodeOperatorsShares.set(normalizedId, entity); + } + return entity ?? null; +} + +/** + * Save a NodeOperatorsShares entity to the store + * + * @param store - The entity store + * @param entity - The entity to save + */ +export function saveNodeOperatorsShares(store: EntityStore, entity: NodeOperatorsSharesEntity): void { + store.nodeOperatorsShares.set(entity.id.toLowerCase(), entity); +} + +/** + * Get a NodeOperatorsShares entity by ID + * + * @param store - The entity store + * @param id - Entity ID (txHash-address) + * @returns The entity if found, undefined otherwise + */ +export function getNodeOperatorsShares(store: EntityStore, id: string): NodeOperatorsSharesEntity | undefined { + return store.nodeOperatorsShares.get(id.toLowerCase()); +} + +/** + * Get all NodeOperatorsShares entities for a given TotalReward + * + * @param store - The entity store + * @param totalRewardId - TotalReward transaction hash + * @returns Array of NodeOperatorsShares entities + */ +export function getNodeOperatorsSharesForReward( + store: EntityStore, + totalRewardId: string, +): NodeOperatorsSharesEntity[] { + const result: NodeOperatorsSharesEntity[] = []; + const normalizedId = totalRewardId.toLowerCase(); + for (const entity of store.nodeOperatorsShares.values()) { + if (entity.totalRewardId.toLowerCase() === normalizedId) { + result.push(entity); + } + } + return result; +} From e5ace0c979d64ccbf3e2f76aeaa1a328e10566fa Mon Sep 17 00:00:00 2001 From: Artyom Veremeenko Date: Thu, 18 Dec 2025 16:04:56 +0300 Subject: [PATCH 09/10] test(graph): remove unsed graph query-related code --- test/graph/entities-scenario.integration.ts | 2 +- test/graph/simulator/index.ts | 86 ------- test/graph/simulator/query.ts | 237 -------------------- 3 files changed, 1 insertion(+), 324 deletions(-) delete mode 100644 test/graph/simulator/query.ts diff --git a/test/graph/entities-scenario.integration.ts b/test/graph/entities-scenario.integration.ts index 5146c6385f..70d84073be 100644 --- a/test/graph/entities-scenario.integration.ts +++ b/test/graph/entities-scenario.integration.ts @@ -1002,7 +1002,7 @@ describe("Comprehensive Mixed Scenario", () => { // Validate global consistency - simulator state should match chain state await validateGlobalConsistency(); - const totalRewardCount = simulator.countTotalRewards(0n); + const totalRewardCount = simulator.getStore().totalRewards.size; const allShares = simulator.getAllShares(); const allTransfers = simulator.getAllLidoTransfers(); const allSubmissions = simulator.getAllLidoSubmissions(); diff --git a/test/graph/simulator/index.ts b/test/graph/simulator/index.ts index b836643111..071cb217e1 100644 --- a/test/graph/simulator/index.ts +++ b/test/graph/simulator/index.ts @@ -34,15 +34,6 @@ import { import { HandlerContext, processTransactionEvents, ProcessTransactionResult, processV3Event } from "./handlers"; import { isExternalSharesBurntEvent, isExternalSharesMintedEvent } from "./handlers/lido"; import { calcAPR_v2, CALCULATION_UNIT } from "./helpers"; -import { - countTotalRewards, - getLatestTotalReward, - getTotalRewardById, - getTotalRewardsInBlockRange, - queryTotalRewards, - TotalRewardsQueryParamsExtended, - TotalRewardsQueryResult, -} from "./query"; import { createEntityStore, EntityStore, @@ -116,18 +107,6 @@ export { processV3Event, } from "./handlers"; -// Re-export query types and functions -export { - queryTotalRewards, - getTotalRewardById, - countTotalRewards, - getLatestTotalReward, - getTotalRewardsInBlockRange, - TotalRewardsQueryParams, - TotalRewardsQueryParamsExtended, - TotalRewardsQueryResult, -} from "./query"; - // Re-export helper functions and types for testing export { calcAPR_v2, @@ -335,71 +314,6 @@ export class GraphSimulator { saveTotals(this.store, totals); } - // ========== Query Methods ========== - - /** - * Query TotalRewards with filtering, ordering, and pagination - * - * Mimics the GraphQL query: - * ```graphql - * query TotalRewards($skip: Int!, $limit: Int!, $block_from: BigInt!) { - * totalRewards( - * skip: $skip - * first: $limit - * where: { block_gt: $block_from } - * orderBy: blockTime - * orderDirection: asc - * ) { ... } - * } - * ``` - * - * @param params - Query parameters (skip, limit, blockFrom, orderBy, orderDirection) - * @returns Array of matching TotalReward results - */ - queryTotalRewards(params: TotalRewardsQueryParamsExtended): TotalRewardsQueryResult[] { - return queryTotalRewards(this.store, params); - } - - /** - * Get a TotalReward by ID - * - * @param id - Transaction hash - * @returns The entity if found, null otherwise - */ - getTotalRewardById(id: string): TotalRewardEntity | null { - return getTotalRewardById(this.store, id); - } - - /** - * Count TotalRewards matching filter criteria - * - * @param blockFrom - Only count entities where block > blockFrom - * @returns Count of matching entities - */ - countTotalRewards(blockFrom: bigint = 0n): number { - return countTotalRewards(this.store, blockFrom); - } - - /** - * Get the most recent TotalReward by block time - * - * @returns The latest entity or null if store is empty - */ - getLatestTotalReward(): TotalRewardEntity | null { - return getLatestTotalReward(this.store); - } - - /** - * Get TotalRewards within a block range - * - * @param fromBlock - Start block (inclusive) - * @param toBlock - End block (inclusive) - * @returns Array of entities within the range - */ - getTotalRewardsInBlockRange(fromBlock: bigint, toBlock: bigint): TotalRewardEntity[] { - return getTotalRewardsInBlockRange(this.store, fromBlock, toBlock); - } - // ========== Shares Entity Methods ========== /** diff --git a/test/graph/simulator/query.ts b/test/graph/simulator/query.ts deleted file mode 100644 index 70a4cb0918..0000000000 --- a/test/graph/simulator/query.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Query functions for Graph Simulator - * - * These functions mimic GraphQL queries against the simulator's entity store. - * They accept parameters similar to the GraphQL query variables. - * - * Reference GraphQL query: - * ```graphql - * query TotalRewards($skip: Int!, $limit: Int!, $block_from: BigInt!, $block: Bytes!) { - * totalRewards( - * skip: $skip - * first: $limit - * block: { hash: $block } - * where: { block_gt: $block_from } - * orderBy: blockTime - * orderDirection: asc - * ) { - * id - * totalPooledEtherBefore - * totalPooledEtherAfter - * totalSharesBefore - * totalSharesAfter - * apr - * block - * blockTime - * logIndex - * } - * } - * ``` - */ - -import { TotalRewardEntity } from "./entities"; -import { EntityStore } from "./store"; - -/** - * Query parameters for TotalRewards query - */ -export interface TotalRewardsQueryParams { - /** Number of results to skip (pagination) */ - skip: number; - - /** Maximum number of results to return */ - limit: number; - - /** - * Filter: only include entities where block > block_from - * Maps to GraphQL: where: { block_gt: $block_from } - */ - blockFrom: bigint; - - /** - * Block hash for historical query (optional) - * Maps to GraphQL: block: { hash: $block } - * Note: In simulator, this is ignored since we don't have historical state - */ - blockHash?: string; -} - -/** - * Result item from TotalRewards query - * Contains only the fields requested in the GraphQL query - */ -export interface TotalRewardsQueryResult { - id: string; - totalPooledEtherBefore: bigint; - totalPooledEtherAfter: bigint; - totalSharesBefore: bigint; - totalSharesAfter: bigint; - apr: number; - block: bigint; - blockTime: bigint; - logIndex: bigint; -} - -/** - * Order direction for sorting - */ -export type OrderDirection = "asc" | "desc"; - -/** - * Order by field options for TotalRewards - */ -export type TotalRewardsOrderBy = "blockTime" | "block" | "logIndex" | "apr"; - -/** - * Extended query parameters with ordering options - */ -export interface TotalRewardsQueryParamsExtended extends TotalRewardsQueryParams { - /** Field to order by (default: blockTime) */ - orderBy?: TotalRewardsOrderBy; - - /** Order direction (default: asc) */ - orderDirection?: OrderDirection; -} - -/** - * Query TotalRewards entities from the store - * - * This function mimics the GraphQL query behavior: - * - Filters by block_gt (block greater than) - * - Orders by blockTime ascending (default) - * - Applies skip/limit pagination - * - * @param store - Entity store to query - * @param params - Query parameters - * @returns Array of matching TotalReward results - */ -export function queryTotalRewards( - store: EntityStore, - params: TotalRewardsQueryParamsExtended, -): TotalRewardsQueryResult[] { - const { skip, limit, blockFrom, orderBy = "blockTime", orderDirection = "asc" } = params; - - // Get all entities from store - const allEntities = Array.from(store.totalRewards.values()); - - // Filter: block > blockFrom - const filtered = allEntities.filter((entity) => entity.block > blockFrom); - - // Sort by orderBy field - const sorted = filtered.sort((a, b) => { - let comparison: number; - - switch (orderBy) { - case "blockTime": - comparison = Number(a.blockTime - b.blockTime); - break; - case "block": - comparison = Number(a.block - b.block); - break; - case "logIndex": - comparison = Number(a.logIndex - b.logIndex); - break; - case "apr": - comparison = a.apr - b.apr; - break; - default: - comparison = Number(a.blockTime - b.blockTime); - } - - return orderDirection === "asc" ? comparison : -comparison; - }); - - // Apply pagination - const paginated = sorted.slice(skip, skip + limit); - - // Map to result format (only requested fields) - return paginated.map(mapToQueryResult); -} - -/** - * Map a TotalRewardEntity to the query result format - */ -function mapToQueryResult(entity: TotalRewardEntity): TotalRewardsQueryResult { - return { - id: entity.id, - totalPooledEtherBefore: entity.totalPooledEtherBefore, - totalPooledEtherAfter: entity.totalPooledEtherAfter, - totalSharesBefore: entity.totalSharesBefore, - totalSharesAfter: entity.totalSharesAfter, - apr: entity.apr, - block: entity.block, - blockTime: entity.blockTime, - logIndex: entity.logIndex, - }; -} - -/** - * Get a single TotalReward by ID (transaction hash) - * - * @param store - Entity store - * @param id - Transaction hash - * @returns The entity if found, null otherwise - */ -export function getTotalRewardById(store: EntityStore, id: string): TotalRewardEntity | null { - return store.totalRewards.get(id.toLowerCase()) ?? null; -} - -/** - * Count TotalRewards matching the filter criteria - * - * @param store - Entity store - * @param blockFrom - Filter: only count entities where block > blockFrom - * @returns Count of matching entities - */ -export function countTotalRewards(store: EntityStore, blockFrom: bigint = 0n): number { - let count = 0; - for (const entity of store.totalRewards.values()) { - if (entity.block > blockFrom) { - count++; - } - } - return count; -} - -/** - * Get the latest TotalReward entity by block time - * - * @param store - Entity store - * @returns The most recent entity or null if store is empty - */ -export function getLatestTotalReward(store: EntityStore): TotalRewardEntity | null { - let latest: TotalRewardEntity | null = null; - - for (const entity of store.totalRewards.values()) { - if (!latest || entity.blockTime > latest.blockTime) { - latest = entity; - } - } - - return latest; -} - -/** - * Get TotalRewards within a block range - * - * @param store - Entity store - * @param fromBlock - Start block (inclusive) - * @param toBlock - End block (inclusive) - * @returns Array of entities within the range, ordered by blockTime asc - */ -export function getTotalRewardsInBlockRange( - store: EntityStore, - fromBlock: bigint, - toBlock: bigint, -): TotalRewardEntity[] { - const results: TotalRewardEntity[] = []; - - for (const entity of store.totalRewards.values()) { - if (entity.block >= fromBlock && entity.block <= toBlock) { - results.push(entity); - } - } - - // Sort by blockTime ascending - return results.sort((a, b) => Number(a.blockTime - b.blockTime)); -} From 738cc9948700f8672e759028b28280748bd96eb2 Mon Sep 17 00:00:00 2001 From: Artyom Veremeenko Date: Thu, 18 Dec 2025 16:41:50 +0300 Subject: [PATCH 10/10] test(graph): make graph simulator track absolute values --- test/graph/README.md | 47 +++++++++++++-------- test/graph/entities-scenario.integration.ts | 30 ++++++------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/test/graph/README.md b/test/graph/README.md index 7f693d80d5..a497f42113 100644 --- a/test/graph/README.md +++ b/test/graph/README.md @@ -56,10 +56,31 @@ To run graph integration tests (assuming localhost fork is running) do: - Mainnet: `RPC_URL=http://localhost:9122 yarn test:integration:upgrade:helper test/graph/*.ts` - Hoodi: `RPC_URL=http://localhost:9123 NETWORK_STATE_FILE=deployed-hoodi.json yarn test:integration:helper test/graph/*.ts` +## Simulator Initialization + +Before processing any transactions, the simulator must be initialized with current on-chain state. This establishes the baseline from which all subsequent event-driven updates are applied. + +**Required initialization:** + +1. **Totals** - `simulator.initializeTotals(totalPooledEther, totalShares)` + + - `totalPooledEther` from `lido.getTotalPooledEther()` + - `totalShares` from `lido.getTotalShares()` + +2. **Shares** - `simulator.initializeShares(address, shares)` for each address that may send/receive shares: + - Treasury address (from `locator.treasury()`) + - Staking module addresses (from `stakingRouter.getStakingModules()`) + - Staking reward recipients (from `stakingRouter.getStakingRewardsDistribution()`) + - Fee distributor addresses (from `module.FEE_DISTRIBUTOR()` for modules like CSM) + - Protocol contracts: Burner, WithdrawalQueue, Accounting, StakingRouter, VaultHub + - Test user addresses + +The simulator then tracks **absolute values** (not deltas), updating them as events are processed. + ## Success Criteria - **Totals consistency**: Simulator's `Totals` must match on-chain `lido.getTotalPooledEther()` and `lido.getTotalShares()` -- **Shares consistency**: Simulator's `Shares` entity for each address must match on-chain `lido.sharesOf(address)` (delta from initial state) +- **Shares consistency**: Simulator's `Shares` entity for each address must match on-chain `lido.sharesOf(address)` - **Exact match**: All `bigint` values must match exactly (no tolerance for rounding) - **No validation warnings**: `shares2mint_mismatch` and `totals_state_mismatch` warnings indicate bugs @@ -122,25 +143,13 @@ Each transaction is processed through `GraphSimulator.processTransactionWithV3() - **`validateGlobalConsistency`**: Compares simulator state against on-chain state: - `Totals.totalPooledEther` vs `lido.getTotalPooledEther()` - `Totals.totalShares` vs `lido.getTotalShares()` - - For each `Shares` entity: `simulatorDelta + initialShares` vs `lido.sharesOf(address)` - -### Address Pre-capture - -At test setup, initial share balances are captured for all addresses that may receive shares during the test: - -- Treasury address -- Staking module addresses (from `stakingRouter.getStakingModules()`) -- Staking reward recipients (from `stakingRouter.getStakingRewardsDistribution()`) -- Fee distributor addresses (from `module.FEE_DISTRIBUTOR()` for modules that have one, e.g., CSM) -- Protocol contracts: Burner, WithdrawalQueue, Accounting, StakingRouter, VaultHub -- Test user addresses (user1-5) - -This allows strict validation of Shares entities by computing: `expectedShares = simulatorDelta + initialShares` + - For each `Shares` entity: `simulator.shares` vs `lido.sharesOf(address)` (direct comparison) ## Specifics - This document does not describe legacy code written for pre-V2 upgrade. -- there are specific workarounds for specific networks for cases when an event does not exist ([Voting example](https://github.com/lidofinance/lido-subgraph/blob/6334a6a28ab6978b66d45220a27c3c2dc78be918/src/Voting.ts#L67)) +- There are specific workarounds for specific networks for cases when an event does not exist ([Voting example](https://github.com/lidofinance/lido-subgraph/blob/6334a6a28ab6978b66d45220a27c3c2dc78be918/src/Voting.ts#L67)) +- The simulator (like the actual subgraph) is **event-triggered but not purely event-derived**. Some events don't contain all required data, so handlers must read from chain. For example, `ExternalSharesMinted` and `ExternalSharesBurnt` events don't include `totalPooledEther`, so the handler reads it via `lido.getTotalPooledEther()`. ## Entities @@ -150,6 +159,8 @@ Subgraph calculates and stores various data structures called entities. Some of **Fields**: `totalPooledEther`, `totalShares` +**Initialization**: At test start, `simulator.initializeTotals(totalPooledEther, totalShares)` is called with values from `lido.getTotalPooledEther()` and `lido.getTotalShares()`. + **Update sources** - Submission: `Lido.Submitted.amount` @@ -161,6 +172,8 @@ Subgraph calculates and stores various data structures called entities. Some of **Fields**: `id` (holder), `shares` +**Initialization**: At test start, `simulator.initializeShares(address, shares)` is called for each address with its on-chain balance from `lido.sharesOf(address)`. + **Update sources** - Submission mint: `Lido.Transfer` (0x0→user) + `Lido.TransferShares` @@ -169,7 +182,7 @@ Subgraph calculates and stores various data structures called entities. Some of - Burn finalization: `Lido.SharesBurnt` - V3 external mints: `Lido.Transfer` (0x0→receiver) triggered by `ExternalSharesMinted` -**Validation**: Simulator tracks share deltas from events. Final balance = `simulatorDelta + initialShares` must equal `lido.sharesOf(address)` +**Validation**: Simulator tracks absolute values. `simulator.shares` must equal `lido.sharesOf(address)`. **Note**: `ExternalSharesMinted` only updates `Totals`, not `Shares`. The accompanying `Transfer` event updates per-address shares. diff --git a/test/graph/entities-scenario.integration.ts b/test/graph/entities-scenario.integration.ts index 70d84073be..8082c0e93f 100644 --- a/test/graph/entities-scenario.integration.ts +++ b/test/graph/entities-scenario.integration.ts @@ -63,9 +63,6 @@ describe("Comprehensive Mixed Scenario", () => { let simulator: GraphSimulator; let initialState: SimulatorInitialState; - // Initial shares for addresses (captured at test start for validation) - const initialShares: Map = new Map(); - // Counters for statistics let depositCount = 0; let transferCount = 0; @@ -122,9 +119,12 @@ describe("Comprehensive Mixed Scenario", () => { // NOW capture chain state and initialize simulator AFTER all setup is done initialState = await captureChainState(ctx); simulator = new GraphSimulator(initialState.treasuryAddress); + + // Initialize simulator with current on-chain state + // Totals: totalPooledEther and totalShares from lido contract simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); - // Capture initial shares for all relevant addresses + // Shares: initialize all addresses that may receive/send shares during the test // Include: treasury, staking modules, reward recipients, protocol contracts, users const { lido, locator, withdrawalQueue, accounting, stakingRouter } = ctx.contracts; const burnerAddress = await locator.burner(); @@ -138,7 +138,7 @@ describe("Comprehensive Mixed Scenario", () => { const moduleAddresses = allModules.map((m) => m.stakingModuleAddress); // Some staking modules (like CSM) have a separate Fee Distributor contract that receives rewards - // We need to capture these addresses as they receive transfers during oracle reports + // We need to initialize these addresses as they receive transfers during oracle reports const feeDistributorAddresses: string[] = []; for (const module of allModules) { try { @@ -156,7 +156,7 @@ describe("Comprehensive Mixed Scenario", () => { } } - const addressesToCapture = [ + const addressesToInitialize = [ initialState.treasuryAddress, ...initialState.stakingRelatedAddresses, ...moduleAddresses, @@ -172,9 +172,9 @@ describe("Comprehensive Mixed Scenario", () => { user4.address, user5.address, ]; - for (const addr of addressesToCapture) { + for (const addr of addressesToInitialize) { const shares = await lido.sharesOf(addr); - initialShares.set(addr.toLowerCase(), shares); + simulator.initializeShares(addr, shares); } log.info("Setup complete", { @@ -182,7 +182,7 @@ describe("Comprehensive Mixed Scenario", () => { "Vault2": await vault2.getAddress(), "Total Pooled Ether": formatEther(initialState.totalPooledEther), "Total Shares": initialState.totalShares.toString(), - "Addresses captured for Shares validation": addressesToCapture.length, + "Addresses initialized for Shares validation": addressesToInitialize.length, }); }); @@ -392,20 +392,14 @@ describe("Comprehensive Mixed Scenario", () => { expect(totals!.totalShares).to.equal(poolState.totalShares, "Totals.totalShares should match chain"); // Verify all Shares entities against on-chain state - // The simulator tracks share deltas from events, so we need to add initial shares - // All addresses should have been pre-captured (treasury, reward recipients, users, burner, WQ) + // Simulator tracks absolute values (initialized at test start, updated by events) const allShares = simulator.getAllShares(); let validatedCount = 0; for (const [address, sharesEntity] of allShares) { - const initialSharesForAddress = initialShares.get(address.toLowerCase()); - expect(initialSharesForAddress, `Address ${address} was not pre-captured - add it to addressesToCapture in setup`) - .to.not.be.undefined; - const onChainShares = await lido.sharesOf(address); - const expectedShares = sharesEntity.shares + initialSharesForAddress!; - expect(expectedShares).to.equal( + expect(sharesEntity.shares).to.equal( onChainShares, - `Shares for ${address} should match chain (simulator: ${sharesEntity.shares}, initial: ${initialSharesForAddress}, expected: ${expectedShares}, on-chain: ${onChainShares})`, + `Shares for ${address} should match chain (simulator: ${sharesEntity.shares}, on-chain: ${onChainShares})`, ); validatedCount++; }