diff --git a/src/common/contracts.py b/src/common/contracts.py index 1f77b859..319d2380 100644 --- a/src/common/contracts.py +++ b/src/common/contracts.py @@ -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() diff --git a/src/redemptions/commands/process_redeemer.py b/src/redemptions/commands/process_redeemer.py index f75da562..d1038e79 100644 --- a/src/redemptions/commands/process_redeemer.py +++ b/src/redemptions/commands/process_redeemer.py @@ -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 @@ -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) diff --git a/src/redemptions/commands/tests/test_process_redeemer.py b/src/redemptions/commands/tests/test_process_redeemer.py index 8fb44d5e..df31edd4 100644 --- a/src/redemptions/commands/tests/test_process_redeemer.py +++ b/src/redemptions/commands/tests/test_process_redeemer.py @@ -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) @@ -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. @@ -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 @@ -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), ): diff --git a/src/redemptions/tasks.py b/src/redemptions/tasks.py index 39be7f52..deb60694 100644 --- a/src/redemptions/tasks.py +++ b/src/redemptions/tasks.py @@ -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 @@ -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,