Skip to content
Merged
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
19 changes: 19 additions & 0 deletions src/common/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,25 @@ async def is_state_update_required(self, block_number: BlockNumber | None = None
block_identifier=block_number
)

async def get_os_token_position(
self, owner: ChecksumAddress, block_number: BlockNumber | None = None
) -> Wei:
return Wei(
await self.contract.functions.osTokenPositions(owner).call(
block_identifier=block_number
)
)

async def get_user_assets(
self, owner: ChecksumAddress, block_number: BlockNumber | None = None
) -> Wei:
shares = await self.contract.functions.getShares(owner).call(block_identifier=block_number)
return Wei(
await self.contract.functions.convertToAssets(shares).call(
block_identifier=block_number
)
)

async def version(self) -> int:
return await self.contract.functions.version().call()

Expand Down
6 changes: 5 additions & 1 deletion src/redemptions/commands/process_redeemer.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
OsTokenConverter,
create_os_token_converter,
)
from src.redemptions.tasks import assign_shares_to_redeem
from src.redemptions.tasks import assign_shares_to_redeem, is_position_ltv_exceeded
from src.redemptions.typings import OsTokenPosition
from src.validators.execution import get_withdrawable_assets

Expand Down Expand Up @@ -329,6 +329,10 @@ async def redeem_positions(
if position.vault in unharvested_vaults:
continue

if await is_position_ltv_exceeded(position, converter, block_number):
logger.info('Skipping position index=%d: LTV > 1', position.index)
continue

if position.vault not in vault_to_withdrawable:
if await VaultContract(position.vault).is_state_update_required(block_number):
logger.info('Skipping unharvested vault %s', position.vault)
Expand Down
22 changes: 22 additions & 0 deletions src/redemptions/commands/tests/test_process_redeemer.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,21 @@ async def test_unharvested_vault_skipped(self) -> None:
mocks['get_withdrawable'].assert_not_called()
mocks['submit_mock'].assert_not_called()

async def test_ltv_exceeded_position_skipped(self) -> None:
"""A position where loan > user_assets (LTV > 1) is skipped without redeeming."""
pos = make_position(processed_shares=500)

with _mock_redeem_positions(withdrawable=Wei(10000), ltv_exceeded=True) as mocks:
await redeem_positions(
tree=make_tree([pos]),
os_token_positions=[pos],
converter=make_converter(100, 100),
block_number=BlockNumber(100),
)

mocks['submit_mock'].assert_not_called()
mocks['get_withdrawable'].assert_not_called()

async def test_submit_failure_skips_position(self) -> None:
"""A failed submission skips that position; subsequent positions are still attempted."""
pos1 = make_position(vault=VAULT_1, owner=OWNER_1, processed_shares=500)
Expand Down Expand Up @@ -304,6 +319,7 @@ def _mock_redeem_positions(
is_meta_vault: bool = False,
state_update_required: bool = False,
submit_results: list[bool] | None = None,
ltv_exceeded: bool = False,
) -> Iterator[dict[str, MagicMock]]:
"""Mock setup for redeem_positions tests.

Expand All @@ -313,6 +329,8 @@ def _mock_redeem_positions(
the unharvested-vault skip. ``submit_results`` controls per-call return values of
tx_redeem_position; a ``False`` entry models a failed submission that should
abort the round. Simulation always succeeds; each live position is simulated first.
``ltv_exceeded`` simulates a position where the user's minted osToken loan exceeds
their vault assets (LTV > 1), causing the position to be skipped.
"""
if isinstance(withdrawable, AsyncMock):
get_withdrawable = withdrawable
Expand All @@ -335,6 +353,10 @@ def _mock_redeem_positions(
patch(f'{MODULE}.get_withdrawable_assets', new=get_withdrawable),
patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=is_meta_vault)),
patch(f'{MODULE}.VaultContract', return_value=vault_contract),
patch(
f'{MODULE}.is_position_ltv_exceeded',
new=AsyncMock(return_value=ltv_exceeded),
),
patch(f'{MODULE}.simulate_redeem_position', new=simulate_mock),
patch(f'{MODULE}.tx_redeem_position', new=submit_mock),
):
Expand Down
13 changes: 13 additions & 0 deletions src/redemptions/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sw_utils.typings import ChainHead, ProtocolConfig
from web3.types import Wei

from src.common.contracts import VaultContract
from src.common.execution import get_finalized_block_number
from src.common.protocol_config import get_protocol_config
from src.config.settings import settings
Expand Down Expand Up @@ -115,6 +116,18 @@ async def aggregate_redemption_assets_by_vaults(
)


async def is_position_ltv_exceeded(
position: OsTokenPosition,
converter: OsTokenConverter,
block_number: BlockNumber,
) -> bool:
vault_contract = VaultContract(position.vault)
minted_shares = await vault_contract.get_os_token_position(position.owner, block_number)
loan_assets = converter.to_assets(minted_shares)
user_assets = await vault_contract.get_user_assets(position.owner, block_number)
return loan_assets > user_assets


async def assign_shares_to_redeem(
positions: list[OsTokenPosition],
total_redemption_shares: Wei,
Expand Down
Loading