@@ -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
616667class TestBuildMetaVaultRedeemOrder :
0 commit comments