nirium 0.9.2__tar.gz → 0.11.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nirium
3
- Version: 0.9.2
3
+ Version: 0.11.0
4
4
  Summary: Autonomous treasury and agentic-payments infrastructure for Stellar (x402 + MPP) — Python client
5
5
  Author: Nirium Team
6
6
  Author-email: Nirium Protocol <niriumprotocol@gmail.com>
@@ -15,6 +15,12 @@ Description-Content-Type: text/markdown
15
15
  Requires-Dist: aiohttp>=3.9.0
16
16
  Requires-Dist: websockets>=13.0
17
17
  Requires-Dist: stellar-sdk<16.0.0,>=11.0.0
18
+ Provides-Extra: langchain
19
+ Requires-Dist: langchain-core>=0.3.0; extra == "langchain"
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=8.0.0; extra == "test"
22
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "test"
23
+ Requires-Dist: langchain-core>=0.3.0; extra == "test"
18
24
  Dynamic: author
19
25
  Dynamic: requires-python
20
26
 
@@ -50,7 +56,9 @@ async def main():
50
56
  market = await agent.get_market()
51
57
  print(f"XLM Price: ${market['xlmPrice']:.4f}")
52
58
 
53
- # Execute a treasury rebalance strategy
59
+ # Trigger a demo strategy on Nirium's own shared testnet vault — a real,
60
+ # working transaction, not a simulation, but it moves Nirium's testnet
61
+ # funds, not yours. To rebalance YOUR OWN vault, see Treasury Rebalance below.
54
62
  result = await agent.execute("blend-yield", "USDC", {"amount": 5000})
55
63
  print(f"Success: {result['success']} | TX: {result.get('txHash')}")
56
64
 
@@ -130,6 +138,33 @@ Licensed for **independent service payments only** — contractors, freelancers,
130
138
 
131
139
  Mainnet is invite-only during early access and additionally requires `client_info`.
132
140
 
141
+ ## Treasury Rebalance
142
+
143
+ Two ways to rebalance a DeFindex vault between idle cash and an invested strategy. Neither is a swap — the contract's `rebalance()` exposes exactly two instructions, `Unwind` and `Invest`, and neither accepts a destination address, so withdrawing anywhere but back into the vault itself is not expressible.
144
+
145
+ ### Propose — you review and sign, available to everyone today
146
+
147
+ The agent decides what it would do, using the same decision logic as the autonomous signer below, but it never signs. Public, no allowlist, no invite required — works for any vault where you're already the on-chain rebalance manager.
148
+
149
+ ```python
150
+ proposal = await agent.propose_treasury_rebalance(
151
+ vault="CABC...",
152
+ caller="GABC...", # must already be this vault's rebalanceManager on-chain
153
+ enter_at=2.5, # your own mandate — Nirium never supplies a default here
154
+ exit_at=2.0,
155
+ )
156
+
157
+ if proposal["instructions"]:
158
+ signed_xdr = sign_with_your_wallet(proposal["xdr"])
159
+ await agent.submit_treasury_tx(signed_xdr)
160
+ else:
161
+ print("Nothing to propose:", proposal["reason"])
162
+ ```
163
+
164
+ ### Autonomous — Nirium signs, invite-only during legal review
165
+
166
+ `execute_treasury_rebalance()` has Nirium sign and submit with its own RebalanceManager key — full autonomy, no per-cycle approval. **This is invite-only while a specific legal question stays open**: whether executing on a client's behalf without taking custody still counts as regulated facilitation under Mexican law. It only runs against vaults explicitly allowlisted server-side; calling it against any other vault returns 403, and Nirium's mainnet infrastructure returns 501 for it entirely, since that box holds no signing key by design. Ask if you want autonomous execution today — otherwise, `propose_treasury_rebalance()` above gives you the same decision-making with you as the one who signs.
167
+
133
168
  ## Audit Trail
134
169
 
135
170
  Anchor evidence to IPFS and get back a CID — an integrity seal, not notarization.
@@ -159,6 +194,7 @@ Anchor a **hash** rather than the data itself: IPFS content cannot be deleted, s
159
194
  | Revenue | `get_revenue()`, `get_info()` |
