iwa 0.1.2__py3-none-any.whl → 0.1.4__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.
@@ -495,3 +495,138 @@ class StakingContract(ContractInstance):
495
495
  tx_params={"from": from_address},
496
496
  )
497
497
  return tx
498
+
499
+ def _fetch_events_chunked(
500
+ self,
501
+ event_type: str,
502
+ from_block: int,
503
+ to_block: int,
504
+ chunk_size: int = 500,
505
+ ) -> List:
506
+ """Fetch events in chunks to handle RPC block range limits.
507
+
508
+ Args:
509
+ event_type: Name of the event (Checkpoint, ServiceInactivityWarning, etc.)
510
+ from_block: Starting block number.
511
+ to_block: Ending block number.
512
+ chunk_size: Max blocks per request (default 500).
513
+
514
+ Returns:
515
+ List of event log entries.
516
+
517
+ """
518
+ all_logs: List = []
519
+ current_from = from_block
520
+
521
+ event = getattr(self.contract.events, event_type, None)
522
+ if event is None:
523
+ logger.debug(f"Event {event_type} not found in contract ABI")
524
+ return []
525
+
526
+ while current_from <= to_block:
527
+ current_to = min(current_from + chunk_size - 1, to_block)
528
+
529
+ try:
530
+ event_filter = event.create_filter(
531
+ from_block=current_from, to_block=current_to
532
+ )
533
+ logs = event_filter.get_all_entries()
534
+ all_logs.extend(logs)
535
+ except Exception as e:
536
+ error_msg = str(e).lower()
537
+ # If range too large, try smaller chunks
538
+ if "range" in error_msg or "limit" in error_msg or "10000" in error_msg:
539
+ if chunk_size > 100:
540
+ logger.debug(
541
+ "Block range too large, retrying with smaller chunks"
542
+ )
543
+ smaller_logs = self._fetch_events_chunked(
544
+ event_type, current_from, current_to, chunk_size // 2
545
+ )
546
+ all_logs.extend(smaller_logs)
547
+ else:
548
+ logger.warning(
549
+ f"Cannot fetch {event_type} events for blocks "
550
+ f"{current_from}-{current_to}: {e}"
551
+ )
552
+ else:
553
+ logger.debug(f"Error fetching {event_type} events: {e}")
554
+
555
+ current_from = current_to + 1
556
+
557
+ return all_logs
558
+
559
+ def get_checkpoint_events(
560
+ self, from_block: int, to_block: Optional[int] = None
561
+ ) -> Dict:
562
+ """Get checkpoint-related events from a block range.
563
+
564
+ Retrieves Checkpoint, ServiceInactivityWarning, and ServicesEvicted events
565
+ to determine which services received rewards and which got warnings.
566
+
567
+ Uses chunked fetching to handle RPCs that limit block ranges.
568
+
569
+ Args:
570
+ from_block: Starting block number.
571
+ to_block: Ending block number (defaults to latest).
572
+
573
+ Returns:
574
+ Dict with:
575
+ - epoch: int, the new epoch number
576
+ - rewarded_services: Dict[int, int] mapping service_id -> reward (wei)
577
+ - inactivity_warnings: List[int] of service IDs with warnings
578
+ - evicted_services: List[int] of evicted service IDs
579
+ - checkpoint_block: int, block where checkpoint occurred
580
+
581
+ """
582
+ if to_block is None:
583
+ to_block = self.chain_interface.web3.eth.block_number
584
+
585
+ result: Dict = {
586
+ "epoch": None,
587
+ "rewarded_services": {},
588
+ "inactivity_warnings": [],
589
+ "evicted_services": [],
590
+ "checkpoint_block": None,
591
+ }
592
+
593
+ try:
594
+ # Get Checkpoint events
595
+ checkpoint_logs = self._fetch_events_chunked(
596
+ "Checkpoint", from_block, to_block
597
+ )
598
+
599
+ if checkpoint_logs:
600
+ # Take the most recent checkpoint
601
+ latest = checkpoint_logs[-1]
602
+ result["epoch"] = latest.args.get("epoch")
603
+ result["checkpoint_block"] = latest.blockNumber
604
+
605
+ # Parse serviceIds and rewards arrays
606
+ service_ids = latest.args.get("serviceIds", [])
607
+ rewards = latest.args.get("rewards", [])
608
+
609
+ for sid, reward in zip(service_ids, rewards, strict=True):
610
+ result["rewarded_services"][sid] = reward
611
+
612
+ # Get ServiceInactivityWarning events
613
+ warning_logs = self._fetch_events_chunked(
614
+ "ServiceInactivityWarning", from_block, to_block
615
+ )
616
+ for log in warning_logs:
617
+ service_id = log.args.get("serviceId")
618
+ if service_id is not None:
619
+ result["inactivity_warnings"].append(service_id)
620
+
621
+ # Get ServicesEvicted events
622
+ evicted_logs = self._fetch_events_chunked(
623
+ "ServicesEvicted", from_block, to_block
624
+ )
625
+ for log in evicted_logs:
626
+ evicted_ids = log.args.get("serviceIds", [])
627
+ result["evicted_services"].extend(evicted_ids)
628
+
629
+ except Exception as e:
630
+ logger.error(f"Error fetching checkpoint events: {e}")
631
+
632
+ return result
@@ -271,3 +271,503 @@ def test_staking_contract(tmp_path): # noqa: C901
271
271
  # Verify new nonces fields
272
272
  assert info["current_safe_nonce"] == 5
273
273
  assert info["current_mech_requests"] == 3
