Skip to content

Commit b3e5f5b

Browse files
committed
Call process_exit_queue after redeem
Signed-off-by: cyc60 <avsysoev60@gmail.com>
1 parent b12dbd6 commit b3e5f5b

3 files changed

Lines changed: 74 additions & 29 deletions

File tree

src/commands/internal/process_redeemer.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
multicall_contract,
2323
os_token_redeemer_contract,
2424
)
25-
from src.common.execution import check_gas_price, transaction_gas_wrapper
25+
from src.common.execution import (
26+
check_gas_price,
27+
transaction_gas_wrapper,
28+
wait_for_execution_endpoints_synced,
29+
)
2630
from src.common.harvest import get_multiple_harvest_params
2731
from src.common.logging import LOG_LEVELS, setup_logging
2832
from src.common.utils import log_verbose
@@ -196,17 +200,31 @@ async def process(
196200
block_number: BlockNumber,
197201
min_queued_assets: Gwei,
198202
) -> None:
203+
try:
204+
await _redeem_os_token_positions(min_queued_assets=min_queued_assets)
205+
finally:
206+
# Re-fetch block number after redemption processing
207+
# to ensure we read the latest on-chain state.
208+
block_number = await execution_client.eth.block_number
209+
await _process_exit_queue(block_number)
210+
211+
212+
async def _redeem_os_token_positions(
213+
min_queued_assets: Gwei,
214+
) -> None:
215+
"""Perform the OsToken redemption flow for a single iteration.
216+
217+
Returns early without raising when there is nothing to redeem; the caller
218+
is responsible for processing the exit queue regardless of the outcome.
219+
"""
199220
if not await check_gas_price():
200221
return
201222

202-
# Step 1: Process exit queue
203-
await _process_exit_queue(block_number)
204-
205223
# Re-fetch block number after exit queue processing
206224
# to ensure we read the latest on-chain state
207225
block_number = await execution_client.eth.block_number
208226

209-
# Step 2: Check queued shares
227+
# Check queued shares
210228
queued_shares = await os_token_redeemer_contract.queued_shares(block_number)
211229
os_token_converter = await create_os_token_converter(block_number)
212230
queued_assets = os_token_converter.to_assets(queued_shares)
@@ -233,25 +251,25 @@ async def process(
233251
settings.network_config.VAULT_BALANCE_SYMBOL,
234252
)
235253

236-
# Step 3: Fetch ALL positions from IPFS (needed for correct merkle tree)
254+
# Fetch ALL positions from IPFS (needed for correct merkle tree)
237255
all_positions = await fetch_positions_from_ipfs(block_number)
238256
if not all_positions:
239257
logger.info('No positions found. Skipping to next interval.')
240258
return
241259

242-
# Step 4: Calculate redeemable shares
260+
# Calculate redeemable shares
243261
os_token_positions = await calculate_redeemable_shares(all_positions, prev_nonce, block_number)
244262
if not os_token_positions:
245263
logger.info('No redeemable positions found. Skipping to next interval.')
246264
return
247265

248-
# Step 5: Bring every involved vault up to date on-chain so subsequent reads
266+
# Bring every involved vault up to date on-chain so subsequent reads
249267
# and redemption transactions run against fresh state — no harvest_params
250268
# plumbing and no updateVaultState bundled in a multicall.
251269
vaults = list({position.vault for position in os_token_positions})
252270
await update_vaults_state(vaults=vaults, block_number=block_number)
253271

