diff --git a/src/redemptions/commands/process_redeemer.py b/src/redemptions/commands/process_redeemer.py index 5c831812..b2cfa0b4 100644 --- a/src/redemptions/commands/process_redeemer.py +++ b/src/redemptions/commands/process_redeemer.py @@ -2,6 +2,7 @@ import logging import sys from dataclasses import replace +from decimal import Decimal from pathlib import Path import click @@ -55,6 +56,11 @@ DEFAULT_MIN_QUEUED_ASSETS = Web3.to_wei(0.1, 'ether') DEFAULT_MIN_QUEUED_ASSETS_GWEI = Web3.from_wei(DEFAULT_MIN_QUEUED_ASSETS, 'gwei') +# Haircut applied when a position is capped to a vault's withdrawable assets, so osToken +# rate growth (avgRewardPerSecond) between simulation and tx inclusion can't push +# receivedAssets above availableAssets and revert the redeem call. +WITHDRAWABLE_ASSETS_HAIRCUT = Decimal('0.999') + @click.option( '--wallet-password-file', @@ -293,6 +299,9 @@ async def _redeem_os_token_positions( # Re-fetch the block number so the freshly-updated state is visible downstream. block_number = await execution_client.eth.block_number + # Recreate the converter after harvesting settles: convertToAssets grows every + # second, so a snapshot taken before waiting for receipts would already be stale. + os_token_converter = await create_os_token_converter(block_number) tree = PositionsMerkleTree(all_positions, nonce) await redeem_positions( @@ -375,8 +384,9 @@ async def redeem_positions( assets_to_redeem = converter.to_assets(shares_to_redeem) if withdrawable < assets_to_redeem: - shares_to_redeem = converter.to_shares(withdrawable) - assets_to_redeem = withdrawable + shares_to_redeem, assets_to_redeem = _apply_withdrawable_haircut( + withdrawable, converter + ) if shares_to_redeem <= 0: continue @@ -415,6 +425,15 @@ async def _get_vault_withdrawable( return vault_to_withdrawable[vault] +def _apply_withdrawable_haircut(withdrawable: Wei, converter: OsTokenConverter) -> tuple[Wei, Wei]: + """Haircut applied when a position is capped to the vault's withdrawable assets, so + osToken rate growth between simulation and tx inclusion can't push receivedAssets + above availableAssets and revert the redeem call. Returns (shares_to_redeem, + assets_to_redeem).""" + assets_to_redeem = Wei(int(withdrawable * WITHDRAWABLE_ASSETS_HAIRCUT)) + return converter.to_shares(assets_to_redeem), assets_to_redeem + + async def _startup_check() -> None: logger.info('Checking connection to execution nodes...') await wait_for_execution_node() diff --git a/src/redemptions/commands/tests/test_process_redeemer.py b/src/redemptions/commands/tests/test_process_redeemer.py index 4d1aa2f6..907f8de4 100644 --- a/src/redemptions/commands/tests/test_process_redeemer.py +++ b/src/redemptions/commands/tests/test_process_redeemer.py @@ -10,6 +10,7 @@ from web3.types import Gwei, Wei from src.redemptions.commands.process_redeemer import ( + WITHDRAWABLE_ASSETS_HAIRCUT, _startup_check, process, redeem_positions, @@ -66,7 +67,8 @@ async def test_single_position_insufficient_assets_partial_fill(self) -> None: ) assert mocks['submit_mock'].await_count == 1 - assert _submitted_position(mocks).shares_to_redeem == Wei(100) + expected_shares = Wei(int(100 * WITHDRAWABLE_ASSETS_HAIRCUT)) + assert _submitted_position(mocks).shares_to_redeem == expected_shares async def test_single_position_zero_withdrawable_skipped(self) -> None: position = make_position(processed_shares=500) @@ -105,7 +107,10 @@ async def test_multiple_positions_share_vault_cache(self) -> None: first_position = _submitted_position(mocks, 0) second_position = _submitted_position(mocks, 1) assert first_position.owner == OWNER_1 and first_position.shares_to_redeem == Wei(500) - assert second_position.owner == OWNER_2 and second_position.shares_to_redeem == Wei(200) + # Remaining withdrawable after pos1 is 200; capped further by the haircut. + expected_second_shares = Wei(int(200 * WITHDRAWABLE_ASSETS_HAIRCUT)) + assert second_position.owner == OWNER_2 + assert second_position.shares_to_redeem == expected_second_shares async def test_budget_derived_ignoring_incoming_shares_to_redeem(self) -> None: """shares_to_redeem is assigned lazily from unprocessed_shares and the remaining @@ -237,6 +242,25 @@ async def test_zero_live_position_skipped_budget_reallocated(self) -> None: assert submitted.owner == OWNER_2 assert submitted.shares_to_redeem == Wei(1000) + async def test_capped_to_withdrawable_applies_haircut(self) -> None: + """When withdrawable < assets_to_redeem, a small margin is subtracted so that + osToken rate growth between simulation and tx inclusion can't push receivedAssets + over the vault's withdrawable assets.""" + pos = make_position(leaf_shares=1200, processed_shares=0) + + with _mock_redeem_positions(withdrawable=Wei(1000)) as mocks: + await redeem_positions( + tree=make_tree([pos]), + os_token_positions=[pos], + total_redemption_shares=Wei(1200), + converter=make_converter(100, 100), + block_number=BlockNumber(100), + ) + + submitted = _submitted_position(mocks) + assert submitted.shares_to_redeem == Wei(int(1000 * WITHDRAWABLE_ASSETS_HAIRCUT)) + assert submitted.shares_to_redeem < Wei(1000) + # --- Async function tests (with mocks) --- @@ -330,6 +354,29 @@ async def test_successful_redemption(self) -> None: assert redeem_call.kwargs['tree'].nonce == 5 assert redeem_call.kwargs['total_redemption_shares'] == Wei(1000) + async def test_converter_recreated_after_vault_state_update(self) -> None: + """The osToken converter used for redemption must reflect state after + update_vaults_state settles, not the snapshot taken before waiting for receipts.""" + positions = [make_position(leaf_shares=1000, processed_shares=500, shares_to_redeem=500)] + converter_before = make_converter(100, 100) + converter_after = make_converter(200, 100) + + with ( + _mock_process(positions=positions) as mocks, + patch( + f'{MODULE}.create_os_token_converter', + new=AsyncMock(side_effect=[converter_before, converter_after]), + ) as mock_create_converter, + ): + 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)) + + assert mock_create_converter.await_count == 2 + redeem_call = mocks['mock_redeem'].await_args + assert redeem_call.kwargs['converter'] is converter_after + async def test_stale_vault_state_skips_redemption(self) -> None: """A failed vault state update leaves stale withdrawable assets and LTVs, so the redemption pass is skipped until the next interval."""