274
+
275
+
276
+ def test_get_checkpoint_events():
277
+ """Test get_checkpoint_events method."""
278
+ with patch("builtins.open", side_effect=side_effect_open):
279
+ with patch("iwa.core.contracts.contract.ChainInterfaces") as mock_interfaces:
280
+ mock_chain = MagicMock()
281
+ mock_interfaces.return_value.get.return_value = mock_chain
282
+
283
+ mock_web3 = MagicMock()
284
+ mock_chain.web3 = mock_web3
285
+ mock_chain.web3._web3 = mock_web3
286
+ mock_web3.eth.block_number = 1000
287
+
288
+ mock_contract = MagicMock()
289
+ mock_web3.eth.contract.return_value = mock_contract
290
+
291
+ # Mock ActivityChecker calls
292
+ mock_contract.functions.agentMech.return_value.call.return_value = VALID_ADDR_2
293
+ mock_contract.functions.livenessRatio.return_value.call.return_value = 10**18
294
+
295
+ with patch(
296
+ "iwa.plugins.olas.contracts.staking.ContractInstance.call"
297
+ ) as mock_call_base:
298
+
299
+ def init_side_effect(method, *args):
300
+ if method == "activityChecker":
301
+ return VALID_ADDR_4
302
+ if method == "stakingToken":
303
+ return VALID_ADDR_2
304
+ return 0
305
+
306
+ mock_call_base.side_effect = init_side_effect
307
+
308
+ staking = StakingContract(VALID_ADDR_1)
309
+
310
+ # Test 1: Checkpoint event with rewarded services
311
+ mock_checkpoint_log = MagicMock()
312
+ mock_checkpoint_log.args = {
313
+ "epoch": 42,
314
+ "serviceIds": [101, 102, 103],
315
+ "rewards": [1000, 2000, 3000],
316
+ }
317
+ mock_checkpoint_log.blockNumber = 999
318
+
319
+ mock_checkpoint_filter = MagicMock()
320
+ mock_checkpoint_filter.get_all_entries.return_value = [mock_checkpoint_log]
321
+ mock_contract.events.Checkpoint.create_filter.return_value = (
322
+ mock_checkpoint_filter
323
+ )
324
+
325
+ # No warnings or evictions
326
+ mock_warning_filter = MagicMock()
327
+ mock_warning_filter.get_all_entries.return_value = []
328
+ mock_contract.events.ServiceInactivityWarning.create_filter.return_value = (
329
+ mock_warning_filter
330
+ )
331
+
332
+ mock_evicted_filter = MagicMock()
333
+ mock_evicted_filter.get_all_entries.return_value = []
334
+ mock_contract.events.ServicesEvicted.create_filter.return_value = (
335
+ mock_evicted_filter
336
+ )
337
+
338
+ result = staking.get_checkpoint_events(from_block=900, to_block=1000)
339
+
340
+ assert result["epoch"] == 42
341
+ assert result["checkpoint_block"] == 999
342
+ assert result["rewarded_services"] == {101: 1000, 102: 2000, 103: 3000}
343
+ assert result["inactivity_warnings"] == []
344
+ assert result["evicted_services"] == []
345
+
346
+
347
+ def test_get_checkpoint_events_with_warnings():
348
+ """Test get_checkpoint_events with inactivity warnings."""
349
+ with patch("builtins.open", side_effect=side_effect_open):
350
+ with patch("iwa.core.contracts.contract.ChainInterfaces") as mock_interfaces:
351
+ mock_chain = MagicMock()
352
+ mock_interfaces.return_value.get.return_value = mock_chain
353
+
354
+ mock_web3 = MagicMock()
355
+ mock_chain.web3 = mock_web3
356
+ mock_chain.web3._web3 = mock_web3
357
+ mock_web3.eth.block_number = 1000
358
+
359
+ mock_contract = MagicMock()
360
+ mock_web3.eth.contract.return_value = mock_contract
361
+
362
+ mock_contract.functions.agentMech.return_value.call.return_value = VALID_ADDR_2
363
+ mock_contract.functions.livenessRatio.return_value.call.return_value = 10**18
364
+
365
+ with patch(
366
+ "iwa.plugins.olas.contracts.staking.ContractInstance.call"
367
+ ) as mock_call_base:
368
+
369
+ def init_side_effect(method, *args):
370
+ if method == "activityChecker":
371
+ return VALID_ADDR_4
372
+ if method == "stakingToken":
373
+ return VALID_ADDR_2
374
+ return 0
375
+
376
+ mock_call_base.side_effect = init_side_effect
377
+
378
+ staking = StakingContract(VALID_ADDR_1)
379
+
380
+ # Checkpoint event
381
+ mock_checkpoint_log = MagicMock()
382
+ mock_checkpoint_log.args = {
383
+ "epoch": 10,
384
+ "serviceIds": [101, 102],
385
+ "rewards": [1000, 2000],
386
+ }
387
+ mock_checkpoint_log.blockNumber = 999
388
+
389
+ mock_checkpoint_filter = MagicMock()
390
+ mock_checkpoint_filter.get_all_entries.return_value = [mock_checkpoint_log]
391
+ mock_contract.events.Checkpoint.create_filter.return_value = (
392
+ mock_checkpoint_filter
393
+ )
394
+
395
+ # Inactivity warnings
396
+ mock_warning_log_1 = MagicMock()
397
+ mock_warning_log_1.args = {"serviceId": 101}
398
+ mock_warning_log_2 = MagicMock()
399
+ mock_warning_log_2.args = {"serviceId": 103}
400
+
401
+ mock_warning_filter = MagicMock()
402
+ mock_warning_filter.get_all_entries.return_value = [
403
+ mock_warning_log_1,
404
+ mock_warning_log_2,
405
+ ]
406
+ mock_contract.events.ServiceInactivityWarning.create_filter.return_value = (
407
+ mock_warning_filter
408
+ )
409
+
410
+ mock_evicted_filter = MagicMock()
411
+ mock_evicted_filter.get_all_entries.return_value = []
412
+ mock_contract.events.ServicesEvicted.create_filter.return_value = (
413
+ mock_evicted_filter
414
+ )
415
+
416
+ result = staking.get_checkpoint_events(from_block=900)
417
+
418
+ assert result["epoch"] == 10
419
+ assert result["inactivity_warnings"] == [101, 103]
420
+ assert result["evicted_services"] == []
421
+
422
+
423
+ def test_get_checkpoint_events_with_evictions():
424
+ """Test get_checkpoint_events with evicted services."""
425
+ with patch("builtins.open", side_effect=side_effect_open):
426
+ with patch("iwa.core.contracts.contract.ChainInterfaces") as mock_interfaces:
427
+ mock_chain = MagicMock()
428
+ mock_interfaces.return_value.get.return_value = mock_chain
429
+
430
+ mock_web3 = MagicMock()
431
+ mock_chain.web3 = mock_web3
432
+ mock_chain.web3._web3 = mock_web3
433
+ mock_web3.eth.block_number = 1000
434
+
435
+ mock_contract = MagicMock()
436
+ mock_web3.eth.contract.return_value = mock_contract
437
+
438
+ mock_contract.functions.agentMech.return_value.call.return_value = VALID_ADDR_2
439
+ mock_contract.functions.livenessRatio.return_value.call.return_value = 10**18
440
+
441
+ with patch(
442
+ "iwa.plugins.olas.contracts.staking.ContractInstance.call"
443
+ ) as mock_call_base:
444
+
445
+ def init_side_effect(method, *args):
446
+ if method == "activityChecker":
447
+ return VALID_ADDR_4
448
+ if method == "stakingToken":
449
+ return VALID_ADDR_2
450
+ return 0
451
+
452
+ mock_call_base.side_effect = init_side_effect
453
+
454
+ staking = StakingContract(VALID_ADDR_1)
455
+
456
+ # Checkpoint event
457
+ mock_checkpoint_log = MagicMock()
458
+ mock_checkpoint_log.args = {
459
+ "epoch": 5,
460
+ "serviceIds": [102],
461
+ "rewards": [5000],
462
+ }
463
+ mock_checkpoint_log.blockNumber = 888
464
+
465
+ mock_checkpoint_filter = MagicMock()
466
+ mock_checkpoint_filter.get_all_entries.return_value = [mock_checkpoint_log]
467
+ mock_contract.events.Checkpoint.create_filter.return_value = (
468
+ mock_checkpoint_filter
469
+ )
470
+
471
+ # No warnings
472
+ mock_warning_filter = MagicMock()
473
+ mock_warning_filter.get_all_entries.return_value = []
474
+ mock_contract.events.ServiceInactivityWarning.create_filter.return_value = (
475
+ mock_warning_filter
476
+ )
477
+
478
+ # Evictions
479
+ mock_evicted_log = MagicMock()
480
+ mock_evicted_log.args = {"serviceIds": [101, 104]}
481
+
482
+ mock_evicted_filter = MagicMock()
483
+ mock_evicted_filter.get_all_entries.return_value = [mock_evicted_log]
484
+ mock_contract.events.ServicesEvicted.create_filter.return_value = (
485
+ mock_evicted_filter
486
+ )
487
+
488
+ result = staking.get_checkpoint_events(from_block=800)
489
+
490
+ assert result["epoch"] == 5
491
+ assert result["checkpoint_block"] == 888
492
+ assert result["rewarded_services"] == {102: 5000}
493
+ assert result["evicted_services"] == [101, 104]
494
+
495
+
496
+ def test_get_checkpoint_events_no_events():
497
+ """Test get_checkpoint_events when no checkpoint events found."""
498
+ with patch("builtins.open", side_effect=side_effect_open):
499
+ with patch("iwa.core.contracts.contract.ChainInterfaces") as mock_interfaces:
500
+ mock_chain = MagicMock()
501
+ mock_interfaces.return_value.get.return_value = mock_chain
502
+
503
+ mock_web3 = MagicMock()
504
+ mock_chain.web3 = mock_web3
505
+ mock_chain.web3._web3 = mock_web3
506
+ mock_web3.eth.block_number = 1000
507
+
508
+ mock_contract = MagicMock()
509
+ mock_web3.eth.contract.return_value = mock_contract
510
+
511
+ mock_contract.functions.agentMech.return_value.call.return_value = VALID_ADDR_2
512
+ mock_contract.functions.livenessRatio.return_value.call.return_value = 10**18
513
+
514
+ with patch(
515
+ "iwa.plugins.olas.contracts.staking.ContractInstance.call"
516
+ ) as mock_call_base:
517
+
518
+ def init_side_effect(method, *args):
519
+ if method == "activityChecker":
520
+ return VALID_ADDR_4
521
+ if method == "stakingToken":
522
+ return VALID_ADDR_2
523
+ return 0
524
+
525
+ mock_call_base.side_effect = init_side_effect
526
+
527
+ staking = StakingContract(VALID_ADDR_1)
528
+
529
+ # No checkpoint events
530
+ mock_checkpoint_filter = MagicMock()
531
+ mock_checkpoint_filter.get_all_entries.return_value = []
532
+ mock_contract.events.Checkpoint.create_filter.return_value = (
533
+ mock_checkpoint_filter
534
+ )
535
+
536
+ mock_warning_filter = MagicMock()
537
+ mock_warning_filter.get_all_entries.return_value = []
538
+ mock_contract.events.ServiceInactivityWarning.create_filter.return_value = (
539
+ mock_warning_filter
540
+ )
541
+
542
+ mock_evicted_filter = MagicMock()
543
+ mock_evicted_filter.get_all_entries.return_value = []
544
+ mock_contract.events.ServicesEvicted.create_filter.return_value = (
545
+ mock_evicted_filter
546
+ )
547
+
548
+ result = staking.get_checkpoint_events(from_block=900)
549
+
550
+ assert result["epoch"] is None
551
+ assert result["checkpoint_block"] is None
552
+ assert result["rewarded_services"] == {}
553
+ assert result["inactivity_warnings"] == []
554
+ assert result["evicted_services"] == []
555
+
556
+
557
+ def test_get_checkpoint_events_handles_exceptions():
558
+ """Test get_checkpoint_events handles exceptions gracefully."""
559
+ with patch("builtins.open", side_effect=side_effect_open):
560
+ with patch("iwa.core.contracts.contract.ChainInterfaces") as mock_interfaces:
561
+ mock_chain = MagicMock()
562
+ mock_interfaces.return_value.get.return_value = mock_chain
563
+
564
+ mock_web3 = MagicMock()
565
+ mock_chain.web3 = mock_web3
566
+ mock_chain.web3._web3 = mock_web3
567
+ mock_web3.eth.block_number = 1000
568
+
569
+ mock_contract = MagicMock()
570
+ mock_web3.eth.contract.return_value = mock_contract
571
+
572
+ mock_contract.functions.agentMech.return_value.call.return_value = VALID_ADDR_2
573
+ mock_contract.functions.livenessRatio.return_value.call.return_value = 10**18
574
+
575
+ with patch(
576
+ "iwa.plugins.olas.contracts.staking.ContractInstance.call"
577
+ ) as mock_call_base:
578
+
579
+ def init_side_effect(method, *args):
580
+ if method == "activityChecker":
581
+ return VALID_ADDR_4
582
+ if method == "stakingToken":
583
+ return VALID_ADDR_2
584
+ return 0
585
+
586
+ mock_call_base.side_effect = init_side_effect
587
+
588
+ staking = StakingContract(VALID_ADDR_1)
589
+
590
+ # Checkpoint filter raises an exception
591
+ mock_contract.events.Checkpoint.create_filter.side_effect = Exception(
592
+ "RPC error"
593
+ )
594
+
595
+ result = staking.get_checkpoint_events(from_block=900)
596
+
597
+ # Should return default empty result
598
+ assert result["epoch"] is None
599
+ assert result["rewarded_services"] == {}
600
+ assert result["inactivity_warnings"] == []
601
+ assert result["evicted_services"] == []
602
+
603
+
604
+ def test_fetch_events_chunked_splits_large_range():
605
+ """Test that _fetch_events_chunked splits large block ranges into chunks."""
606
+ with patch("builtins.open", side_effect=side_effect_open):
607
+ with patch("iwa.core.contracts.contract.ChainInterfaces") as mock_interfaces:
608
+ mock_chain = MagicMock()
609
+ mock_interfaces.return_value.get.return_value = mock_chain
610
+
611
+ mock_web3 = MagicMock()
612
+ mock_chain.web3 = mock_web3
613
+ mock_chain.web3._web3 = mock_web3
614
+ mock_web3.eth.block_number = 2000
615
+
616
+ mock_contract = MagicMock()
617
+ mock_web3.eth.contract.return_value = mock_contract
618
+
619
+ mock_contract.functions.agentMech.return_value.call.return_value = VALID_ADDR_2
620
+ mock_contract.functions.livenessRatio.return_value.call.return_value = 10**18
621
+
622
+ with patch(
623
+ "iwa.plugins.olas.contracts.staking.ContractInstance.call"
624
+ ) as mock_call_base:
625
+
626
+ def init_side_effect(method, *args):
627
+ if method == "activityChecker":
628
+ return VALID_ADDR_4
629
+ if method == "stakingToken":
630
+ return VALID_ADDR_2
631
+ return 0
632
+
633
+ mock_call_base.side_effect = init_side_effect
634
+
635
+ staking = StakingContract(VALID_ADDR_1)
636
+
637
+ # Track calls to create_filter
638
+ call_ranges = []
639
+
640
+ def track_filter_calls(from_block, to_block):
641
+ call_ranges.append((from_block, to_block))
642
+ mock_filter = MagicMock()
643
+ mock_filter.get_all_entries.return_value = []
644
+ return mock_filter
645
+
646
+ mock_contract.events.Checkpoint.create_filter.side_effect = (
647
+ track_filter_calls
648
+ )
649
+
650
+ # Fetch 1200 blocks with chunk_size=500 -> should make 3 calls
651
+ staking._fetch_events_chunked("Checkpoint", 0, 1199, chunk_size=500)
652
+
653
+ assert len(call_ranges) == 3
654
+ assert call_ranges[0] == (0, 499)
655
+ assert call_ranges[1] == (500, 999)
656
+ assert call_ranges[2] == (1000, 1199)
657
+
658
+
659
+ def test_fetch_events_chunked_retries_with_smaller_chunks():
660
+ """Test that _fetch_events_chunked retries with smaller chunks on range error."""
661
+ with patch("builtins.open", side_effect=side_effect_open):
662
+ with patch("iwa.core.contracts.contract.ChainInterfaces") as mock_interfaces:
663
+ mock_chain = MagicMock()
664
+ mock_interfaces.return_value.get.return_value = mock_chain
665
+
666
+ mock_web3 = MagicMock()
667
+ mock_chain.web3 = mock_web3
668
+ mock_chain.web3._web3 = mock_web3
669
+ mock_web3.eth.block_number = 1000
670
+
671
+ mock_contract = MagicMock()
672
+ mock_web3.eth.contract.return_value = mock_contract
673
+
674
+ mock_contract.functions.agentMech.return_value.call.return_value = VALID_ADDR_2
675
+ mock_contract.functions.livenessRatio.return_value.call.return_value = 10**18
676
+
677
+ with patch(
678
+ "iwa.plugins.olas.contracts.staking.ContractInstance.call"
679
+ ) as mock_call_base:
680
+
681
+ def init_side_effect(method, *args):
682
+ if method == "activityChecker":
683
+ return VALID_ADDR_4
684
+ if method == "stakingToken":
685
+ return VALID_ADDR_2
686
+ return 0
687
+
688
+ mock_call_base.side_effect = init_side_effect
689
+
690
+ staking = StakingContract(VALID_ADDR_1)
691
+
692
+ call_count = [0]
693
+
694
+ def fail_then_succeed(from_block, to_block):
695
+ call_count[0] += 1
696
+ # First call fails with range error, subsequent calls succeed
697
+ if call_count[0] == 1:
698
+ raise Exception("block range too large")
699
+ mock_filter = MagicMock()
700
+ mock_filter.get_all_entries.return_value = []
701
+ return mock_filter
702
+
703
+ mock_contract.events.Checkpoint.create_filter.side_effect = (
704
+ fail_then_succeed
705
+ )
706
+
707
+ # Should retry with smaller chunks
708
+ result = staking._fetch_events_chunked(
709
+ "Checkpoint", 0, 499, chunk_size=500
710
+ )
711
+
712
+ # First call fails, then retries with chunk_size=250 (2 calls)
713
+ assert call_count[0] >= 2
714
+ assert result == []
715
+
716
+
717
+ def test_fetch_events_chunked_aggregates_results():
718
+ """Test that _fetch_events_chunked aggregates events from all chunks."""
719
+ with patch("builtins.open", side_effect=side_effect_open):
720
+ with patch("iwa.core.contracts.contract.ChainInterfaces") as mock_interfaces:
721
+ mock_chain = MagicMock()
722
+ mock_interfaces.return_value.get.return_value = mock_chain
723
+
724
+ mock_web3 = MagicMock()
725
+ mock_chain.web3 = mock_web3
726
+ mock_chain.web3._web3 = mock_web3
727
+ mock_web3.eth.block_number = 1000
728
+
729
+ mock_contract = MagicMock()
730
+ mock_web3.eth.contract.return_value = mock_contract
731
+
732
+ mock_contract.functions.agentMech.return_value.call.return_value = VALID_ADDR_2
733
+ mock_contract.functions.livenessRatio.return_value.call.return_value = 10**18
734
+
735
+ with patch(
736
+ "iwa.plugins.olas.contracts.staking.ContractInstance.call"
737
+ ) as mock_call_base:
738
+
739
+ def init_side_effect(method, *args):
740
+ if method == "activityChecker":
741
+ return VALID_ADDR_4
742
+ if method == "stakingToken":
743
+ return VALID_ADDR_2
744
+ return 0
745
+
746
+ mock_call_base.side_effect = init_side_effect
747
+
748
+ staking = StakingContract(VALID_ADDR_1)
749
+
750
+ chunk_num = [0]
751
+
752
+ def return_different_events(from_block, to_block):
753
+ chunk_num[0] += 1
754
+ mock_filter = MagicMock()
755
+ # Each chunk returns a different event
756
+ event = MagicMock()
757
+ event.args = {"serviceId": 100 + chunk_num[0]}
758
+ mock_filter.get_all_entries.return_value = [event]
759
+ return mock_filter
760
+
761
+ mock_contract.events.ServiceInactivityWarning.create_filter.side_effect = (
762
+ return_different_events
763
+ )
764
+
765
+ # Fetch 600 blocks with chunk_size=200 -> 3 chunks
766
+ result = staking._fetch_events_chunked(
767
+ "ServiceInactivityWarning", 0, 599, chunk_size=200
768
+ )
769
+
770
+ # Should have 3 events aggregated
771
+ assert len(result) == 3
772
+ service_ids = [e.args["serviceId"] for e in result]
773
+ assert service_ids == [101, 102, 103]
iwa/web/dependencies.py CHANGED
@@ -9,8 +9,42 @@ from loguru import logger
9
9
 
