Skip to content

Commit 36daeac

Browse files
Skip redemption positions where LTV > 1
1 parent 7cccb5e commit 36daeac

4 files changed

Lines changed: 59 additions & 1 deletion

File tree

src/common/contracts.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,25 @@ async def is_state_update_required(self, block_number: BlockNumber | None = None
232232
block_identifier=block_number
233233
)
234234

235+
async def get_os_token_position(
236+
self, owner: ChecksumAddress, block_number: BlockNumber | None = None
237+
) -> Wei:
238+
return Wei(
239+
await self.contract.functions.osTokenPositions(owner).call(
240+
block_identifier=block_number
241+
)
242+
)
243+
244+
async def get_user_assets(
245+
self, owner: ChecksumAddress, block_number: BlockNumber | None = None
246+
) -> Wei:
247+
shares = await self.contract.functions.getShares(owner).call(block_identifier=block_number)
248+
return Wei(
249+
await self.contract.functions.convertToAssets(shares).call(
250+
block_identifier=block_number
251+
)
252+
)
253+
235254
async def version(self) -> int:
236255
return await self.contract.functions.version().call()
237256

src/redemptions/commands/process_redeemer.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
OsTokenConverter,
4646
create_os_token_converter,
4747
)
48-
from src.redemptions.tasks import assign_shares_to_redeem
48+
from src.redemptions.tasks import assign_shares_to_redeem, is_position_ltv_exceeded
4949
from src.redemptions.typings import OsTokenPosition
5050
from src.validators.execution import get_withdrawable_assets
5151

@@ -329,6 +329,10 @@ async def redeem_positions(
329329
if position.vault in unharvested_vaults:
330330
continue
331331

332+
if await is_position_ltv_exceeded(position, converter, block_number):
333+
logger.info('Skipping position index=%d: LTV > 1', position.index)
334+
continue
335+
332336
if position.vault not in vault_to_withdrawable:
333337
if await VaultContract(position.vault).is_state_update_required(block_number):
334338
logger.info('Skipping unharvested vault %s', position.vault)

src/redemptions/commands/tests/test_process_redeemer.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,21 @@ async def test_unharvested_vault_skipped(self) -> None:
168168
mocks['get_withdrawable'].assert_not_called()
169169
mocks['submit_mock'].assert_not_called()
170170

171+
async def test_ltv_exceeded_position_skipped(self) -> None:
172+
"""A position where loan > user_assets (LTV > 1) is skipped without redeeming."""
173+
pos = make_position(processed_shares=500)
174+
175+
with _mock_redeem_positions(withdrawable=Wei(10000), ltv_exceeded=True) as mocks:
176+
await redeem_positions(
177+
tree=make_tree([pos]),
178+
os_token_positions=[pos],
179+
converter=make_converter(100, 100),
180+
block_number=BlockNumber(100),
181+
)
182+
183+
mocks['submit_mock'].assert_not_called()
184+
mocks['get_withdrawable'].assert_not_called()
185+
171186
async def test_submit_failure_skips_position(self) -> None:
172187
"""A failed submission skips that position; subsequent positions are still attempted."""
173188
pos1 = make_position(vault=VAULT_1, owner=OWNER_1, processed_shares=500)
@@ -304,6 +319,7 @@ def _mock_redeem_positions(
304319
is_meta_vault: bool = False,
305320
state_update_required: bool = False,
306321
submit_results: list[bool] | None = None,
322+
ltv_exceeded: bool = False,
307323
) -> Iterator[dict[str, MagicMock]]:
308324
"""Mock setup for redeem_positions tests.
309325
@@ -313,6 +329,8 @@ def _mock_redeem_positions(
313329
the unharvested-vault skip. ``submit_results`` controls per-call return values of
314330
tx_redeem_position; a ``False`` entry models a failed submission that should
315331
abort the round. Simulation always succeeds; each live position is simulated first.
332+
``ltv_exceeded`` simulates a position where the user's minted osToken loan exceeds
333+
their vault assets (LTV > 1), causing the position to be skipped.
316334
"""
317335
if isinstance(withdrawable, AsyncMock):
318336
get_withdrawable = withdrawable
@@ -335,6 +353,10 @@ def _mock_redeem_positions(
335353
patch(f'{MODULE}.get_withdrawable_assets', new=get_withdrawable),
336354
patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=is_meta_vault)),
337355
patch(f'{MODULE}.VaultContract', return_value=vault_contract),
356+
patch(
357+
f'{MODULE}.is_position_ltv_exceeded',
358+
new=AsyncMock(return_value=ltv_exceeded),
359+
),
338360
patch(f'{MODULE}.simulate_redeem_position', new=simulate_mock),
339361
patch(f'{MODULE}.tx_redeem_position', new=submit_mock),
340362
):

src/redemptions/tasks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sw_utils.typings import ChainHead, ProtocolConfig
88
from web3.types import Wei
99

10+
from src.common.contracts import VaultContract
1011
from src.common.execution import get_finalized_block_number
1112
from src.common.protocol_config import get_protocol_config
1213
from src.config.settings import settings
@@ -115,6 +116,18 @@ async def aggregate_redemption_assets_by_vaults(
115116
)
116117

117118

119+
async def is_position_ltv_exceeded(
120+
position: OsTokenPosition,
121+
converter: OsTokenConverter,
122+
block_number: BlockNumber,
123+
) -> bool:
124+
vault_contract = VaultContract(position.vault)
125+
minted_shares = await vault_contract.get_os_token_position(position.owner, block_number)
126+
loan_assets = converter.to_assets(minted_shares)
127+
user_assets = await vault_contract.get_user_assets(position.owner, block_number)
128+
return loan_assets > user_assets
129+
130+
118131
async def assign_shares_to_redeem(
119132
positions: list[OsTokenPosition],
120133
total_redemption_shares: Wei,

0 commit comments

Comments
 (0)