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/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/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 new file mode 100644 index 0000000000..a497f42113 --- /dev/null +++ b/test/graph/README.md @@ -0,0 +1,344 @@ +# Graph tests intro + +## Problem frame + +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. + +## 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** + +- 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 +- 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` + +## 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)` +- **Exact match**: All `bigint` values must match exactly (no tolerance for rounding) +- **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.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: + - `sharesBeforeDecrease - sharesAfterDecrease == shares` + - `sharesAfterIncrease - sharesBeforeIncrease == shares` +- **`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()` + - `Totals.totalShares` vs `lido.getTotalShares()` + - 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)) +- 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 + +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) + +**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` +- 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) + +**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` +- 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` + +**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. + +### 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. Created iff profitable. + +Notable fields: + +- id (transaction hash) +- totalRewards / totalRewardsWithFees +- mevFee (execution layer rewards) +- feeBasis / treasuryFeeBasisPoints / operatorsFeeBasisPoints +- totalFee / treasuryFee / operatorsFee +- shares2mint / sharesToTreasury / sharesToOperators +- nodeOperatorFeesIds / nodeOperatorsSharesIds (references to per-module entities) +- 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 + +### 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. + +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/entities-scenario.integration.ts b/test/graph/entities-scenario.integration.ts new file mode 100644 index 0000000000..8082c0e93f --- /dev/null +++ b/test/graph/entities-scenario.integration.ts @@ -0,0 +1,1031 @@ +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); + + // Initialize simulator with current on-chain state + // Totals: totalPooledEther and totalShares from lido contract + simulator.initializeTotals(initialState.totalPooledEther, initialState.totalShares); + + // 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(); + 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 initialize 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 addressesToInitialize = [ + 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 addressesToInitialize) { + const shares = await lido.sharesOf(addr); + simulator.initializeShares(addr, 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 initialized for Shares validation": addressesToInitialize.length, + }); + }); + + after(async () => await Snapshot.restore(snapshot)); + + beforeEach(bailOnFailure); + + // ============================================================================ + // Helper Functions + // ============================================================================ + + async function processTx(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], + ]); + + // ========== 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 { + 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 { lido } = ctx.contracts; + const totals = simulator.getTotals(); + expect(totals, "Totals entity should exist").to.not.be.null; + + // 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 + // 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 onChainShares = await lido.sharesOf(address); + expect(sharesEntity.shares).to.equal( + onChainShares, + `Shares for ${address} should match chain (simulator: ${sharesEntity.shares}, 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, + }); + } + + // ============================================================================ + // 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 processTx(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 processTx(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 processTx(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 processTx(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 processTx(vaultReportReceipt, "Vault1 report"); + + const tx = await dashboard1.mintStETH(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processTx(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 processTx(vaultReportReceipt, "Vault2 report"); + + const tx = await dashboard2.mintStETH(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processTx(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 processTx(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 processTx(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 processTx(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; + + // 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; + + 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; + + // 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; + + 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 processTx(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 processTx(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 processTx(vaultReportReceipt, "Vault2 report"); + + const tx = await dashboard2.mintStETH(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processTx(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; + + // 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; + + 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 processTx(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 processTx(vaultReportReceipt, "Vault1 report"); + + const tx = await dashboard1.mintStETH(user3.address, amount); + const receipt = (await tx.wait()) as ContractTransactionReceipt; + + await processTx(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 processTx(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 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 processTx(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; + + // 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; + + 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 processTx(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 processTx(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 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 processTx(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; + + // 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; + + 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 processTx(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 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 processTx(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.getStore().totalRewards.size; + 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/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..6d8a2fc66f --- /dev/null +++ b/test/graph/simulator/entities.ts @@ -0,0 +1,585 @@ +/** + * 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). + * + * ## 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 ========== + // 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 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 ========== + + /** + * 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 2 - Per-Module Fee Distribution + nodeOperatorFeesIds: [], + nodeOperatorsSharesIds: [], + + // Tier 3 + apr: 0, + aprRaw: 0, + aprBeforeFees: 0, + feeBasis: 0n, + treasuryFeeBasisPoints: 0n, + 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, + }; +} + +// ============================================================================ +// 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/index.ts b/test/graph/simulator/handlers/index.ts new file mode 100644 index 0000000000..470a232b88 --- /dev/null +++ b/test/graph/simulator/handlers/index.ts @@ -0,0 +1,234 @@ +/** + * Handler registry for Graph Simulator + * + * Maps event names to their handler functions and coordinates + * event processing across all handlers. + */ + +import { ProtocolContext } from "lib/protocol"; + +import { LogDescriptionWithMeta } from "../../utils/event-extraction"; +import { + LidoSubmissionEntity, + LidoTransferEntity, + SharesBurnEntity, + TotalRewardEntity, + TotalsEntity, +} from "../entities"; +import { EntityStore } from "../store"; + +import { + ExternalSharesBurntResult, + ExternalSharesMintedResult, + handleETHDistributed, + handleExternalSharesBurnt, + handleExternalSharesMinted, + HandlerContext, + handleSharesBurntWithEntity, + handleSubmitted, + handleTransfer, + isETHDistributedEvent, + isExternalSharesBurntEvent, + isExternalSharesMintedEvent, + isSharesBurntEvent, + isSubmittedEvent, + isTransferEvent, + SharesBurntResult, + ValidationWarning, +} from "./lido"; + +// Re-export for convenience +export { + HandlerContext, + ValidationWarning, + SharesBurntResult, + SharesBurntWithEntityResult, + SubmittedResult, + TransferResult, + ExternalSharesMintedResult, + ExternalSharesBurntResult, +} from "./lido"; + +/** + * Result of processing a transaction's events + */ +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; + + /** 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 (legacy format) */ + sharesBurnt: SharesBurntResult[]; + + /** Validation warnings from sanity checks */ + warnings: ValidationWarning[]; +} + +/** + * 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. + * + * 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 + * @returns Processing result with created entities + */ +export function processTransactionEvents( + logs: LogDescriptionWithMeta[], + store: EntityStore, + ctx: HandlerContext, +): ProcessTransactionResult { + const result: ProcessTransactionResult = { + totalRewards: new Map(), + lidoSubmissions: new Map(), + lidoTransfers: new Map(), + sharesBurns: new Map(), + eventsProcessed: 0, + hadProfitableReport: false, + totalsUpdated: false, + totals: null, + sharesBurnt: [], + warnings: [], + }; + + // 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++; + + // ========== 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); + + // 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 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.logIndex > log.logIndex && l.logIndex < tokenRebasedLogIndex) { + if (l.name === "SharesBurnt") { + processedSharesBurntIndices.add(l.logIndex); + } + } + } + } + } + + // ========== 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 = 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; + } + + // ========== 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 + if (!result.totals && store.totals) { + result.totals = store.totals; + } + + 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 new file mode 100644 index 0000000000..71ecdd87bc --- /dev/null +++ b/test/graph/simulator/handlers/lido.ts @@ -0,0 +1,1084 @@ +/** + * Lido event handlers for Graph Simulator + * + * Ports the core logic from lido-subgraph/src/Lido.ts: + * - 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 { ProtocolContext } from "lib/protocol"; + +import { + findAllEventsByName, + findEventByName, + findTransferSharesPairs, + getEventArg, + LogDescriptionWithMeta, + ZERO_ADDRESS, +} from "../../utils/event-extraction"; +import { + createTotalRewardEntity, + LidoSubmissionEntity, + LidoTransferEntity, + SharesBurnEntity, + TotalRewardEntity, + TotalsEntity, +} from "../entities"; +import { calcAPR_v2, CALCULATION_UNIT } from "../helpers"; +import { + EntityStore, + loadLidoSubmissionEntity, + loadLidoTransferEntity, + loadNodeOperatorFeesEntity, + loadNodeOperatorsSharesEntity, + loadSharesBurnEntity, + loadSharesEntity, + loadTotalsEntity, + makeLidoSubmissionId, + makeLidoTransferId, + makeNodeOperatorFeesId, + makeNodeOperatorsSharesId, + makeSharesBurnId, + saveLidoSubmission, + saveLidoTransfer, + saveNodeOperatorFees, + saveNodeOperatorsShares, + saveShares, + saveSharesBurn, + saveTotalReward, + saveTotals, +} 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; + + /** 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; +} + +/** + * 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. + * + * 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 + * @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 { + const warnings: ValidationWarning[] = []; + + // 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) + // Reference: lido-subgraph/src/Lido.ts lines 487-502 + 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}`, + ); + } + + // 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 + 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 + // 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 + // 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); + + // Save entity + saveTotalReward(store, entity); + + 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 + * + * 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 + * @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 + */ +export function _processTokenRebase( + entity: TotalRewardEntity, + tokenRebasedEvent: LogDescriptionWithMeta, + allLogs: LogDescriptionWithMeta[], + ethDistributedLogIndex: number, + treasuryAddress: string, + store: EntityStore, + sharesMintedAsFees?: bigint, +): ValidationWarning[] { + const warnings: ValidationWarning[] = []; + + // 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 sharesMintedAsFeesFromEvent = 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 = sharesMintedAsFeesFromEvent; + 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(); + + // 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()) { + 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; + + // 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); + } + } + } + + // 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; + + // 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 + 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 + + // 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; + + return warnings; +} + +/** + * 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"; +} + +// ============================================================================ +// 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 + * + * 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 + * @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); + + // 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, + 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/helpers.ts b/test/graph/simulator/helpers.ts new file mode 100644 index 0000000000..d90cfdd5c2 --- /dev/null +++ b/test/graph/simulator/helpers.ts @@ -0,0 +1,236 @@ +/** + * Helper functions for Graph Simulator + * + * 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() + */ + +/** + * 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); + +/** + * 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 + * + * - **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 + * + * @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%), or 0 for edge cases + */ +export function calcAPR_v2( + preTotalEther: bigint, + postTotalEther: bigint, + preTotalShares: bigint, + postTotalShares: bigint, + timeElapsed: bigint, +): number { + // 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; + } + + // 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; + + // 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 + // 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 + * + * @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..071cb217e1 --- /dev/null +++ b/test/graph/simulator/index.ts @@ -0,0 +1,612 @@ +/** + * 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 { + LidoSubmissionEntity, + LidoTransferEntity, + NodeOperatorFeesEntity, + NodeOperatorsSharesEntity, + 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 { + createEntityStore, + EntityStore, + getLidoSubmission, + getLidoTransfer, + getNodeOperatorFees, + getNodeOperatorFeesForReward, + getNodeOperatorsShares, + getNodeOperatorsSharesForReward, + getShares, + getSharesBurn, + loadSharesEntity, + loadTotalsEntity, + saveShares, + saveTotals, +} from "./store"; + +// Re-export types and utilities +export { + TotalRewardEntity, + createTotalRewardEntity, + TotalsEntity, + createTotalsEntity, + SharesEntity, + createSharesEntity, + LidoTransferEntity, + createLidoTransferEntity, + LidoSubmissionEntity, + createLidoSubmissionEntity, + SharesBurnEntity, + createSharesBurnEntity, + NodeOperatorFeesEntity, + createNodeOperatorFeesEntity, + NodeOperatorsSharesEntity, + createNodeOperatorsSharesEntity, +} from "./entities"; +export { + EntityStore, + createEntityStore, + getTotalReward, + saveTotalReward, + loadTotalsEntity, + saveTotals, + getShares, + saveShares, + loadSharesEntity, + getLidoTransfer, + getLidoSubmission, + getSharesBurn, + makeLidoTransferId, + makeLidoSubmissionId, + makeSharesBurnId, + getNodeOperatorFees, + getNodeOperatorFeesForReward, + getNodeOperatorsShares, + getNodeOperatorsSharesForReward, + makeNodeOperatorFeesId, + makeNodeOperatorsSharesId, +} from "./store"; +export { SimulatorInitialState, PoolState, captureChainState, capturePoolState } from "../utils/state-capture"; +export { + HandlerContext, + ProcessTransactionResult, + ValidationWarning, + SharesBurntResult, + SharesBurntWithEntityResult, + SubmittedResult, + TransferResult, + ExternalSharesMintedResult, + ExternalSharesBurntResult, + processV3Event, +} from "./handlers"; + +// 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 + * + * 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 + * + * 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 (note: V3 event results are included in totals update) + */ + processTransaction( + receipt: ContractTransactionReceipt, + ctx: ProtocolContext, + blockTimestamp?: bigint, + ): ProcessTransactionResult { + // 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; + } + + /** + * 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(); + } + + // ========== 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); + } + + // ========== 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; + } + + // ========== 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; + } +} + +/** + * 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 2 - Per-Module Fee Distribution (computed at runtime, not derived here) + nodeOperatorFeesIds: [], + nodeOperatorsSharesIds: [], + + // Tier 3 - calculated + apr, + aprRaw: apr, + aprBeforeFees: apr, + feeBasis, + treasuryFeeBasisPoints, + operatorsFeeBasisPoints, + }; + + return expected; +} diff --git a/test/graph/simulator/store.ts b/test/graph/simulator/store.ts new file mode 100644 index 0000000000..228e59edc3 --- /dev/null +++ b/test/graph/simulator/store.ts @@ -0,0 +1,521 @@ +/** + * 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 { + createLidoSubmissionEntity, + createLidoTransferEntity, + createNodeOperatorFeesEntity, + createNodeOperatorsSharesEntity, + createSharesBurnEntity, + createSharesEntity, + createTotalsEntity, + LidoSubmissionEntity, + LidoTransferEntity, + NodeOperatorFeesEntity, + NodeOperatorsSharesEntity, + SharesBurnEntity, + SharesEntity, + TotalRewardEntity, + TotalsEntity, +} from "./entities"; + +/** + * Entity store interface containing all entity collections + * + * Each entity type has its own Map keyed by entity ID. + */ +export interface EntityStore { + /** Totals singleton entity (pool state) */ + totals: TotalsEntity | null; + + /** TotalReward entities keyed by transaction hash */ + totalRewards: 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; + + /** NodeOperatorFees entities keyed by txHash-logIndex */ + nodeOperatorFees: Map; + + /** NodeOperatorsShares entities keyed by txHash-address */ + nodeOperatorsShares: Map; +} + +/** + * Create a new empty entity store + * + * @returns Fresh EntityStore with empty collections + */ +export function createEntityStore(): EntityStore { + return { + totals: null, + totalRewards: new Map(), + shares: new Map(), + lidoTransfers: new Map(), + lidoSubmissions: new Map(), + sharesBurns: new Map(), + nodeOperatorFees: new Map(), + nodeOperatorsShares: 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.totals = null; + store.totalRewards.clear(); + store.shares.clear(); + store.lidoTransfers.clear(); + store.lidoSubmissions.clear(); + store.sharesBurns.clear(); + store.nodeOperatorFees.clear(); + store.nodeOperatorsShares.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) + * + * @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()); +} + +// ============================================================================ +// 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()); +} + +// ============================================================================ +// 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; +} 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..6d1cf8110f --- /dev/null +++ b/test/graph/utils/state-capture.ts @@ -0,0 +1,143 @@ +/** + * 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; + + /** All staking-related addresses that may receive shares (module addresses + reward recipients) */ + stakingRelatedAddresses: 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(); + + // Collect all staking-related addresses that may receive shares: + // 1. Staking module contract addresses from getStakingModules() + // 2. Reward distribution recipients from getStakingRewardsDistribution() + // We need both because getStakingRewardsDistribution() only returns modules with active validators + const addressSet = new Set(); + + // Add all staking module contract addresses + const modules = await stakingRouter.getStakingModules(); + for (const module of modules) { + 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, + stakingRelatedAddresses: Array.from(addressSet), + }; +} + +/** + * 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; +} 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/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