254-
# Step 6: Redeem each position end-to-end in a single loop. Withdrawable assets
272+
# Redeem each position end-to-end in a single loop. Withdrawable assets
255273
# are fetched once per vault and cached; meta vaults short on assets trigger
256274
# sub-vault redemption inline; one redeemOsTokenPositions tx is submitted per
257275
# position.
@@ -408,13 +426,16 @@ async def redeem_positions(
408426
continue
409427

410428
position_to_redeem = replace(position, shares_to_redeem=shares_to_redeem)
411-
if not await _submit_redeem_position(
429+
receipt_block = await _submit_redeem_position(
412430
position=position_to_redeem,
413431
all_positions=all_positions,
414432
tree_nonce=tree_nonce,
415-
):
433+
)
434+
if receipt_block is None:
416435
return
417436

437+
await wait_for_execution_endpoints_synced(receipt_block)
438+
418439
vault_to_withdrawable[position.vault] = Wei(withdrawable - assets_to_redeem)
419440
remaining_shares -= shares_to_redeem
420441

@@ -513,8 +534,14 @@ async def _submit_redeem_position(
513534
position: OsTokenPosition,
514535
all_positions: list[OsTokenPosition],
515536
tree_nonce: int,
516-
) -> bool:
517-
"""Submit one redeemOsTokenPositions transaction for a single position."""
537+
) -> BlockNumber | None:
538+
"""Submit one redeemOsTokenPositions transaction for a single position.
539+
540+
Returns the receipt's block number on a confirmed successful transaction,
541+
``None`` otherwise. The caller uses the returned block as a sync barrier so
542+
that subsequent reads (e.g. withdrawable balances for the next position)
543+
cannot land on a fallback endpoint that has not yet seen the redemption.
544+
"""
518545
multiproof = _build_multi_proof(
519546
tree_nonce=tree_nonce,
520547
all_positions=all_positions,
@@ -534,7 +561,7 @@ async def _submit_redeem_position(
534561
position.vault,
535562
position.owner,
536563
)
537-
return False
564+
return None
538565

539566
tx_hash = Web3.to_hex(tx)
540567
logger.info(
@@ -553,7 +580,7 @@ async def _submit_redeem_position(
553580
position.owner,
554581
tx_hash,
555582
)
556-
return False
583+
return None
557584

558585
logger.info(
559586
'Redeemed %s shares for position (vault %s, owner %s). Tx Hash: %s',
@@ -562,7 +589,7 @@ async def _submit_redeem_position(
562589
position.owner,
563590
tx_hash,
564591
)
565-
return True
592+
return tx_receipt['blockNumber']
566593

567594

568595
def _build_multi_proof(

src/commands/tests/test_internal/test_process_redeemer.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ async def test_submit_failure_aborts_iteration(self) -> None:
286286

287287
with _mock_redeem_positions(
288288
withdrawable=Wei(10000),
289-
submit_results=[False, True],
289+
submit_results=[None, BlockNumber(123)],
290290
) as mocks:
291291
await redeem_positions(
292292
all_positions=[pos1, pos2],
@@ -304,40 +304,40 @@ async def test_submit_failure_aborts_iteration(self) -> None:
304304

305305

306306
class TestSubmitRedeemPosition:
307-
async def test_success_returns_true(self) -> None:
307+
async def test_success_returns_receipt_block(self) -> None:
308308
position = make_position(leaf_shares=1000, shares_to_redeem=500)
309309
with _mock_submit_redeem_position(tx_status=1) as mocks:
310310
result = await _submit_redeem_position(
311311
position=position,
312312
all_positions=[position],
313313
tree_nonce=5,
314314
)
315-
assert result is True
315+
assert result == BlockNumber(456)
316316
mocks['transaction_gas_wrapper'].assert_awaited_once()
317317
mocks['client'].eth.wait_for_transaction_receipt.assert_awaited_once()
318318

319-
async def test_tx_status_zero_returns_false(self) -> None:
320-
"""A reverted on-chain tx returns False without raising."""
319+
async def test_tx_status_zero_returns_none(self) -> None:
320+
"""A reverted on-chain tx returns None without raising."""
321321
position = make_position(leaf_shares=1000, shares_to_redeem=500)
322322
with _mock_submit_redeem_position(tx_status=0):
323323
result = await _submit_redeem_position(
324324
position=position,
325325
all_positions=[position],
326326
tree_nonce=5,
327327
)
328-
assert result is False
328+
assert result is None
329329

330330
@pytest.mark.parametrize('exc_class', [Web3Exception, RuntimeError, ValueError])
331-
async def test_tx_build_failure_returns_false(self, exc_class: type[Exception]) -> None:
332-
"""Each caught exception during tx build/send returns False."""
331+
async def test_tx_build_failure_returns_none(self, exc_class: type[Exception]) -> None:
332+
"""Each caught exception during tx build/send returns None."""
333333
position = make_position(leaf_shares=1000, shares_to_redeem=500)
334334
with _mock_submit_redeem_position(send_exception=exc_class('boom')) as mocks:
335335
result = await _submit_redeem_position(
336336
position=position,
337337
all_positions=[position],
338338
tree_nonce=5,
339339
)
340-
assert result is False
340+
assert result is None
341341
# Receipt is never awaited when the build step raised
342342
mocks['client'].eth.wait_for_transaction_receipt.assert_not_awaited()
343343

@@ -827,14 +827,14 @@ async def test_successful_redemption(self) -> None:
827827
def _mock_redeem_positions(
828828
withdrawable: Wei | AsyncMock | None = None,
829829
is_meta_vault: bool = False,
830-
submit_results: list[bool] | None = None,
830+
submit_results: list[BlockNumber | None] | None = None,
831831
) -> Iterator[dict[str, MagicMock]]:
832832
"""Mock setup for redeem_positions tests.
833833
834834
``withdrawable`` may be a constant Wei value (returned on every call) or an
835835
AsyncMock for fine-grained control (e.g. ``side_effect=[...]`` for sequenced returns).
836836
``submit_results`` controls per-call return values of _submit_redeem_position;
837-
a False entry models a failed submission that should abort the round.
837+
a ``None`` entry models a failed submission that should abort the round.
838838
"""
839839
if isinstance(withdrawable, AsyncMock):
840840
get_withdrawable = withdrawable
@@ -846,15 +846,20 @@ def _mock_redeem_positions(
846846
if submit_results is not None:
847847
submit_mock = AsyncMock(side_effect=submit_results)
848848
else:
849-
submit_mock = AsyncMock(return_value=True)
849+
submit_mock = AsyncMock(return_value=BlockNumber(123))
850850

851851
with (
852852
patch(f'{MODULE}.get_withdrawable_assets', new=get_withdrawable),
853853
patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=is_meta_vault)),
854854
patch(f'{MODULE}._submit_redeem_position', new=submit_mock),
855+
patch(
856+
f'{MODULE}.wait_for_execution_endpoints_synced',
857+
new=AsyncMock(),
858+
) as wait_synced_mock,
855859
):
856860
yield {
857861
'submit_mock': submit_mock,
862+
'wait_synced_mock': wait_synced_mock,
858863
}
859864

860865

@@ -875,7 +880,9 @@ def _mock_submit_redeem_position(
875880
"""
876881
tx = HexBytes(b'\xab' * 32)
877882
mock_client = AsyncMock()
878-
mock_client.eth.wait_for_transaction_receipt = AsyncMock(return_value={'status': tx_status})
883+
mock_client.eth.wait_for_transaction_receipt = AsyncMock(
884+
return_value={'status': tx_status, 'blockNumber': BlockNumber(456)},
885+
)
879886

880887
if send_exception is not None:
881888
gas_wrapper = AsyncMock(side_effect=send_exception)

src/common/execution.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
from urllib.parse import urlparse
44

5+
from eth_typing import BlockNumber
56
from hexbytes import HexBytes
67
from sw_utils import GasManager, InterruptHandler
78
from web3 import Web3
@@ -19,6 +20,8 @@
1920

2021
ALCHEMY_DOMAIN = '.alchemy.com'
2122

23+
EXECUTION_ENDPOINT_SYNC_POLL_INTERVAL = 0.1
24+
2225

2326
class WalletTask(BaseTask):
2427
async def process_block(self, interrupt_handler: InterruptHandler) -> None:
@@ -88,6 +91,14 @@ async def check_gas_price(high_priority: bool = False) -> bool:
8891
return await gas_manager.check_gas_price(high_priority)
8992

9093

94+
async def wait_for_execution_endpoints_synced(target_block: BlockNumber) -> None:
95+
while True:
96+
current_block = await execution_client.eth.block_number
97+
if current_block >= target_block:
98+
return
99+
await asyncio.sleep(EXECUTION_ENDPOINT_SYNC_POLL_INTERVAL)
100+
101+
91102
def build_gas_manager() -> GasManager:
92103
min_effective_priority_fee_per_gas = settings.network_config.MIN_EFFECTIVE_PRIORITY_FEE_PER_GAS
93104
return GasManager(

0 commit comments

Comments
 (0)