iwa 0.0.61__py3-none-any.whl → 0.0.64__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,7 +46,7 @@ def test_claim_rewards_claim_tx_fails(mock_drain_manager):
46
46
  with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
47
47
  mock_staking = mock_staking_cls.return_value
48
48
  mock_staking.get_staking_state.return_value = StakingState.STAKED
49
- mock_staking.get_accrued_rewards.return_value = 1000000000000000000
49
+ mock_staking.calculate_staking_reward.return_value = 1000000000000000000
50
50
  mock_staking.prepare_claim_tx.return_value = None # Failed to prepare
51
51
 
52
52
  success, amount = mock_drain_manager.claim_rewards()
@@ -63,7 +63,7 @@ def test_claim_rewards_send_fails(mock_drain_manager):
63
63
  with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
64
64
  mock_staking = mock_staking_cls.return_value
65
65
  mock_staking.get_staking_state.return_value = StakingState.STAKED
66
- mock_staking.get_accrued_rewards.return_value = 1000000000000000000
66
+ mock_staking.calculate_staking_reward.return_value = 1000000000000000000
67
67
  mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
68
68
  mock_drain_manager.wallet.sign_and_send_transaction.return_value = (False, None)
69
69
 
@@ -81,7 +81,7 @@ def test_claim_rewards_success_no_event(mock_drain_manager):
81
81
  with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
82
82
  mock_staking = mock_staking_cls.return_value
83
83
  mock_staking.get_staking_state.return_value = StakingState.STAKED
84
- mock_staking.get_accrued_rewards.return_value = 1000000000000000000
84
+ mock_staking.calculate_staking_reward.return_value = 1000000000000000000
85
85
  mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
86
86
  mock_staking.extract_events.return_value = [] # No RewardClaimed event