10
10
  from iwa.core.wallet import Wallet
11
11
 
12
- # Singleton wallet instance for the web app
13
- wallet = Wallet()
12
+ # Singleton wallet instance (lazy-initialized or injected)
13
+ _wallet: Optional[Wallet] = None
14
+
15
+
16
+ def get_wallet() -> Wallet:
17
+ """Get the wallet instance (lazy initialization or injected).
18
+
19
+ Returns the injected wallet if set_wallet() was called,
20
+ otherwise creates a new Wallet on first access.
21
+ """
22
+ global _wallet
23
+ if _wallet is None:
24
+ _wallet = Wallet()
25
+ return _wallet
26
+
27
+
28
+ def set_wallet(wallet: Wallet) -> None:
29
+ """Inject an external wallet instance.
30
+
31
+ Call this BEFORE importing routers to share a wallet
32
+ with an external application (e.g., Triton).
33
+ """
34
+ global _wallet
35
+ _wallet = wallet
36
+
37
+
38
+ # Backwards compatibility: module-level wallet property
39
+ # Deprecated: use get_wallet() instead
40
+ class _WalletProxy:
41
+ """Proxy that redirects to get_wallet() for backwards compatibility."""
42
+
43
+ def __getattr__(self, name: str):
44
+ return getattr(get_wallet(), name)
45
+
46
+
47
+ wallet = _WalletProxy()
14
48
 
