Skip to content
Closed
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
23 changes: 21 additions & 2 deletions src/redemptions/commands/process_redeemer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import sys
from dataclasses import replace
from decimal import Decimal
from pathlib import Path

import click
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
51 changes: 49 additions & 2 deletions src/redemptions/commands/tests/test_process_redeemer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) ---

Expand Down Expand Up @@ -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."""
Expand Down
Loading