Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,342 changes: 1,341 additions & 1 deletion assets/AccountingOracle.json

Large diffs are not rendered by default.

304 changes: 179 additions & 125 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ oz-merkle-tree = { git = "https://github.com/lidofinance/oz-merkle-tree", rev =
multiformats = "^0.3.1"
protobuf="^6.31.1"
dag-cbor="^0.3.3"
ssz = "^0.5.2"
py-ecc = "^8.0.0"

[tool.poetry.group.dev.dependencies]
base58 = "^2.1.1"
Expand Down
229 changes: 222 additions & 7 deletions src/modules/accounting/accounting.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)
from src.modules.submodules.oracle_module import BaseModule, ModuleExecuteDelay
from src.modules.submodules.types import ZERO_HASH
from src.providers.consensus.types import PendingDeposit
from src.providers.execution.contracts.accounting_oracle import AccountingOracleContract
from src.services.bunker import BunkerService
from src.services.staking_vaults import StakingVaultsService
Expand All @@ -53,6 +54,8 @@
)
from src.utils.apr import calculate_gross_core_apr
from src.utils.cache import global_lru_cache as lru_cache
from src.utils.deposit_signature import is_valid_deposit_signature
from src.utils.types import hex_str_to_bytes
from src.utils.units import gwei_to_wei
from src.variables import ALLOW_REPORTING_IN_BUNKER_MODE
from src.web3py.extensions.lido_validators import StakingModule
Expand All @@ -73,7 +76,7 @@ class Accounting(BaseModule, ConsensusModule):
Contains exited validator's updates count by each node operator.
"""

COMPATIBLE_CONTRACT_VERSION = 3
COMPATIBLE_CONTRACT_VERSION = 4
COMPATIBLE_CONSENSUS_VERSION = 5

def __init__(self, w3: Web3):
Expand Down Expand Up @@ -188,6 +191,8 @@ def _calculate_report(self, blockstamp: ReferenceBlockStamp):
logger.info({'msg': 'Building the report', 'consensus_version': consensus_version})
rebase_part = self._calculate_rebase_report(blockstamp)
modules_part = self._get_newly_exited_validators_by_modules(blockstamp)
modules_balance_part = self._get_modules_balances(blockstamp)
pending_deposits_balance = self._calculate_pending_deposits_balance(blockstamp)
wq_part = self._calculate_wq_report(blockstamp)

vaults_part = self._handle_vaults_report(blockstamp)
Expand All @@ -198,6 +203,8 @@ def _calculate_report(self, blockstamp: ReferenceBlockStamp):
blockstamp,
rebase_part,
modules_part,
modules_balance_part,
pending_deposits_balance,
wq_part,
vaults_part,
extra_data_part,
Expand Down Expand Up @@ -248,9 +255,211 @@ def _get_consensus_lido_state(self, blockstamp: ReferenceBlockStamp) -> tuple[Va

return ValidatorsCount(len(lido_validators)), ValidatorsBalance(Gwei(total_lido_balance))

def _get_finalization_data(
@lru_cache(maxsize=1)
def _calculate_pending_deposits_balance(self, blockstamp: ReferenceBlockStamp) -> Gwei:
"""
Calculate total pending deposits balance for Lido validators that are not yet active.