15
49
  # Authentication
16
50
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
iwa/web/server.py CHANGED
@@ -147,11 +147,28 @@ async def root():
147
147
 
148
148
 
149
149
  def run_server(host: str = "127.0.0.1", port: int = 8000):
150
- """Run the web server using uvicorn."""
150
+ """Run the web server using uvicorn (blocking)."""
151
151
  import uvicorn
152
152
 
153
153
  uvicorn.run(app, host=host, port=port)
154
154
 
155
155
 
156
+ async def run_server_async(host: str = "127.0.0.1", port: int = 8000):
157
+ """Run the web server in an async context (non-blocking).
158
+
159
+ Use this to embed the web server in another async application.
160
+ The server runs until cancelled.
161
+
162
+ Args:
163
+ host: Bind address. Default "127.0.0.1" (localhost only for security).
164
+ port: Port number. Default 8000.
165
+ """
166
+ import uvicorn
167
+
168
+ config = uvicorn.Config(app, host=host, port=port, log_level="warning")
169
+ server = uvicorn.Server(config)
170
+ await server.serve()
171
+
172
+
156
173
  if __name__ == "__main__":
157
174
  run_server()
@@ -0,0 +1,204 @@
1
+ """Tests for wallet injection in web dependencies."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+
6
+ class TestWalletInjection:
7
+ """Tests for get_wallet, set_wallet, and _WalletProxy."""
8
+
9
+ def setup_method(self):
10
+ """Reset the global wallet state before each test."""
11
+ # Import fresh to reset state
12
+ import iwa.web.dependencies as deps
13
+
14
+ deps._wallet = None
15
+
16
+ def test_get_wallet_lazy_initialization(self):
17
+ """get_wallet() creates a Wallet on first call if none injected."""
18
+ import iwa.web.dependencies as deps
19
+
20
+ deps._wallet = None
21
+
22
+ with patch.object(deps, "Wallet") as mock_wallet_cls:
23
+ mock_wallet_cls.return_value = MagicMock(name="LazyWallet")
24
+
25
+ result = deps.get_wallet()
26
+
27
+ mock_wallet_cls.assert_called_once()
28
+ assert result == mock_wallet_cls.return_value
29
+
30
+ def test_get_wallet_returns_same_instance(self):
31
+ """get_wallet() returns the same instance on subsequent calls."""
32
+ import iwa.web.dependencies as deps
33
+
34
+ deps._wallet = None
35
+
36
+ with patch.object(deps, "Wallet") as mock_wallet_cls:
37
+ mock_wallet_cls.return_value = MagicMock(name="SingletonWallet")
38
+
39
+ first = deps.get_wallet()
40
+ second = deps.get_wallet()
41
+
42
+ # Should only create once
43
+ mock_wallet_cls.assert_called_once()
44
+ assert first is second
45
+
46
+ def test_set_wallet_injects_external_wallet(self):
47
+ """set_wallet() allows injecting an external wallet instance."""
48
+ import iwa.web.dependencies as deps
49
+
50
+ deps._wallet = None
51
+
52
+ external_wallet = MagicMock(name="ExternalWallet")
53
+ deps.set_wallet(external_wallet)
54
+
55
+ result = deps.get_wallet()
56
+
57
+ assert result is external_wallet
58
+
59
+ def test_set_wallet_prevents_lazy_init(self):
60
+ """set_wallet() prevents Wallet() from being called."""
61
+ import iwa.web.dependencies as deps
62
+
63
+ deps._wallet = None
64
+
65
+ with patch.object(deps, "Wallet") as mock_wallet_cls:
66
+ external_wallet = MagicMock(name="InjectedWallet")
67
+ deps.set_wallet(external_wallet)
68
+
69
+ result = deps.get_wallet()
70
+
71
+ # Wallet() should NOT be called
72
+ mock_wallet_cls.assert_not_called()
73
+ assert result is external_wallet
74
+
75
+ def test_set_wallet_overrides_existing(self):
76
+ """set_wallet() can override a previously set wallet."""
77
+ import iwa.web.dependencies as deps
78
+
79
+ deps._wallet = None
80
+
81
+ wallet1 = MagicMock(name="Wallet1")
82
+ wallet2 = MagicMock(name="Wallet2")
83
+
84
+ deps.set_wallet(wallet1)
85
+ assert deps.get_wallet() is wallet1
86
+
87
+ deps.set_wallet(wallet2)
88
+ assert deps.get_wallet() is wallet2
89
+
90
+
91
+ class TestWalletProxy:
92
+ """Tests for _WalletProxy backwards compatibility."""
93
+
94
+ def setup_method(self):
95
+ """Reset the global wallet state before each test."""
96
+ import iwa.web.dependencies as deps
97
+
98
+ deps._wallet = None
99
+
100
+ def test_wallet_proxy_forwards_attribute_access(self):
101
+ """wallet.attribute forwards to get_wallet().attribute."""
102
+ import iwa.web.dependencies as deps
103
+
104
+ deps._wallet = None
105
+
106
+ mock_wallet = MagicMock()
107
+ mock_wallet.balance_service = MagicMock(name="BalanceService")
108
+ deps.set_wallet(mock_wallet)
109
+
110
+ # Access through the proxy
111
+ result = deps.wallet.balance_service
112
+
113
+ assert result is mock_wallet.balance_service
114
+
115
+ def test_wallet_proxy_forwards_method_calls(self):
116
+ """wallet.method() forwards to get_wallet().method()."""
117
+ import iwa.web.dependencies as deps
118
+
119
+ deps._wallet = None
120
+
121
+ mock_wallet = MagicMock()
122
+ mock_wallet.get_accounts_balances.return_value = ({"0x1": {}}, {"0x1": {}})
123
+ deps.set_wallet(mock_wallet)
124
+
125
+ # Call method through proxy
126
+ result = deps.wallet.get_accounts_balances("gnosis", ["native"])
127
+
128
+ mock_wallet.get_accounts_balances.assert_called_once_with("gnosis", ["native"])
129
+ assert result == ({"0x1": {}}, {"0x1": {}})
130
+
131
+ def test_wallet_proxy_triggers_lazy_init(self):
132
+ """Accessing wallet.attr when no wallet set triggers lazy init."""
133
+ import iwa.web.dependencies as deps
134
+
135
+ deps._wallet = None
136
+
137
+ with patch.object(deps, "Wallet") as mock_wallet_cls:
138
+ mock_instance = MagicMock()
139
+ mock_instance.key_storage = MagicMock(name="KeyStorage")
140
+ mock_wallet_cls.return_value = mock_instance
141
+
142
+ # Access through proxy should trigger lazy init
143
+ result = deps.wallet.key_storage
144
+
145
+ mock_wallet_cls.assert_called_once()
146
+ assert result is mock_instance.key_storage
147
+
148
+
149
+ class TestIntegrationWithRouters:
150
+ """Integration tests for wallet injection with routers."""
151
+
152
+ def setup_method(self):
153
+ """Reset the global wallet state before each test."""
154
+ import iwa.web.dependencies as deps
155
+
156
+ deps._wallet = None
157
+
158
+ def test_injected_wallet_used_by_routers(self):
159
+ """Routers using 'wallet' get the injected instance."""
160
+ import iwa.web.dependencies as deps
161
+
162
+ # Inject a mock wallet BEFORE routers access it
163
+ mock_wallet = MagicMock()
164
+ mock_wallet.key_storage = MagicMock()
165
+ mock_wallet.key_storage.accounts = {}
166
+ deps.set_wallet(mock_wallet)
167
+
168
+ # Simulate what a router does
169
+ from iwa.web.dependencies import wallet
170
+
171
+ # Access through the module-level wallet (proxy)
172
+ accounts = wallet.key_storage.accounts
173
+
174
+ assert accounts == {}
175
+ # Verify it's using our injected wallet
176
+ assert deps.get_wallet() is mock_wallet
177
+
178
+
179
+ class TestServerAsync:
180
+ """Tests for run_server_async function."""
181
+
182
+ def test_run_server_async_source_has_localhost_default(self):
183
+ """run_server_async defaults to localhost for security (source check)."""
184
+ import pathlib
185
+
186
+ # Read the source file directly to verify default
187
+ server_path = pathlib.Path(__file__).parent.parent / "server.py"
188
+ source = server_path.read_text()
189
+
190
+ # Verify the function signature has localhost default
191
+ assert 'async def run_server_async(host: str = "127.0.0.1"' in source
192
+
193
+ def test_run_server_source_has_async_function(self):
194
+ """run_server_async function exists in server.py source."""
195
+ import pathlib
196
+
197
+ server_path = pathlib.Path(__file__).parent.parent / "server.py"
198
+ source = server_path.read_text()
199
+
200
+ # Verify the async function exists
201
+ assert "async def run_server_async" in source
202
+ assert "uvicorn.Config" in source
203
+ assert "uvicorn.Server" in source
204
+ assert "await server.serve()" in source
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A secure, modular, and plugin-based framework for crypto agents and ops
5
5
  Requires-Python: <4.0,>=3.12
6
6
  Description-Content-Type: text/markdown
@@ -78,7 +78,7 @@ iwa/plugins/olas/contracts/mech.py,sha256=dXYtyORc-oiu9ga5PtTquOFkoakb6BLGKvlUst
78
78
  iwa/plugins/olas/contracts/mech_marketplace.py,sha256=hMADl5MQGvT2wLRKu4vHGe4RrAZVq8Y2M_EvXWWz528,1554
79
79
  iwa/plugins/olas/contracts/mech_marketplace_v1.py,sha256=ooF5uw1wxwYsoriGUGGxXxmaD8DtWZtK4TJBCUNTGtI,2501
80
80
  iwa/plugins/olas/contracts/service.py,sha256=BDQKeCTCnBNrwKD1a8rrlLytpKG3CAdjr-s0ec-dsFY,8243
81
- iwa/plugins/olas/contracts/staking.py,sha256=kKF4Uc2cFr9_drKG5OzFgzKTdgGyHIM_PUa-L0LX9gU,18486
81
+ iwa/plugins/olas/contracts/staking.py,sha256=Qkr6yS3an-yZe560Dwer_g1wyOL3e1VIAGaeSZLCgfs,23512
82
82
  iwa/plugins/olas/contracts/abis/activity_checker.json,sha256=HT0IMbyTLMO71ITBKwoS950rHe772suPP4b8eDAodJ0,2230
83
83
  iwa/plugins/olas/contracts/abis/mech.json,sha256=bMMCXInjE_2PTPnc_sIyS_H8pod5Sm_e-xTbKgZppKc,16369
84
84
  iwa/plugins/olas/contracts/abis/mech_marketplace.json,sha256=KPnF-H_UATb3wdb_7o6ky_hSp5xwgvckD-QqylsWJLg,32468
@@ -118,7 +118,7 @@ iwa/plugins/olas/tests/test_service_manager_mech.py,sha256=qG6qu5IPRNypXUsblU2OE
118
118
  iwa/plugins/olas/tests/test_service_manager_rewards.py,sha256=2YCrXBU5bEkPuhBoGBhjnO1nA2qwHxn5Ivrror18FHM,12248
119
119
  iwa/plugins/olas/tests/test_service_manager_validation.py,sha256=ajlfH5uc4mAHf8A7GLE5cW7X8utM2vUilM0JdGDdlVg,5382
120
120
  iwa/plugins/olas/tests/test_service_staking.py,sha256=exxWsile_wG_0rz_cGbCPG-_Ubq01Ofl4D_pi0plj5Y,18332
121
- iwa/plugins/olas/tests/test_staking_integration.py,sha256=QCBQf6P2ZmmsEGt2k8W2r53lG2aVRuoMJE-aFxVDLss,9701
121
+ iwa/plugins/olas/tests/test_staking_integration.py,sha256=q7zLQLrUyhtcnZf6MMymx2cX0Gmqaa7i1mRh1clnyj4,30198
122
122
  iwa/plugins/olas/tests/test_staking_validation.py,sha256=uug64jFcXYJ3Nw_lNa3O4fnhNr5wAWHHIrchSbR2MVE,4020
123
123
  iwa/plugins/olas/tui/__init__.py,sha256=5ZRsbC7J3z1xfkZRiwr4bLEklf78rNVjdswe2p7SlS8,28
124
124
  iwa/plugins/olas/tui/olas_view.py,sha256=dgZjfXCWsRRdHpygHfSOCJZFWZrgrVyieq-iYgDkK3w,37404
@@ -146,9 +146,9 @@ iwa/tui/tests/test_wallets_refactor.py,sha256=71G3HLbhTtgDy3ffVbYv0MFYRgdYd-NWGB
146
146
  iwa/tui/tests/test_widgets.py,sha256=C9UgIGeWRaQ459JygFEQx-7hOi9mWrSUDDIMZH1ge50,3994
147
147
  iwa/tui/widgets/__init__.py,sha256=UzD6nJbwv9hOtkWl9I7faXm1a-rcu4xFRxrf4KBwwY4,161
148
148
  iwa/tui/widgets/base.py,sha256=Z8FigMhsfD76PkFVERqMaotd-xwXfuFZm_8TmCMOsl4,3381
149
- iwa/web/dependencies.py,sha256=0_dAJlRh6gKrUDRPKUe92eshFsg572yx_H0lQgSqGDA,2103
149
+ iwa/web/dependencies.py,sha256=iTvdCSuETFLeQPtFydi21s1EKA_a80rbA57s-994fMQ,2980
150
150
  iwa/web/models.py,sha256=MSD9WPy_Nz_amWgoo2KSDTn4ZLv_AV0o0amuNtSf-68,3035
151
- iwa/web/server.py,sha256=4ZLVFEKoGs_NoCcXMeyYzDNdxUXazjwHQaX7CR1pwHE,5239
151
+ iwa/web/server.py,sha256=BcaLtmyPSsVehUtbWf4fbEGJ4E0bDlY_PzVq479HoZM,5786
152
152
  iwa/web/routers/accounts.py,sha256=VhCHrwzRWqZcSW-tTEqFWT5hFl-IYEWpqXeuN8xM3-4,3922
153
153
  iwa/web/routers/state.py,sha256=wsBAOIWZeMWjMwLiiWVhuEXHAceI0IIq6CPpmh7SbIc,2469
154
154
  iwa/web/routers/swap.py,sha256=8xycAytquR29ELxW3vx428W8s9bI_w_x2kpRhhJ0KXY,22630
@@ -162,11 +162,12 @@ iwa/web/routers/olas/staking.py,sha256=jktJ2C1Q9X4aC0tWJByN3sHpEXY0EIvr3rr4N0MtX
162
162
  iwa/web/static/app.js,sha256=VCm9zPJERb9pX6zbFQ_7D47UnztGdibrkZ1dmwhsvdc,114949
163
163
  iwa/web/static/index.html,sha256=Qg06MFK__2KxwWTr8hhjX_GwsoN53QsLCTypu4fBR-Q,28516
164
164
  iwa/web/static/style.css,sha256=7i6T96pS7gXSLDZfyp_87gRlyB9rpsFWJEHJ-dRY1ug,24371
165
+ iwa/web/tests/test_wallet_injection.py,sha256=YjoQh1FMwroswF6O_XZOwYXzanv-JYElNTDNKxAS77g,6751
165
166
  iwa/web/tests/test_web_endpoints.py,sha256=vA25YghHNB23sbmhD4ciesn_f_okSq0tjlkrSiKZ0rs,24007
166
167
  iwa/web/tests/test_web_olas.py,sha256=GunKEAzcbzL7FoUGMtEl8wqiqwYwA5lB9sOhfCNj0TA,16312
167
168
  iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
168
169
  iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
169
- iwa-0.1.2.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
170
+ iwa-0.1.4.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
170
171
  tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
171
172
  tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
172
173
  tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
@@ -224,8 +225,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
224
225
  tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
225
226
  tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
226
227
  tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
227
- iwa-0.1.2.dist-info/METADATA,sha256=5YMpXIjANu_J3sb6-iP2cCGT0cFTMTVKg6qRw9RMVqg,7336
228
- iwa-0.1.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
229
- iwa-0.1.2.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
230
- iwa-0.1.2.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
231
- iwa-0.1.2.dist-info/RECORD,,
228
+ iwa-0.1.4.dist-info/METADATA,sha256=a0L36sHev8jqTBgB7MfGHSz46nAvG9J3OrkHNE1FpvM,7336
229
+ iwa-0.1.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
230
+ iwa-0.1.4.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
231
+ iwa-0.1.4.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
232
+ iwa-0.1.4.dist-info/RECORD,,
File without changes