87
87
  mock_drain_manager.wallet.sign_and_send_transaction.return_value = (
@@ -94,6 +94,33 @@ def test_claim_rewards_success_no_event(mock_drain_manager):
94
94
  assert amount == 1000000000000000000
95
95
 
96
96
 
97
+ def test_claim_rewards_fallback_to_accrued(mock_drain_manager):
98
+ """Test claim_rewards falls back to get_accrued_rewards when calculate_staking_reward fails."""
99
+ mock_drain_manager.service.staking_contract_address = "0xStaking"
100
+ mock_drain_manager.service.service_id = 1
101
+ mock_drain_manager.wallet.master_account.address = "0xMaster"
102
+
103
+ with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
104
+ mock_staking = mock_staking_cls.return_value
105
+ mock_staking.get_staking_state.return_value = StakingState.STAKED
106
+ # calculate_staking_reward fails, should fallback to get_accrued_rewards
107
+ mock_staking.calculate_staking_reward.side_effect = Exception("RPC error")
108
+ mock_staking.get_accrued_rewards.return_value = 2000000000000000000 # 2 OLAS
109
+ mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
110
+ mock_staking.extract_events.return_value = []
111
+ mock_drain_manager.wallet.sign_and_send_transaction.return_value = (
112
+ True,
113
+ {"transactionHash": "0xHash"},
114
+ )
115
+
116
+ success, amount = mock_drain_manager.claim_rewards()
117
+ assert success
118
+ assert amount == 2000000000000000000
119
+ # Verify both methods were called
120
+ mock_staking.calculate_staking_reward.assert_called_once()
121
+ mock_staking.get_accrued_rewards.assert_called_once()
122
+
123
+
97
124
  def test_withdraw_rewards_fallback_to_master(mock_drain_manager):
98
125
  """Test withdraw_rewards falls back to master account when not configured."""
99
126
  mock_drain_manager.service.multisig_address = "0x1111111111111111111111111111111111111111"
@@ -178,3 +205,238 @@ def test_drain_owner_skipped_when_equals_target(mock_drain_manager):
178
205
  # Should skip and return None without calling drain
179
206
  assert result is None
180
207
  mock_drain_manager.wallet.drain.assert_not_called()
208
+
209
+
210
+ def test_claim_rewards_staking_contract_load_fails(mock_drain_manager):
211
+ """Test claim_rewards when StakingContract fails to load."""
212
+ mock_drain_manager.service.staking_contract_address = "0xStaking"
213
+ mock_drain_manager.service.service_id = 1
214
+
215
+ with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
216
+ mock_staking_cls.side_effect = Exception("Failed to load contract")
217
+
218
+ success, amount = mock_drain_manager.claim_rewards()
219
+ assert not success
220
+ assert amount == 0
221
+
222
+
223
+ def test_claim_rewards_not_staked_state(mock_drain_manager):
224
+ """Test claim_rewards when service staking state is not STAKED."""
225
+ mock_drain_manager.service.staking_contract_address = "0xStaking"
226
+ mock_drain_manager.service.service_id = 1
227
+
228
+ with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
229
+ mock_staking = mock_staking_cls.return_value
230
+ mock_staking.get_staking_state.return_value = StakingState.NOT_STAKED
231
+
232
+ success, amount = mock_drain_manager.claim_rewards()
233
+ assert not success
234
+ assert amount == 0
235
+
236
+
237
+ def test_claim_rewards_zero_rewards(mock_drain_manager):
238
+ """Test claim_rewards when claimable rewards is zero."""
239
+ mock_drain_manager.service.staking_contract_address = "0xStaking"
240
+ mock_drain_manager.service.service_id = 1
241
+
242
+ with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
243
+ mock_staking = mock_staking_cls.return_value
244
+ mock_staking.get_staking_state.return_value = StakingState.STAKED
245
+ mock_staking.calculate_staking_reward.return_value = 0
246
+
247
+ success, amount = mock_drain_manager.claim_rewards()
248
+ assert not success
249
+ assert amount == 0
250
+
251
+
252
+ def test_claim_rewards_with_event_amount(mock_drain_manager):
253
+ """Test claim_rewards extracts amount from RewardClaimed event."""
254
+ mock_drain_manager.service.staking_contract_address = "0xStaking"
255
+ mock_drain_manager.service.service_id = 1
256
+ mock_drain_manager.wallet.master_account.address = "0xMaster"
257
+
258
+ with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
259
+ mock_staking = mock_staking_cls.return_value
260
+ mock_staking.get_staking_state.return_value = StakingState.STAKED
261
+ mock_staking.calculate_staking_reward.return_value = 1000000000000000000
262
+ mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
263
+ # RewardClaimed event with 'amount' field
264
+ mock_staking.extract_events.return_value = [
265
+ {"name": "RewardClaimed", "args": {"amount": 1500000000000000000}}
266
+ ]
267
+ mock_drain_manager.wallet.sign_and_send_transaction.return_value = (
268
+ True,
269
+ {"transactionHash": "0xHash"},
270
+ )
271
+
272
+ success, amount = mock_drain_manager.claim_rewards()
273
+ assert success
274
+ # Should use amount from event, not the estimate
275
+ assert amount == 1500000000000000000
276
+
277
+
278
+ def test_claim_rewards_with_event_reward_field(mock_drain_manager):
279
+ """Test claim_rewards extracts from 'reward' field when 'amount' missing."""
280
+ mock_drain_manager.service.staking_contract_address = "0xStaking"
281
+ mock_drain_manager.service.service_id = 1
282
+ mock_drain_manager.wallet.master_account.address = "0xMaster"
283
+
284
+ with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
285
+ mock_staking = mock_staking_cls.return_value
286
+ mock_staking.get_staking_state.return_value = StakingState.STAKED
287
+ mock_staking.calculate_staking_reward.return_value = 1000000000000000000
288
+ mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
289
+ # RewardClaimed event with 'reward' field (no 'amount')
290
+ mock_staking.extract_events.return_value = [
291
+ {"name": "RewardClaimed", "args": {"reward": 2000000000000000000}}
292
+ ]
293
+ mock_drain_manager.wallet.sign_and_send_transaction.return_value = (
294
+ True,
295
+ {"transactionHash": "0xHash"},
296
+ )
297
+
298
+ success, amount = mock_drain_manager.claim_rewards()
299
+ assert success
300
+ assert amount == 2000000000000000000
301
+
302
+
303
+ def test_withdraw_rewards_no_service(mock_drain_manager):
304
+ """Test withdraw_rewards with no active service."""
305
+ mock_drain_manager.service = None
306
+ success, amount = mock_drain_manager.withdraw_rewards()
307
+ assert not success
308
+ assert amount == 0
309
+
310
+
311
+ def test_withdraw_rewards_no_multisig(mock_drain_manager):
312
+ """Test withdraw_rewards when service has no multisig."""
313
+ mock_drain_manager.service.multisig_address = None
314
+ success, amount = mock_drain_manager.withdraw_rewards()
315
+ assert not success
316
+ assert amount == 0
317
+
318
+
319
+ def test_withdraw_rewards_success(mock_drain_manager):
320
+ """Test withdraw_rewards succeeds with balance."""
321
+ mock_drain_manager.service.multisig_address = "0xSafe"
322
+ mock_drain_manager.olas_config.withdrawal_address = "0xWithdraw"
323
+ mock_drain_manager.wallet.account_service.get_tag_by_address.return_value = None
324
+ mock_drain_manager.wallet.send.return_value = "0xTxHash"
325
+
326
+ with patch("iwa.plugins.olas.service_manager.drain.ERC20Contract") as mock_erc20_cls:
327
+ mock_erc20 = mock_erc20_cls.return_value
328
+ mock_erc20.balance_of_wei.return_value = 5000000000000000000 # 5 OLAS
329
+
330
+ success, amount = mock_drain_manager.withdraw_rewards()
331
+ assert success
332
+ assert amount == 5.0 # 5 OLAS
333
+
334
+
335
+ def test_withdraw_rewards_transfer_fails(mock_drain_manager):
336
+ """Test withdraw_rewards when transfer fails."""
337
+ mock_drain_manager.service.multisig_address = "0xSafe"
338
+ mock_drain_manager.olas_config.withdrawal_address = "0xWithdraw"
339
+ mock_drain_manager.wallet.account_service.get_tag_by_address.return_value = None
340
+ mock_drain_manager.wallet.send.return_value = None # Transfer failed
341
+
342
+ with patch("iwa.plugins.olas.service_manager.drain.ERC20Contract") as mock_erc20_cls:
343
+ mock_erc20 = mock_erc20_cls.return_value
344
+ mock_erc20.balance_of_wei.return_value = 5000000000000000000
345
+
346
+ success, amount = mock_drain_manager.withdraw_rewards()
347
+ assert not success
348
+ assert amount == 0
349
+
350
+
351
+ def test_drain_service_full_flow(mock_drain_manager):
352
+ """Test drain_service drains all accounts."""
353
+ mock_drain_manager.service.key = "test_service"
354
+ mock_drain_manager.service.staking_contract_address = None # No staking
355
+ mock_drain_manager.service.multisig_address = "0xSafe"
356
+ mock_drain_manager.service.agent_address = "0xAgent"
357
+ mock_drain_manager.service.service_owner_address = "0xOwner"
358
+ mock_drain_manager.wallet.master_account.address = "0xMaster"
359
+
360
+ # Mock drain returns
361
+ mock_drain_manager.wallet.drain.return_value = (True, {"transactionHash": b"0x123"})
362
+
363
+ result = mock_drain_manager.drain_service(target_address="0xTarget")
364
+
365
+ assert "safe" in result or "agent" in result or "owner" in result
366
+
367
+
368
+ def test_drain_safe_account_with_retry(mock_drain_manager):
369
+ """Test _drain_safe_account retries when rewards were claimed."""
370
+ mock_drain_manager.service.multisig_address = "0xSafe"
371
+ # First call returns None (balance not updated yet), second returns result
372
+ mock_drain_manager.wallet.drain.side_effect = [
373
+ None,
374
+ (True, {"transactionHash": b"0xhash"}),
375
+ ]
376
+
377
+ with patch("time.sleep"): # Don't actually sleep
378
+ mock_drain_manager._drain_safe_account(
379
+ "0xTarget", "gnosis", claimed_rewards=1000
380
+ )
381
+
382
+ # Should have retried
383
+ assert mock_drain_manager.wallet.drain.call_count >= 1
384
+
385
+
386
+ def test_drain_agent_account_success(mock_drain_manager):
387
+ """Test _drain_agent_account success."""
388
+ mock_drain_manager.service.agent_address = "0xAgent"
389
+ mock_drain_manager.wallet.drain.return_value = (True, {"transactionHash": b"0xhash"})
390
+
391
+ result = mock_drain_manager._drain_agent_account("0xTarget", "gnosis")
392
+
393
+ assert result is not None
394
+
395
+
396
+ def test_drain_owner_account_success(mock_drain_manager):
397
+ """Test _drain_owner_account success."""
398
+ mock_drain_manager.service.service_owner_address = "0xOwner"
399
+ mock_drain_manager.wallet.drain.return_value = (True, {"transactionHash": b"0xhash"})
400
+
401
+ result = mock_drain_manager._drain_owner_account("0xTarget", "gnosis")
402
+
403
+ assert result is not None
404
+
405
+
406
+ def test_normalize_drain_result_passthrough(mock_drain_manager):
407
+ """Test _normalize_drain_result passes through non-tuple results."""
408
+ result = mock_drain_manager._normalize_drain_result({"some": "dict"})
409
+ assert result == {"some": "dict"}
410
+
411
+
412
+ def test_normalize_drain_result_string_hash(mock_drain_manager):
413
+ """Test _normalize_drain_result handles string transaction hash."""
414
+ result = mock_drain_manager._normalize_drain_result(
415
+ (True, {"transactionHash": "0xABCDEF"})
416
+ )
417
+ assert result == "0xABCDEF"
418
+
419
+
420
+ def test_claim_rewards_if_needed_success(mock_drain_manager):
421
+ """Test _claim_rewards_if_needed returns claimed amount on success."""
422
+ mock_drain_manager.service.staking_contract_address = "0xStaking"
423
+ mock_drain_manager.claim_rewards = MagicMock(return_value=(True, 1000000000000000000))
424
+
425
+ result = mock_drain_manager._claim_rewards_if_needed(claim_rewards=True)
426
+ assert result == 1000000000000000000
427
+
428
+
429
+ def test_claim_rewards_if_needed_disabled(mock_drain_manager):
430
+ """Test _claim_rewards_if_needed returns 0 when disabled."""
431
+ mock_drain_manager.service.staking_contract_address = "0xStaking"
432
+
433
+ result = mock_drain_manager._claim_rewards_if_needed(claim_rewards=False)
434
+ assert result == 0
435
+
436
+
437
+ def test_claim_rewards_if_needed_no_staking(mock_drain_manager):
438
+ """Test _claim_rewards_if_needed returns 0 when not staked."""
439
+ mock_drain_manager.service.staking_contract_address = None
440
+
441
+ result = mock_drain_manager._claim_rewards_if_needed(claim_rewards=True)
442
+ assert result == 0
@@ -567,3 +567,73 @@ def test_rpc_rotation_stops_when_should_not_retry(
567
567
  assert success is False
568
568
  # Only 1 attempt because should_retry=False
569
569
  assert mock_safe_tx.execute.call_count == 1
570
+
571
+
572
+ # =============================================================================
573
+ # Test: Fee bumping on base fee errors
574
+ # =============================================================================
575
+
576
+
577
+ def test_fee_error_triggers_bump(executor, mock_chain_interface, mock_safe_tx, mock_safe):
578
+ """Test that fee errors trigger gas price bump on retry."""
579
+ with patch.object(executor, "_recreate_safe_client", return_value=mock_safe):
580
+ # First attempt fails with fee error, second succeeds
581
+ mock_safe_tx.execute.side_effect = [
582
+ ValueError("max fee per gas less than block base fee: maxFeePerGas: 596, baseFee: 681"),
583
+ b"tx_hash",
584
+ ]
585
+ mock_chain_interface.web3.eth.wait_for_transaction_receipt.return_value = MagicMock(
586
+ status=1
587
+ )
588
+ # Mock fee calculation
589
+ mock_chain_interface.web3.eth.get_block.return_value = {"baseFeePerGas": 700}
590
+ mock_chain_interface.web3.eth.max_priority_fee = 1
591
+
592
+ with patch("time.sleep"):
593
+ success, tx_hash, receipt = executor.execute_with_retry(
594
+ "0xSafe", mock_safe_tx, ["key1"]
595
+ )
596
+
597
+ assert success is True
598
+ assert mock_safe_tx.execute.call_count == 2
599
+ # Second call should have tx_gas_price (bumped), not eip1559_speed
600
+ second_call_kwargs = mock_safe_tx.execute.call_args_list[1][1]
601
+ assert "tx_gas_price" in second_call_kwargs
602
+
603
+
604
+ def test_fee_error_classification(executor):
605
+ """Test classification of fee-related errors."""
606
+ fee_errors = [
607
+ "max fee per gas less than block base fee",
608
+ "transaction underpriced",
609
+ "maxFeePerGas too low",
610
+ "fee too low for mempool",
611
+ ]
612
+ for error_msg in fee_errors:
613
+ error = ValueError(error_msg)
614
+ result = executor._classify_error(error)
615
+ assert result["is_fee_error"] is True, f"Should detect fee error: {error_msg}"
616
+
617
+
618
+ def test_calculate_bumped_gas_price_eip1559(executor, mock_chain_interface):
619
+ """Test bumped gas price calculation for EIP-1559 chains."""
620
+ mock_chain_interface.web3.eth.get_block.return_value = {"baseFeePerGas": 1000}
621
+ mock_chain_interface.web3.eth.max_priority_fee = 10
622
+
623
+ # With 1.3x bump factor: base_fee * 1.3 * 1.5 + priority = 1000 * 1.3 * 1.5 + 10 = 1960
624
+ result = executor._calculate_bumped_gas_price(1.3)
625
+
626
+ assert result is not None
627
+ assert result == int(1000 * 1.3 * 1.5) + 10
628
+
629
+
630
+ def test_calculate_bumped_gas_price_legacy(executor, mock_chain_interface):
631
+ """Test bumped gas price calculation for legacy chains."""
632
+ mock_chain_interface.web3.eth.get_block.return_value = {} # No baseFeePerGas
633
+ mock_chain_interface.web3.eth.gas_price = 2000
634
+
635
+ # Legacy: gas_price * bump_factor = 2000 * 1.3 = 2600
636
+ result = executor._calculate_bumped_gas_price(1.3)
637
+
638
+ assert result is not None
639
+ assert result == int(2000 * 1.3)