|
13 | 13 | simulate_redeem_position, |
14 | 14 | tx_process_exit_queue, |
15 | 15 | tx_redeem_position, |
| 16 | + update_vaults_state, |
16 | 17 | ) |
17 | 18 | from src.redemptions.merkle_tree import PositionsMerkleTree |
18 | 19 | from src.redemptions.typings import OsTokenPosition |
19 | 20 |
|
20 | 21 | MODULE = 'src.redemptions.execution' |
21 | 22 |
|
22 | 23 | VAULT_1 = Web3.to_checksum_address('0x' + '11' * 20) |
| 24 | +VAULT_2 = Web3.to_checksum_address('0x' + '22' * 20) |
23 | 25 | OWNER_1 = Web3.to_checksum_address('0x' + '33' * 20) |
24 | 26 |
|
25 | 27 |
|
@@ -111,9 +113,86 @@ async def test_process_exit_queue(self, tx_status: int) -> None: |
111 | 113 | mock_redeemer.process_exit_queue.assert_called_once() |
112 | 114 |
|
113 | 115 |
|
| 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 | + |
114 | 152 | # --- Helpers --- |
115 | 153 |
|
116 | 154 |
|
| 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 | + |
117 | 196 | @contextmanager |
118 | 197 | def _mock_tx_redeem_position( |
119 | 198 | tx_status: int = 1, |
|
0 commit comments