160
195
  | Nodes | `get_nodes()` |
161
196
  | Payouts | `create_payout_run()`, `submit_payout()`, `onboard_payout_recipient()`, `submit_payout_onboard()`, `get_payout_runs()`, `get_payout_terms()`, `get_payout_info()` |
197
+ | Treasury | `get_treasury_info()`, `get_treasury_vault()`, `get_treasury_vaults()`, `get_treasury_strategy_asset()`, `deploy_treasury_vault()`, `deposit_to_treasury_vault()`, `withdraw_from_treasury_vault()`, `set_treasury_rebalance_manager()`, `build_treasury_rebalance()`, `propose_treasury_rebalance()`, `execute_treasury_rebalance()`, `submit_treasury_tx()` |
162
198
  | Audit Trail | `anchor_audit_record()`, `get_audit_info()` |
163
199
  | Reporting | `get_reporting_summary()`, `get_reporting_export()` |
164
200
  | Admin | `configure_llm()` |
@@ -30,7 +30,9 @@ async def main():
30
30
  market = await agent.get_market()
31
31
  print(f"XLM Price: ${market['xlmPrice']:.4f}")
32
32
 
33
- # Execute a treasury rebalance strategy
33
+ # Trigger a demo strategy on Nirium's own shared testnet vault — a real,
34
+ # working transaction, not a simulation, but it moves Nirium's testnet
35
+ # funds, not yours. To rebalance YOUR OWN vault, see Treasury Rebalance below.
34
36
  result = await agent.execute("blend-yield", "USDC", {"amount": 5000})
35
37
  print(f"Success: {result['success']} | TX: {result.get('txHash')}")
36
38
 
@@ -110,6 +112,33 @@ Licensed for **independent service payments only** — contractors, freelancers,
110
112
 
111
113
  Mainnet is invite-only during early access and additionally requires `client_info`.
112
114
 
115
+ ## Treasury Rebalance
116
+
117
+ Two ways to rebalance a DeFindex vault between idle cash and an invested strategy. Neither is a swap — the contract's `rebalance()` exposes exactly two instructions, `Unwind` and `Invest`, and neither accepts a destination address, so withdrawing anywhere but back into the vault itself is not expressible.
118
+
119
+ ### Propose — you review and sign, available to everyone today
120
+
121
+ The agent decides what it would do, using the same decision logic as the autonomous signer below, but it never signs. Public, no allowlist, no invite required — works for any vault where you're already the on-chain rebalance manager.
122
+
123
+ ```python
124
+ proposal = await agent.propose_treasury_rebalance(
125
+ vault="CABC...",
126
+ caller="GABC...", # must already be this vault's rebalanceManager on-chain
127
+ enter_at=2.5, # your own mandate — Nirium never supplies a default here
128
+ exit_at=2.0,
129
+ )
130
+
131
+ if proposal["instructions"]:
132
+ signed_xdr = sign_with_your_wallet(proposal["xdr"])
133
+ await agent.submit_treasury_tx(signed_xdr)
134
+ else:
135
+ print("Nothing to propose:", proposal["reason"])
136
+ ```
137
+
138
+ ### Autonomous — Nirium signs, invite-only during legal review
139
+
140
+ `execute_treasury_rebalance()` has Nirium sign and submit with its own RebalanceManager key — full autonomy, no per-cycle approval. **This is invite-only while a specific legal question stays open**: whether executing on a client's behalf without taking custody still counts as regulated facilitation under Mexican law. It only runs against vaults explicitly allowlisted server-side; calling it against any other vault returns 403, and Nirium's mainnet infrastructure returns 501 for it entirely, since that box holds no signing key by design. Ask if you want autonomous execution today — otherwise, `propose_treasury_rebalance()` above gives you the same decision-making with you as the one who signs.
141
+
113
142
  ## Audit Trail
114
143
 
115
144
  Anchor evidence to IPFS and get back a CID — an integrity seal, not notarization.
@@ -139,6 +168,7 @@ Anchor a **hash** rather than the data itself: IPFS content cannot be deleted, s
139
168
  | Revenue | `get_revenue()`, `get_info()` |
140
169
  | Nodes | `get_nodes()` |
