diff --git a/src/commands/internal/process_redeemer.py b/src/commands/internal/process_redeemer.py new file mode 100644 index 00000000..bd67e91e --- /dev/null +++ b/src/commands/internal/process_redeemer.py @@ -0,0 +1,516 @@ +import asyncio +import logging +import sys +from collections import defaultdict +from dataclasses import replace +from pathlib import Path + +import click +from eth_typing import BlockNumber, ChecksumAddress, HexStr +from multiproof import StandardMerkleTree +from multiproof.standard import MultiProof +from sw_utils import InterruptHandler +from web3 import Web3 +from web3.exceptions import Web3Exception +from web3.types import Gwei, Wei + +from src.common.clients import close_clients, execution_client, setup_clients +from src.common.contracts import ( + VaultContract, + multicall_contract, + os_token_redeemer_contract, +) +from src.common.execution import transaction_gas_wrapper +from src.common.harvest import get_multiple_harvest_params +from src.common.logging import LOG_LEVELS, setup_logging +from src.common.typings import HarvestParams +from src.common.utils import log_verbose +from src.common.wallet import wallet +from src.config.networks import AVAILABLE_NETWORKS, ZERO_CHECKSUM_ADDRESS +from src.config.settings import settings +from src.meta_vault.service import is_meta_vault +from src.redemptions.os_token_converter import ( + OsTokenConverter, + create_os_token_converter, +) +from src.redemptions.tasks import ( + batch_size, + get_processed_shares_batch, + iter_os_token_positions, +) +from src.redemptions.typings import OsTokenPosition +from src.validators.execution import get_withdrawable_assets + +logger = logging.getLogger(__name__) + +DEFAULT_INTERVAL = 60 # 1 minute +DEFAULT_MIN_QUEUED_SHARES = Web3.to_wei(0.1, 'ether') +DEFAULT_MIN_QUEUED_SHARES_GWEI = Web3.from_wei(DEFAULT_MIN_QUEUED_SHARES, 'gwei') + + +@click.option( + '--wallet-password-file', + type=click.Path(exists=True, file_okay=True, dir_okay=False), + envvar='WALLET_PASSWORD_FILE', + help='Absolute path to the wallet password file. ' + 'Default is the file generated with "create-wallet" command.', +) +@click.option( + '--wallet-file', + type=click.Path(exists=True, file_okay=True, dir_okay=False), + envvar='WALLET_FILE', + help='Absolute path to the wallet. ' + 'Default is the file generated with "create-wallet" command.', +) +@click.option( + '--execution-endpoints', + type=str, + envvar='EXECUTION_ENDPOINTS', + prompt='Enter the comma separated list of API endpoints for execution nodes', + help='Comma separated list of API endpoints for execution nodes.', +) +@click.option( + '--execution-jwt-secret', + type=str, + envvar='EXECUTION_JWT_SECRET', + help='JWT secret key used for signing and verifying JSON Web Tokens' + ' when connecting to execution nodes.', +) +@click.option( + '--interval', + type=int, + default=DEFAULT_INTERVAL, + envvar='INTERVAL', + help='Sleep interval in seconds between processing rounds.', +) +@click.option( + '--min-queued-shares-gwei', + type=int, + default=DEFAULT_MIN_QUEUED_SHARES_GWEI, + envvar='MIN_QUEUED_SHARES_GWEI', + help='Minimum queued shares (in Gwei) to trigger redemption processing.', +) +@click.option( + '--log-level', + type=click.Choice( + LOG_LEVELS, + case_sensitive=False, + ), + default='INFO', + envvar='LOG_LEVEL', + help='The log level.', +) +@click.option( + '-v', + '--verbose', + help='Enable debug mode. Default is false.', + envvar='VERBOSE', + is_flag=True, +) +@click.option( + '--network', + help='The network of the meta vaults.', + prompt='Enter the network name', + envvar='NETWORK', + type=click.Choice( + AVAILABLE_NETWORKS, + case_sensitive=False, + ), +) +@click.command( + help='Monitors the EthOsTokenRedeemer/GnoOsTokenRedeemer contracts' + ' and automatically processes OsToken position redemptions' + ' and exit queue checkpoints.' +) +# pylint: disable-next=too-many-arguments +def process_redeemer( + execution_endpoints: str, + execution_jwt_secret: str | None, + network: str, + verbose: bool, + log_level: str, + interval: int, + min_queued_assets_gwei: int, + wallet_file: str | None, + wallet_password_file: str | None, +) -> None: + settings.set( + # No specific vault address is set — redemptions are processed across all vaults. + vault=ZERO_CHECKSUM_ADDRESS, + vault_dir=Path.home() / '.stakewise', + execution_endpoints=execution_endpoints, + execution_jwt_secret=execution_jwt_secret, + verbose=verbose, + network=network, + wallet_file=wallet_file, + wallet_password_file=wallet_password_file, + log_level=log_level, + ) + try: + asyncio.run(main(interval=interval, min_queued_assets=Gwei(min_queued_assets_gwei))) + except Exception as e: + log_verbose(e) + sys.exit(1) + + +async def main( + interval: int, + min_queued_assets: Gwei, +) -> None: + setup_logging() + await setup_clients() + await _startup_check() + try: + with InterruptHandler() as interrupt_handler: + while not interrupt_handler.exit: + block_number = await execution_client.eth.block_number + await process(block_number=block_number, min_queued_assets=min_queued_assets) + await interrupt_handler.sleep(interval) + + finally: + await close_clients() + + +async def process( + block_number: BlockNumber, + min_queued_assets: Gwei, +) -> None: + # Step 1: Process exit queue + await _process_exit_queue(block_number) + + # Re-fetch block number after exit queue processing + # to ensure we read the latest on-chain state + block_number = await execution_client.eth.block_number + + # Step 2: Check queued shares + queued_shares = await os_token_redeemer_contract.queued_shares(block_number) + os_token_converter = await create_os_token_converter(block_number) + queued_assets = os_token_converter.to_assets(queued_shares) + if queued_assets < Web3.to_wei(min_queued_assets, 'gwei'): + logger.info( + 'Queued assets %s below threshold %s. Skipping to next interval.', + Web3.from_wei(queued_assets, 'ether'), + Web3.from_wei(Web3.to_wei(min_queued_assets, 'gwei'), 'ether'), + ) + return + + # The Merkle root was calculated before the nonce was incremented + # in setRedeemablePositions, so we use the previous nonce for Merkle proofs. + nonce = await os_token_redeemer_contract.nonce(block_number) + if nonce == 0: + logger.info('Zero nonce for redemption. Skipping to next interval.') + return + prev_nonce = nonce - 1 + + logger.info( + 'Process queued shares for Redemption: %s (~%s %s)', + queued_shares, + Web3.from_wei(queued_assets, 'ether'), + settings.network_config.VAULT_BALANCE_SYMBOL, + ) + # Step 3: Fetch ALL positions from IPFS (needed for correct merkle tree) + all_positions = await fetch_positions_from_ipfs(block_number) + if not all_positions: + logger.info('No positions found. Skipping to next interval.') + return + + # Step 4: Calculate redeemable shares + os_token_positions = await calculate_redeemable_shares(all_positions, prev_nonce, block_number) + if not os_token_positions: + logger.info('No redeemable positions found. Skipping to next interval.') + return + + # Step 5: Fetch vault params + vaults = {p.vault for p in os_token_positions} + vault_to_harvest_params = await get_multiple_harvest_params(list(vaults), block_number) + # vault_to_withdrawable_assets = await fetch_vault_withdrawable_assets( + # vaults=vaults, + # vault_to_harvest_params=vault_to_harvest_params, + # ) + vault_to_withdrawable_assets: dict[ChecksumAddress, Wei] = {} + for vault in vaults: + vault_to_withdrawable_assets[vault] = await get_withdrawable_assets( + vault, vault_to_harvest_params.get(vault) + ) + + # Step 6: Select positions + positions_to_redeem = await select_positions( + os_token_positions=os_token_positions, + queued_shares=queued_shares, + converter=os_token_converter, + vault_to_harvest_params=vault_to_harvest_params, + vault_to_withdrawable_assets=vault_to_withdrawable_assets, + ) + + if not positions_to_redeem: + logger.info('No positions eligible for redemption.') + return + + # Step 7: Execute redemption (uses all_positions for complete merkle tree) + tx_hash = await execute_redemption( + all_positions=all_positions, + positions_to_redeem=positions_to_redeem, + vault_to_harvest_params=vault_to_harvest_params, + nonce=prev_nonce, + ) + if tx_hash: + logger.info( + 'Successfully redeemed %s OsToken positions. Transaction hash: %s', + len(positions_to_redeem), + tx_hash, + ) + + +async def _process_exit_queue(block_number: BlockNumber) -> None: + """Call processExitQueue() on the redeemer contract if canProcessExitQueue.""" + can_process_exit_queue = await os_token_redeemer_contract.can_process_exit_queue(block_number) + if not can_process_exit_queue: + return + logger.info('Exit queue can be processed. Calling processExitQueue...') + tx_hash = await os_token_redeemer_contract.process_exit_queue() + logger.info('Waiting for processExitQueue transaction %s confirmation', tx_hash) + tx_receipt = await execution_client.eth.wait_for_transaction_receipt( + tx_hash, timeout=settings.execution_transaction_timeout + ) + if not tx_receipt['status']: + logger.error('processExitQueue transaction failed. Tx Hash: %s', tx_hash) + else: + logger.info('processExitQueue confirmed. Tx Hash: %s', tx_hash) + + +async def fetch_positions_from_ipfs(block_number: BlockNumber) -> list[OsTokenPosition]: + positions: list[OsTokenPosition] = [] + async for position in iter_os_token_positions(block_number=block_number): + positions.append(position) + return positions + + +async def calculate_redeemable_shares( + all_positions: list[OsTokenPosition], + nonce: int, + block_number: BlockNumber, +) -> list[OsTokenPosition]: + """Query processed shares and return positions with available_shares > 0.""" + redeemable: list[OsTokenPosition] = [] + + for i in range(0, len(all_positions), batch_size): + batch = all_positions[i : i + batch_size] + processed_shares_batch = await get_processed_shares_batch( + os_token_positions_batch=batch, + nonce=nonce, + block_number=block_number, + ) + for position, processed_shares in zip(batch, processed_shares_batch): + available_shares = position.amount - processed_shares + if available_shares <= 0: + continue + redeemable.append( + OsTokenPosition( + owner=position.owner, + vault=position.vault, + amount=position.amount, + available_shares=Wei(available_shares), + ) + ) + + return redeemable + + +async def select_positions( + os_token_positions: list[OsTokenPosition], + queued_shares: int, + converter: OsTokenConverter, + vault_to_harvest_params: dict[ChecksumAddress, HarvestParams | None], + vault_to_withdrawable_assets: dict[ChecksumAddress, Wei], +) -> list[OsTokenPosition]: + """Select positions to redeem, capped by queued_shares and withdrawable assets. + + Per-vault: if total assets needed exceed withdrawable, attempt meta-vault sub-vault + redemption once for the full deficit before processing individual positions. + """ + vault_to_positions: defaultdict[ChecksumAddress, list[OsTokenPosition]] = defaultdict(list) + for position in os_token_positions: + vault_to_positions[position.vault].append(position) + + positions_to_redeem: list[OsTokenPosition] = [] + remaining_shares = queued_shares + + for vault_address, positions in vault_to_positions.items(): + if remaining_shares <= 0: + break + + withdrawable_assets = await _compute_vault_withdrawable_assets( + vault_address=vault_address, + withdrawable_assets=vault_to_withdrawable_assets[vault_address], + positions=positions, + converter=converter, + remaining_shares=remaining_shares, + harvest_params=vault_to_harvest_params[vault_address], + ) + + for position in positions: + if remaining_shares <= 0: + break + + shares_to_redeem = Wei(min(position.available_shares, remaining_shares)) + redeemable_assets = converter.to_assets(shares_to_redeem) + + if redeemable_assets > withdrawable_assets: + shares_to_redeem = converter.to_shares(withdrawable_assets) + if shares_to_redeem <= 0: + continue + redeemable_assets = converter.to_assets(shares_to_redeem) + + logger.info( + 'Position Owner: %s, Vault: %s, Shares to Redeem: %s', + position.owner, + position.vault, + shares_to_redeem, + ) + positions_to_redeem.append(replace(position, shares_to_redeem=shares_to_redeem)) + withdrawable_assets = Wei(withdrawable_assets - redeemable_assets) + remaining_shares -= shares_to_redeem + + return positions_to_redeem + + +# pylint: disable-next=too-many-arguments +async def _compute_vault_withdrawable_assets( + withdrawable_assets: Wei, + vault_address: ChecksumAddress, + harvest_params: HarvestParams | None, + positions: list[OsTokenPosition], + remaining_shares: int, + converter: OsTokenConverter, +) -> Wei: + """Compute withdrawable assets for a vault, attempting meta-vault redemption if needed.""" + # Pre-compute total assets needed across all positions in this vault + total_vault_assets = Wei(0) + for position in positions: + if remaining_shares <= 0: + break + shares = Wei(min(position.available_shares, remaining_shares)) + total_vault_assets = Wei(total_vault_assets + converter.to_assets(shares)) + remaining_shares -= shares + + # redeem meta vault + if total_vault_assets > withdrawable_assets: + deficit = Wei(total_vault_assets - withdrawable_assets) + withdrawable_assets = await _try_redeem_meta_vault( + vault_address, + deficit, + withdrawable_assets, + harvest_params, + ) + return withdrawable_assets + + +async def _try_redeem_meta_vault( + vault_address: ChecksumAddress, + deficit: Wei, + current_withdrawable: Wei, + harvest_params: HarvestParams | None, +) -> Wei: + """If vault is a meta-vault, redeem sub-vaults for the deficit. + + Returns the (possibly updated) withdrawable assets. + """ + if not await is_meta_vault(vault_address): + return current_withdrawable + + logger.info('Vault %s is a meta-vault with insufficient withdrawable assets.', vault_address) + try: + tx_hash = await os_token_redeemer_contract.redeem_sub_vaults_assets(vault_address, deficit) + logger.info( + 'redeemSubVaultsAssets confirmed for vault %s. Tx Hash: %s', + vault_address, + tx_hash, + ) + except (Web3Exception, RuntimeError): + logger.error( + 'redeemSubVaultsAssets failed for vault %s. ' + 'Proceeding with current withdrawable assets.', + vault_address, + ) + return current_withdrawable + + # Re-query actual withdrawable assets on-chain after sub-vault redemption + return await get_withdrawable_assets(vault_address, harvest_params) + + +async def execute_redemption( + all_positions: list[OsTokenPosition], + positions_to_redeem: list[OsTokenPosition], + vault_to_harvest_params: dict[ChecksumAddress, HarvestParams | None], + nonce: int, +) -> HexStr | None: + """Build multiproof from all positions and execute the redemption transaction.""" + multiproof = build_multi_proof( + all_positions=all_positions, + positions_to_redeem=positions_to_redeem, + tree_nonce=nonce, + ) + calls: list[tuple[ChecksumAddress, HexStr]] = [] + + for vault in set(pos.vault for pos in positions_to_redeem): + harvest_params = vault_to_harvest_params.get(vault) + if harvest_params: + vault_contract = VaultContract(vault) + calls.append( + ( + vault_contract.contract_address, + vault_contract.get_update_state_call(harvest_params), + ) + ) + + # Maps to Solidity struct: + # OsTokenPosition(address vault, address owner, uint256 leafShares, uint256 sharesToRedeem) + positions_arg = [ + (pos.vault, pos.owner, pos.amount, pos.shares_to_redeem) for pos in positions_to_redeem + ] + redeem_call = os_token_redeemer_contract.encode_abi( + fn_name='redeemOsTokenPositions', + args=[positions_arg, multiproof.proof, multiproof.proof_flags], + ) + calls.append((os_token_redeemer_contract.contract_address, redeem_call)) + + try: + tx_function = multicall_contract.functions.aggregate(calls) + tx = await transaction_gas_wrapper(tx_function=tx_function) + except Web3Exception: + logger.exception('Failed to redeem os token positions') + return None + + tx_hash = Web3.to_hex(tx) + logger.info('Waiting for transaction %s confirmation', tx_hash) + tx_receipt = await execution_client.eth.wait_for_transaction_receipt( + tx, timeout=settings.execution_transaction_timeout + ) + if not tx_receipt['status']: + logger.error('Failed to redeem os token positions...') + return None + + return tx_hash + + +async def _startup_check() -> None: + positions_manager = await os_token_redeemer_contract.positions_manager() + if positions_manager != wallet.account.address: + raise RuntimeError( + f'The Position Manager role must be assigned to the address {wallet.account.address}.' + ) + + +def build_multi_proof( + tree_nonce: int, + all_positions: list[OsTokenPosition], + positions_to_redeem: list[OsTokenPosition], +) -> MultiProof[tuple[int, ChecksumAddress, Wei, ChecksumAddress]]: + """Build a merkle multiproof from all positions, proving the positions to redeem.""" + all_leaves = [p.merkle_leaf(tree_nonce) for p in all_positions] + tree = StandardMerkleTree.of( + all_leaves, + ['uint256', 'address', 'uint256', 'address'], + ) + redeem_leaves = [p.merkle_leaf(tree_nonce) for p in positions_to_redeem] + return tree.get_multi_proof(redeem_leaves) diff --git a/src/commands/internal/update_redeemable_positions.py b/src/commands/internal/update_redeemable_positions.py index 03283cb2..8c892181 100644 --- a/src/commands/internal/update_redeemable_positions.py +++ b/src/commands/internal/update_redeemable_positions.py @@ -167,6 +167,7 @@ def update_redeemable_positions( access_key=api_access_key, ) settings.set( + # No specific vault address is set — redemptions are updated across all vaults. vault=ZERO_CHECKSUM_ADDRESS, vault_dir=Path.home() / '.stakewise', execution_endpoints=execution_endpoints, diff --git a/src/commands/tests/test_internal/test_process_redeemer.py b/src/commands/tests/test_internal/test_process_redeemer.py new file mode 100644 index 00000000..0f9ebb36 --- /dev/null +++ b/src/commands/tests/test_internal/test_process_redeemer.py @@ -0,0 +1,590 @@ +import asyncio +from contextlib import contextmanager +from typing import Iterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from eth_typing import BlockNumber, ChecksumAddress, HexStr +from hexbytes import HexBytes +from sw_utils.tests import faker +from web3 import Web3 +from web3.exceptions import Web3Exception +from web3.types import Gwei, Wei + +from src.commands.internal.process_redeemer import ( + _process_exit_queue, + _startup_check, + _try_redeem_meta_vault, + build_multi_proof, + calculate_redeemable_shares, + execute_redemption, + fetch_positions_from_ipfs, + process, + select_positions, +) +from src.common.typings import HarvestParams +from src.redemptions.os_token_converter import OsTokenConverter +from src.redemptions.typings import OsTokenPosition + +MODULE = 'src.commands.internal.process_redeemer' + +VAULT_1 = Web3.to_checksum_address('0x' + '11' * 20) +VAULT_2 = Web3.to_checksum_address('0x' + '22' * 20) +OWNER_1 = Web3.to_checksum_address('0x' + '33' * 20) +OWNER_2 = Web3.to_checksum_address('0x' + '44' * 20) + + +# --- Pure function tests (no mocks) --- + + +class TestBuildMultiProof: + def test_single_position(self) -> None: + position = make_position(amount=1000, available_shares=500) + result = build_multi_proof( + tree_nonce=5, + all_positions=[position], + positions_to_redeem=[position], + ) + assert len(result.leaves) == 1 + + def test_partial_redeem(self) -> None: + pos1 = make_position(vault=VAULT_1, owner=OWNER_1, amount=1000, available_shares=500) + pos2 = make_position(vault=VAULT_2, owner=OWNER_2, amount=2000, available_shares=1000) + + result = build_multi_proof( + tree_nonce=5, + all_positions=[pos1, pos2], + positions_to_redeem=[pos1], + ) + assert len(result.leaves) == 1 + assert len(result.proof) > 0 + + def test_all_positions_redeemed(self) -> None: + pos1 = make_position(vault=VAULT_1, owner=OWNER_1, amount=1000, available_shares=500) + pos2 = make_position(vault=VAULT_2, owner=OWNER_2, amount=2000, available_shares=1000) + + result = build_multi_proof( + tree_nonce=5, + all_positions=[pos1, pos2], + positions_to_redeem=[pos1, pos2], + ) + assert len(result.leaves) == 2 + + +class TestSelectPositions: + async def test_empty_positions(self) -> None: + positions_to_redeem = await select_positions( + os_token_positions=[], + queued_shares=10000, + converter=make_converter(), + vault_to_harvest_params={}, + vault_to_withdrawable_assets={}, + ) + assert positions_to_redeem == [] + + async def test_single_position_sufficient_assets(self) -> None: + position = make_position(available_shares=500) + + positions_to_redeem = await select_positions( + os_token_positions=[position], + queued_shares=10000, + converter=make_converter(), + vault_to_harvest_params={VAULT_1: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(1000)}, + ) + assert len(positions_to_redeem) == 1 + assert positions_to_redeem[0].shares_to_redeem == Wei(500) + + async def test_single_position_insufficient_assets_partial_fill(self) -> None: + position = make_position(available_shares=500) + + with patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=False)): + positions_to_redeem = await select_positions( + os_token_positions=[position], + queued_shares=10000, + converter=make_converter(100, 100), + vault_to_harvest_params={VAULT_1: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(100)}, + ) + assert len(positions_to_redeem) == 1 + assert positions_to_redeem[0].shares_to_redeem == Wei(100) + + async def test_single_position_zero_withdrawable(self) -> None: + position = make_position(available_shares=500) + + with patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=False)): + positions_to_redeem = await select_positions( + os_token_positions=[position], + queued_shares=10000, + converter=make_converter(100, 100), + vault_to_harvest_params={VAULT_1: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(0)}, + ) + assert positions_to_redeem == [] + + async def test_queued_shares_limits_redemption(self) -> None: + position = make_position(available_shares=500) + + positions_to_redeem = await select_positions( + os_token_positions=[position], + queued_shares=200, + converter=make_converter(), + vault_to_harvest_params={VAULT_1: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(10000)}, + ) + assert len(positions_to_redeem) == 1 + assert positions_to_redeem[0].shares_to_redeem == Wei(200) + + async def test_multiple_positions_limited_by_withdrawable_assets(self) -> None: + pos1 = make_position(owner=OWNER_1, available_shares=500) + pos2 = make_position(owner=OWNER_2, available_shares=1000) + + with patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=False)): + positions_to_redeem = await select_positions( + os_token_positions=[pos1, pos2], + queued_shares=10000, + converter=make_converter(100, 100), + vault_to_harvest_params={VAULT_1: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(700)}, + ) + assert len(positions_to_redeem) == 2 + assert positions_to_redeem[0].owner == OWNER_1 + assert positions_to_redeem[0].shares_to_redeem == Wei(500) + assert positions_to_redeem[1].owner == OWNER_2 + assert positions_to_redeem[1].shares_to_redeem == Wei(200) + + async def test_multiple_vaults_both_selected(self) -> None: + pos1 = make_position(vault=VAULT_1, owner=OWNER_1, available_shares=500) + pos2 = make_position(vault=VAULT_2, owner=OWNER_2, available_shares=800) + + positions_to_redeem = await select_positions( + os_token_positions=[pos1, pos2], + queued_shares=10000, + converter=make_converter(100, 100), + vault_to_harvest_params={VAULT_1: None, VAULT_2: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(10000), VAULT_2: Wei(10000)}, + ) + assert len(positions_to_redeem) == 2 + + async def test_stops_across_vaults_when_queued_shares_exhausted(self) -> None: + pos1 = make_position(vault=VAULT_1, owner=OWNER_1, available_shares=500) + pos2 = make_position(vault=VAULT_2, owner=OWNER_2, available_shares=800) + + positions_to_redeem = await select_positions( + os_token_positions=[pos1, pos2], + queued_shares=500, + converter=make_converter(100, 100), + vault_to_harvest_params={VAULT_1: None, VAULT_2: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(10000), VAULT_2: Wei(10000)}, + ) + assert len(positions_to_redeem) == 1 + assert positions_to_redeem[0].vault == VAULT_1 + + async def test_preserves_original_amount(self) -> None: + pos = make_position(amount=1000, available_shares=500) + + positions_to_redeem = await select_positions( + os_token_positions=[pos], + queued_shares=200, + converter=make_converter(), + vault_to_harvest_params={VAULT_1: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(10000)}, + ) + assert positions_to_redeem[0].amount == Wei(1000) + assert positions_to_redeem[0].shares_to_redeem == Wei(200) + + async def test_stops_within_vault_when_queued_shares_exhausted(self) -> None: + pos1 = make_position(owner=OWNER_1, available_shares=400) + pos2 = make_position(owner=OWNER_2, available_shares=300) + + positions_to_redeem = await select_positions( + os_token_positions=[pos1, pos2], + queued_shares=400, + converter=make_converter(100, 100), + vault_to_harvest_params={VAULT_1: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(10000)}, + ) + assert len(positions_to_redeem) == 1 + assert positions_to_redeem[0].owner == OWNER_1 + + async def test_partial_fills_first_then_exhausts_withdrawable(self) -> None: + pos1 = make_position(owner=OWNER_1, available_shares=1000) + pos2 = make_position(owner=OWNER_2, available_shares=100) + + with patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=False)): + positions_to_redeem = await select_positions( + os_token_positions=[pos1, pos2], + queued_shares=10000, + converter=make_converter(100, 100), + vault_to_harvest_params={VAULT_1: None}, + vault_to_withdrawable_assets={VAULT_1: Wei(500)}, + ) + assert len(positions_to_redeem) == 1 + assert positions_to_redeem[0].owner == OWNER_1 + assert positions_to_redeem[0].shares_to_redeem == Wei(500) + + +# --- Async function tests (with mocks) --- + + +class TestFetchPositionsFromIpfs: + async def test_empty_positions(self) -> None: + async def empty_gen(block_number: BlockNumber | None = None): # type: ignore[misc] + return + yield # noqa: unreachable + + with patch(f'{MODULE}.iter_os_token_positions', side_effect=empty_gen): + result = await fetch_positions_from_ipfs(block_number=BlockNumber(100)) + assert result == [] + + async def test_returns_all_positions(self) -> None: + pos1 = make_position(vault=VAULT_1, owner=OWNER_1, amount=1000) + pos2 = make_position(vault=VAULT_2, owner=OWNER_2, amount=2000) + + async def gen(block_number: BlockNumber | None = None): # type: ignore[misc] + yield pos1 + yield pos2 + + with patch(f'{MODULE}.iter_os_token_positions', side_effect=gen): + result = await fetch_positions_from_ipfs(block_number=BlockNumber(100)) + assert len(result) == 2 + assert result[0] is pos1 + assert result[1] is pos2 + + +class TestCalculateRedeemableShares: + async def test_all_shares_processed(self) -> None: + pos = make_position(amount=1000) + with patch( + f'{MODULE}.get_processed_shares_batch', + new=AsyncMock(return_value=[Wei(1000)]), + ): + result = await calculate_redeemable_shares( + [pos], nonce=5, block_number=BlockNumber(100) + ) + assert result == [] + + async def test_partial_processed_shares(self) -> None: + pos = make_position(amount=1000) + with patch( + f'{MODULE}.get_processed_shares_batch', + new=AsyncMock(return_value=[Wei(300)]), + ): + result = await calculate_redeemable_shares( + [pos], nonce=5, block_number=BlockNumber(100) + ) + assert len(result) == 1 + assert result[0].available_shares == Wei(700) + assert result[0].amount == Wei(1000) + + async def test_multiple_positions_mixed(self) -> None: + pos1 = make_position(vault=VAULT_1, owner=OWNER_1, amount=1000) + pos2 = make_position(vault=VAULT_2, owner=OWNER_2, amount=2000) + + with patch( + f'{MODULE}.get_processed_shares_batch', + new=AsyncMock(return_value=[Wei(1000), Wei(500)]), + ): + result = await calculate_redeemable_shares( + [pos1, pos2], nonce=5, block_number=BlockNumber(100) + ) + assert len(result) == 1 + assert result[0].owner == OWNER_2 + assert result[0].available_shares == Wei(1500) + + +class TestTryRedeemMetaVault: + async def test_not_meta_vault(self) -> None: + with patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=False)): + result = await _try_redeem_meta_vault( + vault_address=VAULT_1, + deficit=Wei(400), + current_withdrawable=Wei(100), + harvest_params=None, + ) + assert result == Wei(100) + + async def test_meta_vault_successful_redeem(self) -> None: + with ( + patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=True)), + patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer, + patch( + f'{MODULE}.get_withdrawable_assets', + new=AsyncMock(return_value=Wei(600)), + ), + ): + mock_redeemer.redeem_sub_vaults_assets = AsyncMock(return_value='0xabc') + result = await _try_redeem_meta_vault( + vault_address=VAULT_1, + deficit=Wei(400), + current_withdrawable=Wei(100), + harvest_params=None, + ) + assert result == Wei(600) + mock_redeemer.redeem_sub_vaults_assets.assert_called_once_with(VAULT_1, Wei(400)) + + async def test_meta_vault_failed_redeem(self) -> None: + with ( + patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=True)), + patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer, + ): + mock_redeemer.redeem_sub_vaults_assets = AsyncMock(side_effect=RuntimeError('fail')) + result = await _try_redeem_meta_vault( + vault_address=VAULT_1, + deficit=Wei(400), + current_withdrawable=Wei(100), + harvest_params=None, + ) + assert result == Wei(100) + + +class TestProcessExitQueue: + async def test_cannot_process(self) -> None: + with patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer: + mock_redeemer.can_process_exit_queue = AsyncMock(return_value=False) + await _process_exit_queue(BlockNumber(100)) + mock_redeemer.process_exit_queue.assert_not_called() + + @pytest.mark.parametrize('tx_status', [1, 0]) + async def test_process_exit_queue(self, tx_status: int) -> None: + mock_client = AsyncMock() + mock_client.eth.wait_for_transaction_receipt = AsyncMock(return_value={'status': tx_status}) + with ( + patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer, + patch(f'{MODULE}.execution_client', new=mock_client), + patch(f'{MODULE}.settings') as mock_settings, + ): + mock_settings.execution_transaction_timeout = 120 + mock_redeemer.can_process_exit_queue = AsyncMock(return_value=True) + mock_redeemer.process_exit_queue = AsyncMock(return_value='0xabc') + await _process_exit_queue(BlockNumber(100)) + mock_redeemer.process_exit_queue.assert_called_once() + + +class TestStartupCheck: + async def test_authorized(self) -> None: + wallet_address = faker.eth_address() + mock_wallet = MagicMock() + mock_wallet.account.address = wallet_address + with ( + patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer, + patch(f'{MODULE}.wallet', new=mock_wallet), + ): + mock_redeemer.positions_manager = AsyncMock(return_value=wallet_address) + await _startup_check() + + async def test_unauthorized(self) -> None: + mock_wallet = MagicMock() + mock_wallet.account.address = faker.eth_address() + with ( + patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer, + patch(f'{MODULE}.wallet', new=mock_wallet), + ): + mock_redeemer.positions_manager = AsyncMock(return_value=faker.eth_address()) + with pytest.raises(RuntimeError, match='Position Manager role must be assigned'): + await _startup_check() + + +class TestExecuteRedemption: + async def test_successful_with_harvest_params(self) -> None: + pos = make_position(vault=VAULT_1, amount=1000, available_shares=500, shares_to_redeem=500) + harvest_params = make_harvest_params() + + with _mock_execute_redemption(tx_status=1) as mocks: + mock_vault = mocks['MockVaultContract'].return_value + mock_vault.contract_address = VAULT_1 + mock_vault.get_update_state_call.return_value = HexStr('0xupdate') + + result = await execute_redemption( + all_positions=[pos], + positions_to_redeem=[pos], + vault_to_harvest_params={VAULT_1: harvest_params}, + nonce=5, + ) + + assert result is not None + mock_vault.get_update_state_call.assert_called_once_with(harvest_params) + + async def test_successful_without_harvest_params(self) -> None: + pos = make_position(vault=VAULT_1, amount=1000, available_shares=500, shares_to_redeem=500) + + with _mock_execute_redemption(tx_status=1): + result = await execute_redemption( + all_positions=[pos], + positions_to_redeem=[pos], + vault_to_harvest_params={VAULT_1: None}, + nonce=5, + ) + + assert result is not None + + async def test_web3_exception(self) -> None: + pos = make_position(amount=1000, available_shares=500, shares_to_redeem=500) + + with _mock_execute_redemption(tx_side_effect=Web3Exception('fail')): + result = await execute_redemption( + all_positions=[pos], + positions_to_redeem=[pos], + vault_to_harvest_params={VAULT_1: None}, + nonce=5, + ) + + assert result is None + + async def test_tx_receipt_fails(self) -> None: + pos = make_position(amount=1000, available_shares=500, shares_to_redeem=500) + + with _mock_execute_redemption(tx_status=0): + result = await execute_redemption( + all_positions=[pos], + positions_to_redeem=[pos], + vault_to_harvest_params={VAULT_1: None}, + nonce=5, + ) + + assert result is None + + +class TestProcess: + async def test_no_queued_shares(self) -> None: + with _mock_process() as mocks: + mocks['mock_redeemer'].queued_shares = AsyncMock(return_value=Wei(0)) + await process(block_number=BlockNumber(100), min_queued_assets=Gwei(1)) + + async def test_below_threshold(self) -> None: + with _mock_process() as mocks: + # 500 wei queued shares, threshold is 1000 Gwei + mocks['mock_redeemer'].queued_shares = AsyncMock(return_value=Wei(500)) + await process(block_number=BlockNumber(100), min_queued_assets=Gwei(1000)) + mocks['mock_execute'].assert_not_called() + + async def test_no_eligible_positions(self) -> None: + with _mock_process() as mocks: + mocks['mock_redeemer'].queued_shares = AsyncMock(return_value=Wei(1000)) + mocks['mock_redeemer'].nonce = AsyncMock(return_value=5) + await process(block_number=BlockNumber(100), min_queued_assets=Gwei(0)) + mocks['mock_execute'].assert_not_called() + + async def test_successful_redemption(self) -> None: + positions = [make_position(amount=1000, available_shares=500, shares_to_redeem=500)] + + with _mock_process(positions=positions) as mocks: + mocks['mock_redeemer'].queued_shares = AsyncMock(return_value=Wei(1000)) + mocks['mock_redeemer'].nonce = AsyncMock(return_value=5) + await process(block_number=BlockNumber(100), min_queued_assets=Gwei(0)) + mocks['mock_execute'].assert_called_once() + + +# --- Helpers --- + + +@contextmanager +def _mock_execute_redemption( + tx_status: int = 1, + tx_side_effect: Exception | None = None, +) -> Iterator[dict[str, MagicMock]]: + """Common mock setup for execute_redemption tests.""" + mock_client = AsyncMock() + mock_client.eth.wait_for_transaction_receipt = AsyncMock(return_value={'status': tx_status}) + + tx_mock = AsyncMock( + return_value=b'\x01' * 32, + side_effect=tx_side_effect, + ) + + with ( + patch(f'{MODULE}.VaultContract') as MockVaultContract, + patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer, + patch(f'{MODULE}.multicall_contract'), + patch(f'{MODULE}.transaction_gas_wrapper', new=tx_mock), + patch(f'{MODULE}.execution_client', new=mock_client), + patch(f'{MODULE}.settings') as mock_settings, + ): + mock_settings.execution_transaction_timeout = 120 + mock_redeemer.encode_abi.return_value = HexStr('0xredeem') + mock_redeemer.contract_address = VAULT_2 + yield { + 'MockVaultContract': MockVaultContract, + 'mock_redeemer': mock_redeemer, + } + + +@contextmanager +def _mock_process( + positions: list[OsTokenPosition] | None = None, +) -> Iterator[dict[str, MagicMock]]: + """Common mock setup for process() tests.""" + positions = positions or [] + mock_client = MagicMock() + block_number_future: asyncio.Future[BlockNumber] = asyncio.Future() + block_number_future.set_result(BlockNumber(101)) + mock_client.eth.block_number = block_number_future + + with ( + patch(f'{MODULE}._process_exit_queue', new=AsyncMock()), + patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer, + patch( + f'{MODULE}.create_os_token_converter', + new=AsyncMock(return_value=make_converter()), + ), + patch(f'{MODULE}.settings') as mock_settings, + patch( + f'{MODULE}.fetch_positions_from_ipfs', + new=AsyncMock(return_value=positions), + ), + patch( + f'{MODULE}.calculate_redeemable_shares', + new=AsyncMock(return_value=positions), + ), + patch( + f'{MODULE}.get_multiple_harvest_params', + new=AsyncMock(return_value={VAULT_1: None}), + ), + patch( + f'{MODULE}.get_withdrawable_assets', + new=AsyncMock(return_value=Wei(10000)), + ), + patch( + f'{MODULE}.select_positions', + new=AsyncMock(return_value=positions), + ), + patch( + f'{MODULE}.execute_redemption', + new=AsyncMock(return_value='0xtxhash'), + ) as mock_execute, + patch(f'{MODULE}.execution_client', new=mock_client), + ): + mock_settings.network_config.VAULT_BALANCE_SYMBOL = 'ETH' + yield { + 'mock_redeemer': mock_redeemer, + 'mock_execute': mock_execute, + } + + +def make_converter(total_assets: int = 110, total_shares: int = 100) -> OsTokenConverter: + return OsTokenConverter(Wei(total_assets), Wei(total_shares)) + + +def make_position( + vault: ChecksumAddress = VAULT_1, + owner: ChecksumAddress = OWNER_1, + amount: int = 1000, + available_shares: int = 0, + shares_to_redeem: int = 0, +) -> OsTokenPosition: + return OsTokenPosition( + vault=vault, + owner=owner, + amount=Wei(amount), + available_shares=Wei(available_shares), + shares_to_redeem=Wei(shares_to_redeem), + ) + + +def make_harvest_params() -> HarvestParams: + return HarvestParams( + rewards_root=HexBytes(b'\x01' * 32), + reward=Wei(100), + unlocked_mev_reward=Wei(50), + proof=[HexBytes(b'\x02' * 32)], + ) diff --git a/src/common/contracts.py b/src/common/contracts.py index 00d038bd..91f6c342 100644 --- a/src/common/contracts.py +++ b/src/common/contracts.py @@ -572,6 +572,36 @@ async def get_exit_queue_missing_assets( block_identifier=block_number ) + async def positions_manager(self) -> ChecksumAddress: + return await self.contract.functions.positionsManager().call() + + async def queued_shares(self, block_number: BlockNumber | None = None) -> Wei: + return await self.contract.functions.queuedShares().call(block_identifier=block_number) + + async def can_process_exit_queue(self, block_number: BlockNumber | None = None) -> bool: + return await self.contract.functions.canProcessExitQueue().call( + block_identifier=block_number + ) + + async def process_exit_queue(self) -> HexStr: + tx_function = self.contract.functions.processExitQueue() + tx_hash = await transaction_gas_wrapper(tx_function) + return Web3.to_hex(tx_hash) + + async def redeem_sub_vaults_assets( + self, vault_address: ChecksumAddress, assets_to_redeem: Wei + ) -> HexStr: + tx_function = self.contract.functions.redeemSubVaultsAssets(vault_address, assets_to_redeem) + tx_hash = await transaction_gas_wrapper(tx_function) + tx_receipt = await self.execution_client.eth.wait_for_transaction_receipt( + tx_hash, timeout=settings.execution_transaction_timeout + ) + if not tx_receipt['status']: + raise RuntimeError( + f'redeemSubVaultsAssets transaction failed. Tx Hash: {Web3.to_hex(tx_hash)}' + ) + return Web3.to_hex(tx_hash) + class ValidatorsCheckerContract(ContractWrapper): abi_path = 'abi/IValidatorsChecker.json' diff --git a/src/common/harvest.py b/src/common/harvest.py index e9f89967..d221104a 100644 --- a/src/common/harvest.py +++ b/src/common/harvest.py @@ -1,7 +1,9 @@ +from typing import cast + from hexbytes import HexBytes from sw_utils.networks import GNO_NETWORKS from web3 import Web3 -from web3.types import BlockNumber, Wei +from web3.types import BlockNumber, ChecksumAddress, Wei from src.common.clients import ipfs_fetch_client from src.common.contracts import VaultContract, keeper_contract @@ -9,30 +11,57 @@ from src.config.settings import settings -async def get_harvest_params(block_number: BlockNumber | None = None) -> HarvestParams | None: - if not await keeper_contract.can_harvest(settings.vault, block_number): - return None +async def get_harvest_params( + vault: ChecksumAddress, block_number: BlockNumber | None = None +) -> HarvestParams | None: + """Get harvest params for a single vault.""" + result = await get_multiple_harvest_params([vault], block_number) + return result[vault] + + +async def get_multiple_harvest_params( + vaults: list[ChecksumAddress], block_number: BlockNumber | None = None +) -> dict[ChecksumAddress, HarvestParams | None]: + """Get harvest params for multiple vaults. + + Checks can_harvest for all vaults first, then fetches IPFS data only + if at least one vault is harvestable. + """ + if not vaults: + return {} last_rewards = await keeper_contract.get_last_rewards_update(block_number) if last_rewards is None: - return None + return {vault: None for vault in vaults} + + harvestable_vaults: list[ChecksumAddress] = [ + vault for vault in vaults if await keeper_contract.can_harvest(vault, block_number) + ] + + if not harvestable_vaults: + return {vault: None for vault in vaults} + + ipfs_data = cast(dict, await ipfs_fetch_client.fetch_json(last_rewards.ipfs_hash)) + + results: dict[ChecksumAddress, HarvestParams | None] = {vault: None for vault in vaults} + for vault in harvestable_vaults: + vault_contract = VaultContract(vault) + results[vault] = await _extract_harvest_params( + vault_contract=vault_contract, + ipfs_data=ipfs_data, + rewards_root=last_rewards.rewards_root, + ) - vault_contract = VaultContract(settings.vault) - harvest_params = await _fetch_harvest_params_from_ipfs( - vault_contract=vault_contract, - ipfs_hash=last_rewards.ipfs_hash, - rewards_root=last_rewards.rewards_root, - ) - return harvest_params + return results -async def _fetch_harvest_params_from_ipfs( - vault_contract: VaultContract, ipfs_hash: str, rewards_root: bytes +async def _extract_harvest_params( + vault_contract: VaultContract, ipfs_data: dict, rewards_root: bytes ) -> HarvestParams | None: - ipfs_data = await ipfs_fetch_client.fetch_json(ipfs_hash) + """Extract harvest params for a single vault from pre-fetched IPFS data.""" mev_escrow = await vault_contract.mev_escrow() - for vault_data in ipfs_data['vaults']: # type: ignore + for vault_data in ipfs_data['vaults']: if vault_contract.contract_address != Web3.to_checksum_address(vault_data['vault']): continue diff --git a/src/common/startup_check.py b/src/common/startup_check.py index 4205b0e9..98331570 100644 --- a/src/common/startup_check.py +++ b/src/common/startup_check.py @@ -495,8 +495,10 @@ async def _check_events_logs() -> None: async def _check_vault_withdrawable_assets() -> None: - harvest_params = await get_harvest_params() - withdrawable_assets = await get_withdrawable_assets(harvest_params=harvest_params) + harvest_params = await get_harvest_params(settings.vault) + withdrawable_assets = await get_withdrawable_assets( + settings.vault, harvest_params=harvest_params + ) # Note. We round down assets in the log message because of the case when assets # is slightly less than required amount to register validator. diff --git a/src/common/tests/test_harvest.py b/src/common/tests/test_harvest.py new file mode 100644 index 00000000..1bea35cb --- /dev/null +++ b/src/common/tests/test_harvest.py @@ -0,0 +1,38 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +from eth_typing import BlockNumber +from web3 import Web3 + +from src.common.harvest import get_multiple_harvest_params + +HARVEST_MODULE = 'src.common.harvest' + +VAULT_1 = Web3.to_checksum_address('0x' + '11' * 20) +VAULT_2 = Web3.to_checksum_address('0x' + '22' * 20) +OWNER_1 = Web3.to_checksum_address('0x' + '33' * 20) +OWNER_2 = Web3.to_checksum_address('0x' + '44' * 20) + + +class TestGetMultipleHarvestParams: + async def test_empty_vaults(self) -> None: + result = await get_multiple_harvest_params([], BlockNumber(100)) + assert result == {} + + async def test_no_last_rewards(self) -> None: + with patch(f'{HARVEST_MODULE}.keeper_contract') as mock_keeper: + mock_keeper.get_last_rewards_update = AsyncMock(return_value=None) + result = await get_multiple_harvest_params([VAULT_1], BlockNumber(100)) + assert result == {VAULT_1: None} + + async def test_cannot_harvest(self) -> None: + mock_last_rewards = MagicMock() + mock_last_rewards.ipfs_hash = 'QmTest' + with ( + patch(f'{HARVEST_MODULE}.keeper_contract') as mock_keeper, + patch(f'{HARVEST_MODULE}.ipfs_fetch_client') as mock_ipfs, + ): + mock_keeper.get_last_rewards_update = AsyncMock(return_value=mock_last_rewards) + mock_keeper.can_harvest = AsyncMock(return_value=False) + mock_ipfs.fetch_json = AsyncMock(return_value={}) + result = await get_multiple_harvest_params([VAULT_1, VAULT_2], BlockNumber(100)) + assert result == {VAULT_1: None, VAULT_2: None} diff --git a/src/harvest/tasks.py b/src/harvest/tasks.py index 6d228edf..1467438a 100644 --- a/src/harvest/tasks.py +++ b/src/harvest/tasks.py @@ -19,7 +19,7 @@ async def process_block(self, interrupt_handler: InterruptHandler) -> None: """ # check current gas prices - harvest_params = await get_harvest_params() + harvest_params = await get_harvest_params(settings.vault) if not harvest_params: return diff --git a/src/main.py b/src/main.py index 375d011c..03c0a3ac 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ from src.commands.create_wallet import create_wallet from src.commands.exit_validators import exit_validators from src.commands.init import init +from src.commands.internal.process_redeemer import process_redeemer from src.commands.internal.update_redeemable_positions import ( update_redeemable_positions, ) @@ -58,6 +59,7 @@ def cli() -> None: cli.add_command(node_start) cli.add_command(node_status) cli.add_command(update_redeemable_positions) +cli.add_command(process_redeemer) if __name__ == '__main__': diff --git a/src/redemptions/tasks.py b/src/redemptions/tasks.py index dc8bf7a5..2f901caf 100644 --- a/src/redemptions/tasks.py +++ b/src/redemptions/tasks.py @@ -20,6 +20,7 @@ batch_size = 20 +ZERO_MERKLE_ROOT = HexStr('0x' + '0' * 64) async def get_redemption_assets(chain_head: ChainHead) -> Wei: @@ -27,11 +28,18 @@ async def get_redemption_assets(chain_head: ChainHead) -> Wei: Get redemption assets for operator's vault. For Gno networks return value in GNO-Wei. """ + # The contract increments nonce during setRedeemablePositions, + # but uses nonce - 1 for leaf hash computation during redemption. + nonce = await os_token_redeemer_contract.nonce(chain_head.block_number) + if nonce == 0: + logger.info('Zero nonce for redemption. Skipping redemption assets.') + return Wei(0) + protocol_config = await get_protocol_config() # Aggregate redemption assets per vault vault_to_redemption_assets = await get_vault_to_redemption_assets( - chain_head=chain_head, protocol_config=protocol_config + chain_head=chain_head, tree_nonce=nonce - 1, protocol_config=protocol_config ) # Distribute redemption assets from meta vaults to their underlying vaults vault_to_redemption_assets = await distribute_meta_vault_redemption_assets( @@ -43,7 +51,7 @@ async def get_redemption_assets(chain_head: ChainHead) -> Wei: async def get_vault_to_redemption_assets( - chain_head: ChainHead, protocol_config: ProtocolConfig + chain_head: ChainHead, tree_nonce: int, protocol_config: ProtocolConfig ) -> defaultdict[ChecksumAddress, Wei]: """ Get redemption assets per vault. @@ -66,13 +74,14 @@ async def get_vault_to_redemption_assets( vault_to_redemption_assets = await aggregate_redemption_assets_by_vaults( total_redemption_assets, + tree_nonce=tree_nonce, block_number=chain_head.block_number, ) return vault_to_redemption_assets async def aggregate_redemption_assets_by_vaults( - total_redemption_assets: Wei, block_number: BlockNumber | None = None + total_redemption_assets: Wei, tree_nonce: int, block_number: BlockNumber | None = None ) -> defaultdict[ChecksumAddress, Wei]: """ Iterate through redeemable positions until the total redemption assets are exhausted. @@ -87,7 +96,6 @@ async def aggregate_redemption_assets_by_vaults( os_token_converter = await create_os_token_converter(block_number) total_redemption_shares = os_token_converter.to_shares(total_redemption_assets) - nonce = await os_token_redeemer_contract.nonce(block_number=block_number) vault_to_unprocessed_shares: defaultdict[ChecksumAddress, Wei] = defaultdict(lambda: Wei(0)) # Iterate through redeemable positions until total redemption shares are exhausted @@ -96,7 +104,7 @@ async def aggregate_redemption_assets_by_vaults( ): processed_shares_batch = await get_processed_shares_batch( os_token_positions_batch=os_token_position_batch, - nonce=nonce, + nonce=tree_nonce, block_number=block_number, ) for os_token_position, processed_shares in zip( @@ -143,7 +151,8 @@ async def iter_os_token_positions( # Check whether redeemable positions are available if not redeemable_positions.ipfs_hash: return - + if redeemable_positions.merkle_root == ZERO_MERKLE_ROOT: + return # Fetch redeemable positions data from IPFS data = cast(list[dict], await ipfs_fetch_client.fetch_json(redeemable_positions.ipfs_hash)) diff --git a/src/redemptions/tests/test_tasks.py b/src/redemptions/tests/test_tasks.py index 16529ff7..3d901b24 100644 --- a/src/redemptions/tests/test_tasks.py +++ b/src/redemptions/tests/test_tasks.py @@ -28,7 +28,7 @@ async def test_redeemable_positions_empty(self): with self.patch(redeemable_positions=redeemable_positions): redemption_assets_by_vaults = await aggregate_redemption_assets_by_vaults( - total_redemption_assets + total_redemption_assets, tree_nonce=0 ) assert redemption_assets_by_vaults == {} @@ -66,7 +66,7 @@ async def test_redeemable_positions_1_vault( processed_shares_batch=processed_shares_batch, ): redemption_assets_by_vaults = await aggregate_redemption_assets_by_vaults( - total_redemption_assets + total_redemption_assets, tree_nonce=0 ) assert len(redemption_assets_by_vaults) <= 1 # length can be 0 if no assets to redeem assert redemption_assets_by_vaults[vault_1] == expected_redeemed_assets @@ -132,7 +132,7 @@ async def test_redeemable_positions_2_vaults( processed_shares_batch=processed_shares_batch, ): redemption_assets_by_vaults = await aggregate_redemption_assets_by_vaults( - total_redemption_assets + total_redemption_assets, tree_nonce=0 ) # length can be less than 2 if no assets to redeem assert len(redemption_assets_by_vaults) <= 2 @@ -200,7 +200,7 @@ async def test_2_vaults_many_users(self): processed_shares_batches=processed_shares_batches, ): redemption_assets_by_vaults = await aggregate_redemption_assets_by_vaults( - total_redemption_assets + total_redemption_assets, tree_nonce=0 ) assert len(redemption_assets_by_vaults) == 2 assert redemption_assets_by_vaults[vault_1] == redemption_shares_vault_1 * Decimal( diff --git a/src/redemptions/typings.py b/src/redemptions/typings.py index f780a7d5..ff65e5a6 100644 --- a/src/redemptions/typings.py +++ b/src/redemptions/typings.py @@ -58,6 +58,8 @@ class OsTokenPosition: owner: ChecksumAddress vault: ChecksumAddress amount: Wei + available_shares: Wei = Wei(0) + shares_to_redeem: Wei = Wei(0) def as_dict(self) -> dict: return { diff --git a/src/reward_splitter/tasks.py b/src/reward_splitter/tasks.py index 545ab948..9ba4d8e7 100644 --- a/src/reward_splitter/tasks.py +++ b/src/reward_splitter/tasks.py @@ -61,7 +61,7 @@ async def process_block(self, interrupt_handler: InterruptHandler) -> None: 'Processing fee splitter %s ', reward_splitter.address, ) - harvest_params = await get_harvest_params() + harvest_params = await get_harvest_params(settings.vault) exit_requests = splitter_to_exit_requests.get(reward_splitter.address, []) # nosec reward_splitter_calls = await _get_reward_splitter_calls( diff --git a/src/validators/execution.py b/src/validators/execution.py index 9452139e..d78727ef 100644 --- a/src/validators/execution.py +++ b/src/validators/execution.py @@ -206,9 +206,11 @@ def process_network_validator_event( return None -async def get_withdrawable_assets(harvest_params: HarvestParams | None) -> Wei: +async def get_withdrawable_assets( + vault: ChecksumAddress, harvest_params: HarvestParams | None +) -> Wei: """Fetches vault's available assets for staking.""" - vault_contract = VaultContract(settings.vault) + vault_contract = VaultContract(vault) if harvest_params is None: return await vault_contract.functions.withdrawableAssets().call() diff --git a/src/validators/tasks.py b/src/validators/tasks.py index 61960d37..caf28313 100644 --- a/src/validators/tasks.py +++ b/src/validators/tasks.py @@ -50,7 +50,7 @@ async def process(self) -> None: await update_unused_validator_keys_metric( keystore=self.keystore, ) - harvest_params = await get_harvest_params() + harvest_params = await get_harvest_params(settings.vault) vault_assets = await get_vault_assets(harvest_params=harvest_params) vault_assets = Gwei(max(0, vault_assets - settings.vault_min_balance_gwei)) @@ -262,7 +262,7 @@ async def register_new_validators( async def get_vault_assets(harvest_params: HarvestParams | None) -> Gwei: - vault_assets = await get_withdrawable_assets(harvest_params=harvest_params) + vault_assets = await get_withdrawable_assets(settings.vault, harvest_params=harvest_params) if settings.network in GNO_NETWORKS: # apply GNO -> mGNO exchange rate vault_assets = convert_to_mgno(vault_assets) diff --git a/src/withdrawals/assets.py b/src/withdrawals/assets.py index 5e56960d..c083fb16 100644 --- a/src/withdrawals/assets.py +++ b/src/withdrawals/assets.py @@ -50,7 +50,7 @@ async def get_queued_assets( Get exit queue missing assets. For Gno networks return value in mGNO-Gwei. """ - harvest_params = await get_harvest_params(chain_head.block_number) + harvest_params = await get_harvest_params(settings.vault, chain_head.block_number) # Get exit queue cumulative tickets exit_queue_cumulative_ticket = (