Skip to content

Commit 3512904

Browse files
Restore update_vaults_state with batched multicall
1 parent 4a4ac34 commit 3512904

6 files changed

Lines changed: 156 additions & 3 deletions

File tree

src/config/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,9 @@ def network_config(self) -> NetworkConfig:
410410
)
411411
EVENTS_CONCURRENCY_LIMIT: int = decouple_config('EVENTS_CONCURRENCY_LIMIT', default=10, cast=int)
412412

413+
# Maximum number of updateState calls batched into a single multicall transaction.
414+
MULTICALL_CHUNK_SIZE: int = decouple_config('MULTICALL_CHUNK_SIZE', default=20, cast=int)
415+
413416
# Backoff retries
414417
DEFAULT_RETRY_TIME = 60
415418

src/redemptions/commands/process_redeemer.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
simulate_redeem_position,
3333
tx_process_exit_queue,
3434
tx_redeem_position,
35+
update_vaults_state,
3536
)
3637
from src.redemptions.fetch_positions import (
3738
cached_fetch_positions_from_ipfs,
@@ -273,6 +274,15 @@ async def _redeem_os_token_positions(
273274
logger.info('No redeemable positions found. Skipping to next interval.')
274275
return
275276

277+
if not dry_run:
278+
# Bring vaults up to date on-chain so withdrawable assets and position LTV
279+
# are computed from fresh state rather than skipping unharvested vaults.
280+
vaults = list({position.vault for position in os_token_positions})
281+
await update_vaults_state(vaults=vaults)
282+
283+
# Re-fetch the block number so the freshly-updated state is visible downstream.
284+
block_number = await execution_client.eth.block_number
285+
276286
tree = PositionsMerkleTree(all_positions, nonce)
277287
await redeem_positions(
278288
tree=tree,
@@ -323,7 +333,7 @@ async def redeem_positions(
323333
unharvested_vaults.add(position.vault)
324334
continue
325335
vault_to_withdrawable[position.vault] = await get_withdrawable_assets(
326-
position.vault, harvest_params=None
336+
position.vault, block_number=block_number
327337
)
328338
withdrawable = vault_to_withdrawable[position.vault]
329339

src/redemptions/commands/tests/test_process_redeemer.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,13 +392,18 @@ def _mock_process(
392392
f'{MODULE}.redeem_positions',
393393
new=AsyncMock(),
394394
) as mock_redeem,
395+
patch(
396+
f'{MODULE}.update_vaults_state',
397+
new=AsyncMock(),
398+
) as mock_update_state,
395399
patch(f'{MODULE}.execution_client', new=mock_client),
396400
):
397401
mock_settings.network_config.VAULT_BALANCE_SYMBOL = 'ETH'
398402
mock_redeemer.can_process_exit_queue = AsyncMock(return_value=False)
399403
yield {
400404
'mock_redeemer': mock_redeemer,
401405
'mock_redeem': mock_redeem,
406+
'mock_update_state': mock_update_state,
402407
}
403408

404409

src/redemptions/execution.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,78 @@
11
import logging
2+
from itertools import batched
23

4+
from eth_typing import ChecksumAddress, HexStr
5+
from hexbytes import HexBytes
36
from web3 import Web3
47
from web3.contract.async_contract import AsyncContractFunction
58
from web3.exceptions import Web3Exception
69

710
from src.common.clients import execution_client
11+
from src.common.contracts import VaultContract, multicall_contract
812
from src.common.execution import (
913
transaction_gas_wrapper,
1014
wait_for_execution_endpoints_synced,
1115
)
12-
from src.config.settings import settings
16+
from src.common.harvest import get_multiple_harvest_params
17+
from src.config.settings import MULTICALL_CHUNK_SIZE, settings
18+
from src.meta_vault.service import is_meta_vault
1319
from src.redemptions.contracts import os_token_redeemer_contract
1420
from src.redemptions.merkle_tree import PositionsMerkleTree
1521
from src.redemptions.typings import OsTokenPosition
1622

1723
logger = logging.getLogger(__name__)
1824

1925

26+
async def update_vaults_state(
27+
vaults: list[ChecksumAddress],
28+
) -> None:
29+
"""Bring every regular vault in ``vaults`` up to date on-chain via batched
30+
updateState multicalls.
31+
32+
Meta vaults are skipped. Vaults are selected via ``can_harvest`` at the
33+
latest state. Fresh on-chain state lets ``get_withdrawable_assets`` (and
34+
position LTV) read accurate values without threading harvest_params through
35+
every call.
36+
"""
37+
regular_vaults: list[ChecksumAddress] = []
38+
for vault in vaults:
39+
if await is_meta_vault(vault):
40+
continue
41+
regular_vaults.append(vault)
42+
43+
if not regular_vaults:
44+
return
45+
46+
vault_to_harvest_params = await get_multiple_harvest_params(regular_vaults)
47+
calls: list[tuple[ChecksumAddress, HexStr]] = []
48+
for vault in regular_vaults:
49+
harvest_params = vault_to_harvest_params.get(vault)
50+
if harvest_params is None:
51+
continue
52+
vault_contract = VaultContract(vault)
53+
calls.append(
54+
(vault_contract.contract_address, vault_contract.get_update_state_call(harvest_params))
55+
)
56+
57+
if not calls:
58+
return
59+
60+
for chunk in batched(calls, MULTICALL_CHUNK_SIZE):
61+
await tx_update_vaults_state(list(chunk))
62+
63+
64+
async def tx_update_vaults_state(calls: list[tuple[ChecksumAddress, HexStr]]) -> None:
65+
"""Submit a single updateState multicall and wait for its receipt."""
66+
tx_hash = await multicall_contract.tx_aggregate(calls)
67+
logger.info('Waiting for updateState multicall tx %s confirmation', tx_hash)
68+
tx_receipt = await execution_client.eth.wait_for_transaction_receipt(
69+
HexBytes(Web3.to_bytes(hexstr=tx_hash)), timeout=settings.execution_transaction_timeout
70+
)
71+
if not tx_receipt['status']:
72+
raise RuntimeError(f'updateState multicall tx failed. Tx Hash: {tx_hash}')
73+
logger.info('updateState multicall confirmed. Tx Hash: %s', tx_hash)
74+
75+
2076
async def tx_process_exit_queue() -> None:
2177
"""Call processExitQueue() on the redeemer contract."""
2278
tx_hash = await os_token_redeemer_contract.process_exit_queue()

src/redemptions/tests/test_execution.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
simulate_redeem_position,
1414
tx_process_exit_queue,
1515
tx_redeem_position,
16+
update_vaults_state,
1617
)
1718
from src.redemptions.merkle_tree import PositionsMerkleTree
1819
from src.redemptions.typings import OsTokenPosition
1920

2021
MODULE = 'src.redemptions.execution'
2122

2223
VAULT_1 = Web3.to_checksum_address('0x' + '11' * 20)
24+
VAULT_2 = Web3.to_checksum_address('0x' + '22' * 20)
2325
OWNER_1 = Web3.to_checksum_address('0x' + '33' * 20)
2426

2527

@@ -111,9 +113,86 @@ async def test_process_exit_queue(self, tx_status: int) -> None:
111113
mock_redeemer.process_exit_queue.assert_called_once()
112114

113115

116+
class TestUpdateVaultsState:
117+
async def test_submits_multicall_for_harvestable_vaults(self) -> None:
118+
with _mock_update_vaults_state() as mocks:
119+
await update_vaults_state(vaults=[VAULT_1, VAULT_2])
120+
mocks['tx_aggregate'].assert_awaited_once()
121+
await_args = mocks['tx_aggregate'].await_args
122+
assert await_args is not None
123+
# One updateState call per harvestable vault.
124+
assert len(await_args.args[0]) == 2
125+
126+
async def test_skips_meta_vaults(self) -> None:
127+
with _mock_update_vaults_state(is_meta_vault=True) as mocks:
128+
await update_vaults_state(vaults=[VAULT_1])
129+
mocks['tx_aggregate'].assert_not_awaited()
130+
131+
async def test_skips_when_not_harvestable(self) -> None:
132+
"""can_harvest returns no harvest params, so there is nothing to update."""
133+
with _mock_update_vaults_state(harvest_params=None) as mocks:
134+
await update_vaults_state(vaults=[VAULT_1])
135+
mocks['tx_aggregate'].assert_not_awaited()
136+
137+
async def test_batches_calls_into_chunks(self) -> None:
138+
"""Calls are split into MULTICALL_CHUNK_SIZE-sized transactions."""
139+
with _mock_update_vaults_state(chunk_size=1) as mocks:
140+
await update_vaults_state(vaults=[VAULT_1, VAULT_2])
141+
# Two vaults, chunk size 1 → one multicall transaction per vault.
142+
assert mocks['tx_aggregate'].await_count == 2
143+
for call in mocks['tx_aggregate'].await_args_list:
144+
assert len(call.args[0]) == 1
145+
146+
async def test_raises_on_failed_receipt(self) -> None:
147+
with _mock_update_vaults_state(tx_status=0):
148+
with pytest.raises(RuntimeError, match='updateState multicall tx failed'):
149+
await update_vaults_state(vaults=[VAULT_1])
150+
151+
114152
# --- Helpers ---
115153

116154

155+
@contextmanager
156+
def _mock_update_vaults_state(
157+
is_meta_vault: bool = False,
158+
harvest_params: object = object(),
159+
tx_status: int = 1,
160+
chunk_size: int | None = None,
161+
) -> Iterator[dict[str, AsyncMock]]:
162+
"""Mock setup for update_vaults_state tests. VaultContract, harvest params,
163+
the multicall contract and the execution client are all stubbed so only the
164+
orchestration logic is exercised. ``harvest_params=None`` models a vault that
165+
can_harvest filtered out. ``chunk_size`` overrides MULTICALL_CHUNK_SIZE."""
166+
vault_contract = MagicMock()
167+
vault_contract.contract_address = VAULT_1
168+
vault_contract.get_update_state_call = MagicMock(return_value='0xdeadbeef')
169+
170+
tx_aggregate = AsyncMock(return_value='0x' + 'ab' * 32)
171+
mock_multicall = MagicMock()
172+
mock_multicall.tx_aggregate = tx_aggregate
173+
174+
mock_client = MagicMock()
175+
mock_client.eth.wait_for_transaction_receipt = AsyncMock(return_value={'status': tx_status})
176+
177+
def harvest_params_for(vaults: list, _block: BlockNumber | None = None) -> dict:
178+
return {vault: harvest_params for vault in vaults}
179+
180+
with (
181+
patch(f'{MODULE}.is_meta_vault', new=AsyncMock(return_value=is_meta_vault)),
182+
patch(f'{MODULE}.VaultContract', return_value=vault_contract),
183+
patch(
184+
f'{MODULE}.get_multiple_harvest_params',
185+
new=AsyncMock(side_effect=harvest_params_for),
186+
),
187+
patch(f'{MODULE}.multicall_contract', new=mock_multicall),
188+
patch(f'{MODULE}.execution_client', new=mock_client),
189+
patch(f'{MODULE}.MULTICALL_CHUNK_SIZE', new=chunk_size if chunk_size is not None else 20),
190+
patch(f'{MODULE}.settings') as mock_settings,
191+
):
192+
mock_settings.execution_transaction_timeout = 60
193+
yield {'tx_aggregate': tx_aggregate}
194+
195+
117196
@contextmanager
118197
def _mock_tx_redeem_position(
119198
tx_status: int = 1,

src/validators/execution.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ async def tx_fund_validators(
140140

141141
async def get_withdrawable_assets(
142142
vault: ChecksumAddress,
143-
harvest_params: HarvestParams | None,
143+
harvest_params: HarvestParams | None = None,
144144
block_number: BlockNumber | None = None,
145145
) -> Wei:
146146
"""Fetches vault's available assets for staking."""

0 commit comments

Comments
 (0)