141
170
  | Payouts | `create_payout_run()`, `submit_payout()`, `onboard_payout_recipient()`, `submit_payout_onboard()`, `get_payout_runs()`, `get_payout_terms()`, `get_payout_info()` |
171
+ | Treasury | `get_treasury_info()`, `get_treasury_vault()`, `get_treasury_vaults()`, `get_treasury_strategy_asset()`, `deploy_treasury_vault()`, `deposit_to_treasury_vault()`, `withdraw_from_treasury_vault()`, `set_treasury_rebalance_manager()`, `build_treasury_rebalance()`, `propose_treasury_rebalance()`, `execute_treasury_rebalance()`, `submit_treasury_tx()` |
142
172
  | Audit Trail | `anchor_audit_record()`, `get_audit_info()` |
143
173
  | Reporting | `get_reporting_summary()`, `get_reporting_export()` |
144
174
  | Admin | `configure_llm()` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nirium"
7
- version = "0.9.2"
7
+ version = "0.11.0"
8
8
  description = "Autonomous treasury and agentic-payments infrastructure for Stellar (x402 + MPP) — Python client"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -17,8 +17,27 @@ dependencies = [
17
17
  "stellar-sdk>=11.0.0,<16.0.0",
18
18
  ]
19
19
 
20
+ [project.optional-dependencies]
21
+ langchain = ["langchain-core>=0.3.0"]
22
+ test = [
23
+ "pytest>=8.0.0",
24
+ "pytest-asyncio>=0.23.0",
25
+ "langchain-core>=0.3.0",
26
+ ]
27
+
20
28
  [project.urls]
21
29
  Homepage = "https://nirium.xyz"
22
30
  Repository = "https://github.com/nirium-protocol/nirium-sdk"
23
31
  Documentation = "https://nirium.xyz/docs"
24
32
  "Bug Tracker" = "https://github.com/nirium-protocol/nirium-sdk/issues"
33
+
34
+ [tool.setuptools]
35
+ package-dir = {"" = "src"}
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
42
+ testpaths = ["tests"]
43
+ pythonpath = ["src"]
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="nirium",
5
- version="0.9.2",
5
+ version="0.11.0",
6
6
  package_dir={"": "src"},
7
7
  packages=find_packages(where="src"),