Algorithm (based on pending_deposits_integration_report.md):
1. Get all pending deposits from consensus layer
2. Get all active Lido validators
3. Get Lido withdrawal credentials
4. For each pending deposit pubkey:
- Skip if validator with this pubkey already exists
- Validate deposits with BLS signature verification and withdrawal credentials matching
- Sum up valid deposits
"""
pending_deposits = self.w3.cc.get_pending_deposits(blockstamp)

if not pending_deposits:
logger.info({'msg': 'No pending deposits found'})
return Gwei(0)

# Get Lido withdrawal credentials
lido_wc = self.w3.lido_contracts.lido.get_withdrawal_credentials(blockstamp.block_hash)

# Get all active Lido validators
lido_validators = self.w3.lido_validators.get_lido_validators(blockstamp)
lido_validator_pubkeys = set(validator.validator.pubkey for validator in lido_validators)

# Group deposits by pubkey
deposits_by_pubkey = defaultdict(list)
for deposit in pending_deposits:
deposits_by_pubkey[deposit.pubkey].append(deposit)

total_pending_balance = Gwei(0)
validated_pubkeys_count = 0

# Process each pubkey's deposits
for pubkey, deposits in deposits_by_pubkey.items():
# Skip if validator already exists
if pubkey in lido_validator_pubkeys:
continue

# Validate and sum deposits for this pubkey
valid_balance = self._get_valid_lido_deposits_value(
lido_withdrawal_credentials=lido_wc,
pubkey_deposits=deposits,
)

if valid_balance > 0:
total_pending_balance = Gwei(total_pending_balance + valid_balance)
validated_pubkeys_count += 1

logger.info({
'msg': 'Calculate pending deposits balance (in Gwei)',
'value': total_pending_balance,
'total_deposits': len(pending_deposits),
'new_validators_with_deposits': validated_pubkeys_count,
})

return total_pending_balance

def _get_valid_lido_deposits_value(
self,
lido_withdrawal_credentials: str,
pubkey_deposits: list[PendingDeposit],
) -> Gwei:
"""
Validates pending deposits for a single pubkey according to pending_deposits_integration_report.md.

