Skip to content

Commit ece992d

Browse files
committed
Wait for block sync before get_withdrawable_assets
Signed-off-by: cyc60 <avsysoev60@gmail.com>
1 parent b3e5f5b commit ece992d

3 files changed

Lines changed: 83 additions & 21 deletions

File tree

src/commands/internal/process_redeemer.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,11 @@ async def redeem_positions(
412412
withdrawable = vault_to_withdrawable[position.vault]
413413

414414
if withdrawable < assets_to_redeem and await is_meta_vault(position.vault):
415-
await _redeem_meta_vault_sub_vaults(
415+
last_receipt_block = await _redeem_meta_vault_sub_vaults(
416416
vault_address=position.vault, assets=assets_to_redeem
417417
)
418+
if last_receipt_block is not None:
419+
await wait_for_execution_endpoints_synced(last_receipt_block)
418420
withdrawable = await get_withdrawable_assets(position.vault, harvest_params=None)
419421
vault_to_withdrawable[position.vault] = withdrawable
420422

@@ -468,12 +470,17 @@ async def _startup_check() -> None:
468470
async def _redeem_meta_vault_sub_vaults(
469471
vault_address: ChecksumAddress,
470472
assets: Wei,
471-
) -> None:
473+
) -> BlockNumber | None:
472474
"""Redeem from sub-vaults to bring a meta vault's withdrawable assets up to ``assets``.
473475
474476
Builds a bottom-up redeem order so deepest nested meta vaults are redeemed first,
475477
then submits one redeemSubVaultsAssets transaction per entry. Failures abort the
476478
sequence; the caller refetches withdrawable to discover whatever progress was made.
479+
480+
Returns the block number of the last successful redeemSubVaultsAssets receipt, or
481+
``None`` if no transaction succeeded. The caller uses this as a sync barrier so
482+
subsequent withdrawable reads cannot land on a fallback endpoint that has not
483+
yet seen the redemptions.
477484
"""
478485
try:
479486
redeem_order = await _build_meta_vault_redeem_order(vault_address, assets)
@@ -483,25 +490,29 @@ async def _redeem_meta_vault_sub_vaults(
483490
'Proceeding with current withdrawable assets.',
484491
vault_address,
485492
)
486-
return
493+
return None
487494

495+
last_receipt_block: BlockNumber | None = None
488496
for redeem_entry in redeem_order:
489497
try:
490-
tx_hash = await os_token_redeemer_contract.redeem_sub_vaults_assets(
498+
tx_hash, receipt_block = await os_token_redeemer_contract.redeem_sub_vaults_assets(
491499
redeem_entry.vault, redeem_entry.assets
492500
)
493501
logger.info(
494502
'redeemSubVaultsAssets confirmed for vault %s. Tx Hash: %s',
495503
redeem_entry.vault,
496504
tx_hash,
497505
)
506+
last_receipt_block = receipt_block
498507
except (Web3Exception, RuntimeError, ValueError):
499508
logger.exception(
500509
'redeemSubVaultsAssets failed for vault %s. '
501510
'Proceeding with current withdrawable assets.',
502511
redeem_entry.vault,
503512
)
504-
return
513+
return last_receipt_block
514+
515+
return last_receipt_block
505516

506517

507518
async def _build_meta_vault_redeem_order(

src/commands/tests/test_internal/test_process_redeemer.py

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -211,14 +211,22 @@ async def test_preserves_original_leaf_shares_in_call(self) -> None:
211211
assert submitted.shares_to_redeem == Wei(200)
212212

213213
async def test_meta_vault_redeem_sub_vaults_when_short(self) -> None:
214-
"""Meta vault short on withdrawable triggers sub-vault redemption then refetches."""
214+
"""Meta vault short on withdrawable triggers sub-vault redemption then refetches.
215+
216+
After the redemption, the caller must wait for fallback execution endpoints to
217+
catch up to the receipt block before re-reading withdrawable, otherwise a stale
218+
read can underestimate available assets.
219+
"""
215220
pos = make_position(unprocessed_shares=500)
216221
# First call: short. Second call (post sub-vault redeem): plenty.
217222
get_withdrawable = AsyncMock(side_effect=[Wei(100), Wei(1000)])
218223

219224
with (
220225
_mock_redeem_positions(withdrawable=get_withdrawable, is_meta_vault=True) as mocks,
221-
patch(f'{MODULE}._redeem_meta_vault_sub_vaults', new=AsyncMock()) as mock_redeem_sub,
226+
patch(
227+
f'{MODULE}._redeem_meta_vault_sub_vaults',
228+
new=AsyncMock(return_value=BlockNumber(900)),
229+
) as mock_redeem_sub,
222230
):
223231
await redeem_positions(
224232
all_positions=[pos],
@@ -229,17 +237,45 @@ async def test_meta_vault_redeem_sub_vaults_when_short(self) -> None:
229237
)
230238

231239
mock_redeem_sub.assert_awaited_once()
240+
# Sync barrier called with the receipt block before re-reading withdrawable.
241+
mocks['wait_synced_mock'].assert_any_await(BlockNumber(900))
232242
assert get_withdrawable.await_count == 2
233243
assert _submitted_position(mocks).shares_to_redeem == Wei(500)
234244

245+
async def test_meta_vault_redeem_no_successful_tx_skips_sync_barrier(self) -> None:
246+
"""If sub-vault redemption submitted no successful tx, no sync barrier is needed."""
247+
pos = make_position(unprocessed_shares=500)
248+
get_withdrawable = AsyncMock(side_effect=[Wei(100), Wei(1000)])
249+
250+
with (
251+
_mock_redeem_positions(withdrawable=get_withdrawable, is_meta_vault=True) as mocks,
252+
patch(
253+
f'{MODULE}._redeem_meta_vault_sub_vaults',
254+
new=AsyncMock(return_value=None),
255+
),
256+
):
257+
await redeem_positions(
258+
all_positions=[pos],
259+
os_token_positions=[pos],
260+
queued_shares=10000,
261+
converter=make_converter(100, 100),
262+
tree_nonce=5,
263+
)
264+
265+
# Only the post-position-submit sync, not a sub-vault sync.
266+
mocks['wait_synced_mock'].assert_awaited_once_with(BlockNumber(123))
267+
235268
async def test_meta_vault_redeem_still_short_partial_fill(self) -> None:
236269
"""Sub-vault redemption helped but didn't fully cover — partial fill."""
237270
pos = make_position(unprocessed_shares=500)
238271
get_withdrawable = AsyncMock(side_effect=[Wei(100), Wei(300)])
239272

240273
with (
241274
_mock_redeem_positions(withdrawable=get_withdrawable, is_meta_vault=True) as mocks,
242-
patch(f'{MODULE}._redeem_meta_vault_sub_vaults', new=AsyncMock()),
275+
patch(
276+
f'{MODULE}._redeem_meta_vault_sub_vaults',
277+
new=AsyncMock(return_value=BlockNumber(900)),
278+
),
243279
):
244280
await redeem_positions(
245281
all_positions=[pos],
@@ -261,7 +297,10 @@ async def test_meta_vault_cache_writeback_persists_across_positions(self) -> Non
261297

262298
with (
263299
_mock_redeem_positions(withdrawable=get_withdrawable, is_meta_vault=True) as mocks,
264-
patch(f'{MODULE}._redeem_meta_vault_sub_vaults', new=AsyncMock()),
300+
patch(
301+
f'{MODULE}._redeem_meta_vault_sub_vaults',
302+
new=AsyncMock(return_value=BlockNumber(900)),
303+
),
265304
):
266305
await redeem_positions(
267306
all_positions=[pos1, pos2],
@@ -517,9 +556,12 @@ async def test_successful_redeem(self) -> None:
517556
),
518557
patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer,
519558
):
520-
mock_redeemer.redeem_sub_vaults_assets = AsyncMock(return_value='0xabc')
521-
await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
559+
mock_redeemer.redeem_sub_vaults_assets = AsyncMock(
560+
return_value=('0xabc', BlockNumber(789))
561+
)
562+
result = await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
522563
mock_redeemer.redeem_sub_vaults_assets.assert_awaited_once_with(VAULT_1, Wei(400))
564+
assert result == BlockNumber(789)
523565

524566
@pytest.mark.parametrize('exc_class', [Web3Exception, RuntimeError, ValueError])
525567
async def test_failed_redeem_aborts_sequence(self, exc_class: type[Exception]) -> None:
@@ -537,8 +579,9 @@ async def test_failed_redeem_aborts_sequence(self, exc_class: type[Exception]) -
537579
patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer,
538580
):
539581
mock_redeemer.redeem_sub_vaults_assets = AsyncMock(side_effect=exc_class('fail'))
540-
await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
582+
result = await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
541583
mock_redeemer.redeem_sub_vaults_assets.assert_awaited_once_with(VAULT_2, Wei(200))
584+
assert result is None
542585

543586
async def test_unexpected_exception_propagates(self) -> None:
544587
"""Exceptions outside the (Web3Exception, RuntimeError, ValueError) catch list propagate."""
@@ -554,7 +597,10 @@ async def test_unexpected_exception_propagates(self) -> None:
554597
await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
555598

556599
async def test_first_succeeds_second_fails(self) -> None:
557-
"""First sub-vault redemption succeeds; subsequent failure aborts after the second call."""
600+
"""First sub-vault redemption succeeds; subsequent failure aborts after the second call.
601+
602+
The block of the last successful tx is returned so the caller can sync endpoints.
603+
"""
558604
with (
559605
patch(
560606
f'{MODULE}._build_meta_vault_redeem_order',
@@ -568,17 +614,18 @@ async def test_first_succeeds_second_fails(self) -> None:
568614
patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer,
569615
):
570616
mock_redeemer.redeem_sub_vaults_assets = AsyncMock(
571-
side_effect=['0xabc', RuntimeError('fail')]
617+
side_effect=[('0xabc', BlockNumber(700)), RuntimeError('fail')]
572618
)
573-
await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
619+
result = await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
574620

575621
calls = mock_redeemer.redeem_sub_vaults_assets.call_args_list
576622
assert len(calls) == 2
577623
assert calls[0].args == (VAULT_2, Wei(200))
578624
assert calls[1].args == (VAULT_1, Wei(400))
625+
assert result == BlockNumber(700)
579626

580627
async def test_nested_meta_vault_all_succeed(self) -> None:
581-
"""Nested meta vault is redeemed before parent, in order."""
628+
"""Nested meta vault is redeemed before parent, in order; last receipt block returned."""
582629
with (
583630
patch(
584631
f'{MODULE}._build_meta_vault_redeem_order',
@@ -591,13 +638,16 @@ async def test_nested_meta_vault_all_succeed(self) -> None:
591638
),
592639
patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer,
593640
):
594-
mock_redeemer.redeem_sub_vaults_assets = AsyncMock(return_value='0xabc')
595-
await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
641+
mock_redeemer.redeem_sub_vaults_assets = AsyncMock(
642+
side_effect=[('0xabc', BlockNumber(700)), ('0xdef', BlockNumber(800))]
643+
)
644+
result = await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
596645

597646
calls = mock_redeemer.redeem_sub_vaults_assets.call_args_list
598647
assert len(calls) == 2
599648
assert calls[0].args == (VAULT_2, Wei(200))
600649
assert calls[1].args == (VAULT_1, Wei(400))
650+
assert result == BlockNumber(800)
601651

602652
async def test_build_order_failure_returns_silently(self) -> None:
603653
"""Failure to build the redeem order is logged and swallowed."""
@@ -609,8 +659,9 @@ async def test_build_order_failure_returns_silently(self) -> None:
609659
patch(f'{MODULE}.os_token_redeemer_contract') as mock_redeemer,
610660
):
611661
mock_redeemer.redeem_sub_vaults_assets = AsyncMock()
612-
await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
662+
result = await _redeem_meta_vault_sub_vaults(vault_address=VAULT_1, assets=Wei(400))
613663
mock_redeemer.redeem_sub_vaults_assets.assert_not_called()
664+
assert result is None
614665

615666

616667
class TestBuildMetaVaultRedeemOrder:

src/common/contracts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ async def process_exit_queue(self) -> HexStr:
607607

608608
async def redeem_sub_vaults_assets(
609609
self, vault_address: ChecksumAddress, assets_to_redeem: Wei
610-
) -> HexStr:
610+
) -> tuple[HexStr, BlockNumber]:
611611
tx_function = self.contract.functions.redeemSubVaultsAssets(vault_address, assets_to_redeem)
612612
tx_hash = await transaction_gas_wrapper(tx_function)
613613
tx_receipt = await self.execution_client.eth.wait_for_transaction_receipt(
@@ -617,7 +617,7 @@ async def redeem_sub_vaults_assets(
617617
raise RuntimeError(
618618
f'redeemSubVaultsAssets transaction failed. Tx Hash: {Web3.to_hex(tx_hash)}'
619619
)
620-
return Web3.to_hex(tx_hash)
620+
return Web3.to_hex(tx_hash), tx_receipt['blockNumber']
621621

622622

623623
class ValidatorsCheckerContract(ContractWrapper):

0 commit comments

Comments
 (0)