8
8
  install_requires=[
@@ -10,6 +10,14 @@ setup(
10
10
  "aiohttp>=3.9.0",
11
11
  "stellar-sdk>=11.0.0,<16.0.0",
12
12
  ],
13
+ extras_require={
14
+ "langchain": ["langchain-core>=0.3.0"],
15
+ "test": [
16
+ "pytest>=8.0.0",
17
+ "pytest-asyncio>=0.23.0",
18
+ "langchain-core>=0.3.0",
19
+ ],
20
+ },
13
21
  author="Nirium Team",
14
22
  description="Autonomous treasury and agentic-payments infrastructure for Stellar (x402 + MPP) — Python client",
15
23
  keywords=["nirium", "stellar", "defi", "x402", "mpp", "agentic-payments", "soroban"],
@@ -0,0 +1,28 @@
1
+ """Nirium — autonomous treasury and agentic-payments infrastructure for Stellar (x402 + MPP), Python client."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .client import Agent, WebSocketMaxRetriesExceeded, WebSocketStatus # type: ignore
6
+
7
+ if TYPE_CHECKING:
8
+ from .langchain_tools import NiriumX402Tool, create_nirium_x402_tool
9
+
10
+ __version__ = "0.11.0"
11
+ __all__ = [
12
+ "Agent",
13
+ "WebSocketMaxRetriesExceeded",
14
+ "WebSocketStatus",
15
+ "NiriumX402Tool",
16
+ "create_nirium_x402_tool",
17
+ ]
18
+
19
+
20
+ def __getattr__(name: str):
21
+ if name in {"NiriumX402Tool", "create_nirium_x402_tool"}:
22
+ from .langchain_tools import NiriumX402Tool, create_nirium_x402_tool
23
+
24
+ return {
25
+ "NiriumX402Tool": NiriumX402Tool,
26
+ "create_nirium_x402_tool": create_nirium_x402_tool,
27
+ }[name]
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -5,8 +5,11 @@
5
5
  # Synced with backend API (real Horizon data, Soroban execution)
6
6
  # ═══════════════════════════════════════════════════════════════
7
7
  import asyncio
8
+ import collections
9
+ import hashlib
8
10
  import json
9
11
  import logging
12
+ import random
10
13
  import aiohttp # type: ignore
11
14
  import websockets # type: ignore
12
15
  from typing import Callable, Dict, Any, List, Optional
@@ -24,6 +27,19 @@ _X402_NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
24
27
  logger = logging.getLogger("nirium.client")
25
28
 
26
29
 
30
+ class WebSocketMaxRetriesExceeded(Exception):
31
+ """Raised when consecutive WebSocket reconnection retries exceed the configured limit."""
32
+ pass
33
+
34
+
35
+ class WebSocketStatus:
36
+ CONNECTING = "connecting"
37
+ CONNECTED = "connected"
38
+ RECONNECTING = "reconnecting"
39
+ DISCONNECTED = "disconnected"
40
+ CLOSED = "closed"
41
+
42
+
27
43
  class Agent:
28
44
  """
29
45
  Nirium Agent — Full API + WebSocket client for the Nirium autonomous agent.
@@ -49,7 +65,18 @@ class Agent:
49
65
  elif token_local is not None:
50
66
  self.headers["Authorization"] = f"Bearer {token_local}"
51
67
 
52
- self.callbacks: Dict[str, List[Callable]] = {"signal": [], "log": [], "connected": []}
68
+ self.callbacks: Dict[str, List[Callable]] = {
69
+ "signal": [],
70
+ "log": [],
71
+ "connected": [],
72
+ "status": [],
73
+ "error": [],
74
+ }
75
+ self._active_ws: Optional[Any] = None
76
+ self._ws_running: bool = False
77
+ self._ws_close_requested: bool = False
78
+ self._close_event: asyncio.Event = asyncio.Event()
79
+ self.last_x402_settlement: Optional[Dict[str, Any]] = None
53
80
 
54
81
  # ─── Decorators ────────────────────────────────────────────
55
82
 
@@ -542,6 +569,29 @@ class Agent:
542
569
  payload["caller"] = caller
543
570
  return await self._post("/api/treasury/rebalance", payload)
544
571
 
572
+ async def propose_treasury_rebalance(
573
+ self, vault: str, caller: str, enter_at: float, exit_at: float,
574
+ min_idle: Optional[Any] = None,
575
+ ) -> Dict[str, Any]:
576
+ """Ask the agent what it would propose for this vault — the same decision
577
+ logic the autonomous signer uses (rate vs. your own ``enter_at``/``exit_at``),
578
+ but this never signs. Returns an unsigned XDR for you to review and sign
579
+ yourself, or an empty ``instructions`` list with a ``reason`` if there's
580
+ nothing to do right now. Public: no allowlist, no invite — works for any
581
+ vault where ``caller`` is already the on-chain rebalanceManager. Unlike
582
+ ``execute_treasury_rebalance``, Nirium never executes on your behalf here,
583
+ so this doesn't wait on the same legal review the fully autonomous path does.
584
+
585
+ ``enter_at``/``exit_at`` are your mandate, not a Nirium default. ``min_idle``
586
+ is stroops, as a string or number — omit for no minimum (0).
587
+ """
588
+ payload: Dict[str, Any] = {
589
+ "vault": vault, "caller": caller, "enterAt": enter_at, "exitAt": exit_at,
590
+ }
591
+ if min_idle is not None:
592
+ payload["minIdle"] = str(min_idle)
593
+ return await self._post("/api/treasury/rebalance/propose", payload)
594
+
545
595
  async def execute_treasury_rebalance(
546
596
  self, vault: str, instructions: List[Dict[str, Any]], caller: Optional[str] = None,
547
597
  ) -> Dict[str, Any]:
@@ -559,6 +609,7 @@ class Agent:
559
609
  """Broadcast an XDR you already signed (deploy/deposit/withdraw/rebalance/set-rebalance-manager) and wait for confirmation."""
560
610
  return await self._post("/api/treasury/submit", {"xdr": xdr})
561
611
 
612
+
562
613
  # ─── Admin ───────────────────────────────────────────────
563
614
 
564
615
  async def configure_llm(
@@ -632,13 +683,13 @@ class Agent:
632
683
  it settle, each verified against a TypeScript-generated reference:
633
684
 
634
685
  1. The source account is the NULL account, not the payer. The
635
- facilitator replaces it with its own when it submits and pays the
636
- fee. Using the payer's account makes the simulator emit
637
- source-account credentials, which the facilitator cannot honour.
686
+ facilitator replaces it with its own when it submits and pays the
687
+ fee. Using the payer's account makes the simulator emit
688
+ source-account credentials, which the facilitator cannot honour.
638
689
  2. The auth entry must therefore carry ADDRESS credentials, signed with
639
- an expiration ledger.
690
+ an expiration ledger.
640
691
  3. The transaction must be re-simulated AFTER signing: the signature
641
- adds bytes, and the resource fee computed before it is too low.
692
+ adds bytes, and the resource fee computed before it is too low.
642
693
  """
643
694
  extra = accepted.get("extra") or {}
644
695
  if not extra.get("areFeesSponsored"):
@@ -685,7 +736,51 @@ class Agent:
685
736
  prepared.transaction.fee = 100 + int(sim.min_resource_fee)
686
737
  return prepared.to_xdr()
687
738
 
688
- async def x402_fetch(self, url: str, method: str = "GET") -> Dict[str, Any]:
739
+ def _x402_http_payload(self, body: Optional[Any]) -> Dict[str, Any]:
740
+ if body is None:
741
+ return {}
742
+ if isinstance(body, (dict, list)):
743
+ return {"json": body}
744
+ return {"data": body}
745
+
746
+ def _x402_store_settlement(self, headers: Any) -> None:
747
+ raw = (
748
+ headers.get("PAYMENT-RESPONSE")
749
+ or headers.get("payment-response")
750
+ or headers.get("X-PAYMENT-RESPONSE")
751
+ or headers.get("x-payment-response")
752
+ or headers.get("Payment-Receipt")
753
+ or headers.get("payment-receipt")
754
+ )
755
+ if not raw:
756
+ self.last_x402_settlement = None
757
+ return
758
+ try:
759
+ decoded = json.loads(_b64.b64decode(raw).decode("utf-8"))
760
+ except (ValueError, json.JSONDecodeError, TypeError):
761
+ try:
762
+ decoded = json.loads(raw)
763
+ except (ValueError, json.JSONDecodeError, TypeError):
764
+ self.last_x402_settlement = None
765
+ return
766
+ self.last_x402_settlement = decoded if isinstance(decoded, dict) else None
767
+
768
+ async def _x402_read_payload(self, resp: Any) -> Dict[str, Any]:
769
+ text = await resp.text()
770
+ if not text:
771
+ return {}
772
+ try:
773
+ parsed = json.loads(text)
774
+ except json.JSONDecodeError:
775
+ return {"body": text}
776
+ return parsed if isinstance(parsed, dict) else {"body": parsed}
777
+
778
+ async def x402_fetch(
779
+ self,
780
+ url: str,
781
+ method: str = "GET",
782
+ body: Optional[Any] = None,
783
+ ) -> Dict[str, Any]:
689
784
  """
690
785
  Fetch a paid resource via x402 (protocol v2).
691
786
 
@@ -693,15 +788,20 @@ class Agent:
693
788
  header, signs a Soroban auth entry for the exact amount, and retries
694
789
  with the ``PAYMENT-SIGNATURE`` header.
695
790
 
791
+ ``body`` is forwarded on both the challenge request and the paid retry
792
+ so POST/PUT callers do not need a second payment client.
793
+
696
794
  Returns the JSON payload from the paid resource.
697
795
  """
698
796
  if not hasattr(self, "_x402_keypair"):
699
797
  raise RuntimeError("x402 client not initialized. Call agent.init_x402() first.")
700
798
 
799
+ payload = self._x402_http_payload(body)
800
+ self.last_x402_settlement = None
701
801
  async with aiohttp.ClientSession() as session:
702
- async with session.request(method, url) as resp:
802
+ async with session.request(method, url, **payload) as resp:
703
803
  if resp.status != 402:
704
- return await resp.json()
804
+ return await self._x402_read_payload(resp)
705
805
  # v2 entrega los requirements en un header base64; el body va vacío.
706
806
  raw = resp.headers.get("payment-required")
707
807
 
@@ -732,14 +832,15 @@ class Agent:
732
832
  # v2 usa PAYMENT-SIGNATURE. X-PAYMENT es de v1: mandarlo hace que el
733
833
  # servidor ignore el pago por completo y reemita el 402 sin explicar.
734
834
  async with session.request(
735
- method, url, headers={"PAYMENT-SIGNATURE": header}
835
+ method, url, headers={"PAYMENT-SIGNATURE": header}, **payload
736
836
  ) as paid:
737
837
  if paid.status >= 400:
738
- body = await paid.text()
838
+ rejected = await paid.text()
739
839
  raise RuntimeError(
740
- f"x402 payment rejected (HTTP {paid.status}): {body[:300]}"
840
+ f"x402 payment rejected (HTTP {paid.status}): {rejected[:300]}"
741
841
  )
742
- return await paid.json()
842
+ self._x402_store_settlement(paid.headers)
843
+ return await self._x402_read_payload(paid)
743
844
 
744
845
  # ─── MPP Protocol (Charge Mode) ─────────────────────────
745
846
 
@@ -818,25 +919,156 @@ class Agent:
818
919
 
819
920
  # ─── WebSocket ───────────────────────────────────────────
820
921
 
821
- async def subscribe(self, callback: Optional[Callable] = None):
822
- """Start real-time WebSocket connection for signals."""
922
+ async def close(self):
923
+ """
924
+ Gracefully close active WebSocket connection and stop subscriptions.
925
+ """
926
+ self._ws_close_requested = True
927
+ self._ws_running = False
928
+ self._close_event.set()
929
+ if self._active_ws is not None:
930
+ try:
931
+ await self._active_ws.close()
932
+ except Exception:
933
+ pass
934
+ self._active_ws = None
935
+ await self._emit("status", WebSocketStatus.CLOSED)
936
+
937
+ async def subscribe(
938
+ self,
939
+ callback: Optional[Callable] = None,
940
+ max_retries: Optional[int] = None,
941
+ initial_delay: float = 1.0,
942
+ max_delay: float = 30.0,
943
+ backoff_factor: float = 2.0,
944
+ jitter: float = 0.2,
945
+ dedupe_size: int = 1000,
946
+ ):
947
+ """
948
+ Start real-time WebSocket connection for signals with resilient exponential backoff,
949
+ jitter, retry capping, deduplication, and typed event channels.
950
+
951
+ Args:
952
+ callback: Optional callback for "signal" events (preserves legacy API).
953
+ max_retries: Maximum consecutive reconnection attempts before raising WebSocketMaxRetriesExceeded.
954
+ initial_delay: Initial reconnect delay in seconds (default: 1.0).
955
+ max_delay: Maximum reconnect delay in seconds (default: 30.0).
956
+ backoff_factor: Multiplier for exponential backoff (default: 2.0).
957
+ jitter: Fractional random jitter factor applied to delay (default: 0.2, i.e. +/- 20%).
958
+ dedupe_size: Number of recent message IDs to track for deduplication.
959
+ """
823
960
  if callback:
824
961
  self.callbacks.setdefault("signal", []).append(callback)
825
962
 
826
963
  auth_query = f"?token={self.token}" if self.token else ""
827
964
  url = f"{self.ws_url}{auth_query}"
828
965
 
829
- while True:
966
+ self._ws_running = True
967
+ self._ws_close_requested = False
968
+ self._close_event.clear()
969
+ attempt = 0
970
+ seen_ids: collections.deque = collections.deque(maxlen=dedupe_size)
971
+ seen_set: set = set()
972
+
973
+ def _dedupe_check(msg_data: Dict[str, Any]) -> bool:
974
+ """Returns True if message is a new unseen signal, False if duplicate."""
975
+ msg_id = (
976
+ msg_data.get("id")
977
+ or msg_data.get("signal_id")
978
+ or msg_data.get("txHash")
979
+ )
980
+ if not msg_id:
981
+ raw_str = json.dumps(msg_data, sort_keys=True)
982
+ msg_id = hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
983
+
984
+ if msg_id in seen_set:
985
+ return False
986
+
987
+ if len(seen_ids) >= dedupe_size and seen_ids:
988
+ oldest = seen_ids.popleft()
989
+ seen_set.discard(oldest)
990
+
991
+ seen_ids.append(msg_id)
992
+ seen_set.add(msg_id)
993
+ return True
994
+
995
+ while self._ws_running and not self._ws_close_requested:
830
996
  try:
997
+ await self._emit(
998
+ "status",
999
+ WebSocketStatus.CONNECTING if attempt == 0 else WebSocketStatus.RECONNECTING,
1000
+ )
831
1001
  async with websockets.connect(url) as ws:
1002
+ self._active_ws = ws
832
1003
  logger.info("Connected to Nirium Signal Stream")
1004
+ await self._emit("status", WebSocketStatus.CONNECTED)
833
1005
  await self._emit("connected", None)
834
1006
 
835
1007
  async for message in ws:
1008
+ if self._ws_close_requested:
1009
+ break
1010
+ # Reset retry counter upon successful message exchange
1011
+ attempt = 0
836
1012
  data = json.loads(message)
837
- event = data.get("type")
1013
+ event = data.get("type", "signal")
1014
+
1015
+ # Deduplicate only signal messages
1016
+ if event == "signal":
1017
+ if not _dedupe_check(data):
1018
+ logger.debug(f"Dropped duplicate signal: {data.get('id')}")
1019
+ continue
1020
+
838
1021
  if event in self.callbacks:
839
1022
  await self._emit(event, data)
1023
+ if (
1024
+ event != "signal"
1025
+ and len(self.callbacks.get("signal", [])) > 0
1026
+ and event not in ("connected", "status", "error", "log")
1027
+ ):
1028
+ await self._emit("signal", data)
1029
+
1030
+ if not self._ws_close_requested:
1031
+ raise ConnectionResetError("WebSocket connection closed by remote server")
1032
+
840
1033
  except Exception as e:
841
- logger.error(f"WS Disconnected: {e}. Reconnecting in 5s...")
842
- await asyncio.sleep(5)
1034
+ self._active_ws = None
1035
+ if self._ws_close_requested:
1036
+ logger.info("WebSocket connection closed by user request.")
1037
+ await self._emit("status", WebSocketStatus.CLOSED)
1038
+ break
1039
+
1040
+ attempt += 1
1041
+ await self._emit("status", WebSocketStatus.DISCONNECTED)
1042
+ await self._emit(
1043
+ "error",
1044
+ {"error": str(e), "exception": e, "attempt": attempt},
1045
+ )
1046
+
1047
+ if max_retries is not None and attempt >= max_retries:
1048
+ logger.error(
1049
+ f"WS Reconnect failed after {attempt} attempts. Max retries exceeded."
1050
+ )
1051
+ await self._emit("status", WebSocketStatus.CLOSED)
1052
+ raise WebSocketMaxRetriesExceeded(
1053
+ f"WebSocket connection failed after {attempt} consecutive attempts: {e}"
1054
+ ) from e
1055
+
1056
+ # Calculate exponential backoff with jitter
1057
+ base_delay = min(
1058
+ max_delay, initial_delay * (backoff_factor ** (attempt - 1))
1059
+ )
1060
+ jitter_range = base_delay * jitter
1061
+ actual_delay = max(
1062
+ 0.05, base_delay + random.uniform(-jitter_range, jitter_range)
1063
+ )
1064
+
1065
+ logger.warning(
1066
+ f"WS Disconnected: {e}. Reconnecting (attempt {attempt}/{max_retries or 'inf'}) in {actual_delay:.2f}s..."
1067
+ )
1068
+ try:
1069
+ await asyncio.wait_for(self._close_event.wait(), timeout=actual_delay)
1070
+ # If close event was triggered during backoff sleep, terminate immediately
1071
+ break
1072
+ except asyncio.TimeoutError:
1073
+ # Backoff sleep elapsed normally, proceed to reconnect
1074
+ pass