CRITICAL LOGIC from original _get_valid_deposits_value():
- Once a valid deposit is found (BLS signature + WC check), all subsequent deposits are accepted WITHOUT checks
- This protects against front-run attacks (attacker can't create valid BLS signature without private key)
- Uses "all-or-nothing" logic: if first valid deposit has wrong WC → return 0

Algorithm:
1. For each deposit in order:
a. If already found valid deposit → accept without checks (front-run protection)
b. Otherwise check BLS signature
c. If valid → check withdrawal_credentials match
d. If WC mismatch on first valid → return 0 (all-or-nothing)
e. If WC match → mark as valid_found and continue
"""
if not pubkey_deposits:
return Gwei(0)

valid_deposits_value = Gwei(0)
valid_found = False
genesis_config = self.get_cc_genesis_config()
genesis_fork_version = genesis_config.genesis_fork_version
genesis_validators_root = genesis_config.genesis_validators_root

for deposit in pubkey_deposits:
# CRITICAL: If already found valid deposit - accept ALL subsequent ones WITHOUT checks
if valid_found:
valid_deposits_value = Gwei(valid_deposits_value + deposit.amount)
continue

# Check BLS signature
is_valid = is_valid_deposit_signature(
pubkey=hex_str_to_bytes(deposit.pubkey),
withdrawal_credentials=hex_str_to_bytes(deposit.withdrawal_credentials),
amount_gwei=deposit.amount,
signature=hex_str_to_bytes(deposit.signature),
genesis_validators_root=bytes.fromhex(genesis_validators_root[2:]),
fork_version=bytes.fromhex(genesis_fork_version[2:]),
)

if not is_valid:
logger.warning({
'msg': f'Invalid deposit signature for deposit: {deposit.signature}.',
})
continue

# Check withdrawal_credentials match
if deposit.withdrawal_credentials != lido_withdrawal_credentials:
logger.warning({
'msg': (
f"Mismatch deposit withdrawal_credentials {deposit.withdrawal_credentials} "
f"to Lido protocol withdrawal_credentials {lido_withdrawal_credentials}. "
f"Skipping any further pending deposits count."
)
})
# CRITICAL: If first VALID deposit has wrong WC → return 0 (all-or-nothing)
return Gwei(0)

# Found valid deposit - mark and include
valid_found = True
valid_deposits_value = Gwei(valid_deposits_value + deposit.amount)

return valid_deposits_value

@lru_cache(maxsize=1)
def _get_modules_balances(
self, blockstamp: ReferenceBlockStamp
) -> tuple[FinalizationBatches, FinalizationShareRate]:
) -> tuple[list[StakingModuleId], list[Gwei], list[Gwei]]:
"""
Calculate active and pending balances for each staking module.

Returns:
tuple of:
- list of staking module IDs with updated balances
- list of active balances (in Gwei) for corresponding modules
- list of pending balances (in Gwei) for corresponding modules
"""
staking_modules = self.w3.lido_contracts.staking_router.get_staking_modules(blockstamp.block_hash)
lido_validators = self.w3.lido_validators.get_lido_validators(blockstamp)

pending_deposits = self.w3.cc.get_pending_deposits(blockstamp)
pending_balances_by_pubkey = self.staking_vaults.get_total_pending_amount_by_pubkey(pending_deposits)

# Build module address to module ID mapping
module_address_to_id: dict[str, StakingModuleId] = {}
for module in staking_modules:
module_address_to_id[module.staking_module_address] = module.id

# Calculate balances per module
module_active_balances: dict[StakingModuleId, Gwei] = defaultdict(lambda: Gwei(0))
module_pending_balances: dict[StakingModuleId, Gwei] = defaultdict(lambda: Gwei(0))

# Sum active balances from validators
for validator in lido_validators:
module_address = validator.lido_id.moduleAddress
if module_id := module_address_to_id.get(module_address):
module_active_balances[module_id] = Gwei(
module_active_balances[module_id] + validator.balance
)

# Sum pending balances from pending deposits
lido_validator_pubkeys = set(validator.validator.pubkey for validator in lido_validators)
lido_keys = self.w3.kac.get_used_lido_keys(blockstamp)

# Build pubkey to module address mapping
pubkey_to_module_address = {key.key: key.moduleAddress for key in lido_keys}

for pubkey, pb in pending_balances_by_pubkey.items():
# Only count pending deposits for validators that don't exist yet
if pubkey not in lido_validator_pubkeys:
if module_address := pubkey_to_module_address.get(pubkey):
if module_id := module_address_to_id.get(module_address):
module_pending_balances[module_id] = Gwei(
module_pending_balances[module_id] + pb
)

# Prepare result lists (only modules with any balance)
module_ids: list[StakingModuleId] = []
active_balances: list[Gwei] = []
pending_balances: list[Gwei] = []

all_module_ids = set(module_active_balances.keys()) | set(module_pending_balances.keys())

for module_id in sorted(all_module_ids):
module_ids.append(module_id)
active_balances.append(module_active_balances[module_id])
pending_balances.append(module_pending_balances[module_id])

logger.info({
'msg': 'Calculate balances by staking modules',
'modules_count': len(module_ids),
'module_ids': module_ids,
'active_balances': active_balances,
'pending_balances': pending_balances,
})

return module_ids, active_balances, pending_balances

def _get_finalization_data(self, blockstamp: ReferenceBlockStamp) -> tuple[FinalizationBatches, FinalizationShareRate]:
simulation = self.simulate_full_rebase(blockstamp)
chain_config = self.get_chain_config(blockstamp)
frame_config = self.get_frame_config(blockstamp)
Expand Down Expand Up @@ -488,7 +697,7 @@ def _calculate_extra_data_report(self, blockstamp: ReferenceBlockStamp) -> Extra
@staticmethod
def _update_metrics(report_data: ReportData):
ACCOUNTING_IS_BUNKER.set(report_data.is_bunker)
ACCOUNTING_CL_BALANCE_GWEI.set(report_data.cl_balance_gwei)
ACCOUNTING_CL_BALANCE_GWEI.set(report_data.cl_active_balance_gwei)
ACCOUNTING_EL_REWARDS_VAULT_BALANCE_WEI.set(report_data.el_rewards_vault_balance)
ACCOUNTING_WITHDRAWAL_VAULT_BALANCE_WEI.set(report_data.withdrawal_vault_balance)

Expand All @@ -498,24 +707,30 @@ def _combine_report_parts(
blockstamp: ReferenceBlockStamp,
report_rebase_part: RebaseReport,
report_modules_part: tuple[list[StakingModuleId], list[int]],
report_modules_balance_part: tuple[list[StakingModuleId], list[Gwei], list[Gwei]],
report_pending_deposits: Gwei,
report_wq_part: WqReport,
report_vaults_part: VaultsReport,
extra_data: ExtraData,
) -> ReportData:
validators_count, cl_balance, withdrawal_vault_balance, el_rewards_vault_balance, shares_requested_to_burn = (
_, cl_balance, withdrawal_vault_balance, el_rewards_vault_balance, shares_requested_to_burn = (
report_rebase_part
)
staking_module_ids_list, exit_validators_count_list = report_modules_part
module_ids_with_updated_balance, active_balances_by_module, pending_balances_by_module = report_modules_balance_part
is_bunker, finalization_batches, finalization_share_rate = report_wq_part
tree_root, tree_cid = report_vaults_part

return ReportData(
consensus_version=consensus_version,
ref_slot=blockstamp.ref_slot,
validators_count=validators_count,
cl_balance_gwei=cl_balance,
cl_active_balance_gwei=cl_balance,
cl_pending_balance_gwei=report_pending_deposits,
staking_module_ids_with_exited_validators=staking_module_ids_list,
count_exited_validators_by_staking_module=exit_validators_count_list,
staking_module_ids_with_updated_balance=module_ids_with_updated_balance,
active_balances_gwei_by_staking_module=active_balances_by_module,
pending_balances_gwei_by_staking_module=pending_balances_by_module,
withdrawal_vault_balance=withdrawal_vault_balance,
el_rewards_vault_balance=el_rewards_vault_balance,
shares_requested_to_burn=shares_requested_to_burn,
Expand Down
14 changes: 10 additions & 4 deletions src/modules/accounting/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,13 @@ def snake_to_camel(s):
class ReportData:
consensus_version: int
ref_slot: SlotNumber
validators_count: int
cl_balance_gwei: Gwei
cl_active_balance_gwei: Gwei
cl_pending_balance_gwei: Gwei
staking_module_ids_with_exited_validators: list[StakingModuleId]
count_exited_validators_by_staking_module: list[int]
staking_module_ids_with_updated_balance: list[StakingModuleId]
active_balances_gwei_by_staking_module: list[Gwei]
pending_balances_gwei_by_staking_module: list[Gwei]
withdrawal_vault_balance: Wei
el_rewards_vault_balance: Wei
shares_requested_to_burn: Shares
Expand All @@ -71,10 +74,13 @@ def as_tuple(self):
return (
self.consensus_version,
self.ref_slot,
self.validators_count,
self.cl_balance_gwei,
self.cl_active_balance_gwei,
self.cl_pending_balance_gwei,
self.staking_module_ids_with_exited_validators,
self.count_exited_validators_by_staking_module,
self.staking_module_ids_with_updated_balance,
self.active_balances_gwei_by_staking_module,
self.pending_balances_gwei_by_staking_module,
self.withdrawal_vault_balance,
self.el_rewards_vault_balance,
self.shares_requested_to_burn,
Expand Down
6 changes: 5 additions & 1 deletion src/modules/submodules/consensus.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ def get_chain_config(self, blockstamp: BlockStamp) -> ChainConfig:
@lru_cache(maxsize=1)
def get_cc_genesis_config(self) -> ConsensusGenesisConfig:
genesis = self.w3.cc.get_genesis()
return ConsensusGenesisConfig(genesis_time=genesis.genesis_time)
return ConsensusGenesisConfig(
genesis_time=genesis.genesis_time,
genesis_fork_version=genesis.genesis_fork_version,
genesis_validators_root=genesis.genesis_validators_root,
)

@lru_cache(maxsize=1)
def get_initial_or_current_frame(self, blockstamp: BlockStamp) -> CurrentFrame:
Expand Down
2 changes: 2 additions & 0 deletions src/modules/submodules/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class ChainConfig:
@dataclass(frozen=True)
class ConsensusGenesisConfig:
genesis_time: int
genesis_fork_version: str
genesis_validators_root: str

@dataclass(frozen=True)
class CurrentFrame:
Expand Down
2 changes: 2 additions & 0 deletions src/providers/consensus/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class BeaconSpecResponse(Nested, FromResponse):
@dataclass
class GenesisResponse(Nested, FromResponse):
genesis_time: int
genesis_validators_root: str
genesis_fork_version: str


@dataclass
Expand Down
Loading
Loading