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.
- {nirium-0.9.2 → nirium-0.11.0}/PKG-INFO +38 -2
- {nirium-0.9.2 → nirium-0.11.0}/README.md +31 -1
- {nirium-0.9.2 → nirium-0.11.0}/pyproject.toml +20 -1
- {nirium-0.9.2 → nirium-0.11.0}/setup.py +9 -1
- nirium-0.11.0/src/nirium/__init__.py +28 -0
- {nirium-0.9.2 → nirium-0.11.0}/src/nirium/client.py +251 -19
- nirium-0.11.0/src/nirium/langchain_tools.py +279 -0
- {nirium-0.9.2 → nirium-0.11.0}/src/nirium.egg-info/PKG-INFO +38 -2
- {nirium-0.9.2 → nirium-0.11.0}/src/nirium.egg-info/SOURCES.txt +4 -1
- nirium-0.11.0/src/nirium.egg-info/requires.txt +11 -0
- nirium-0.11.0/tests/test_langchain_x402.py +201 -0
- nirium-0.11.0/tests/test_websocket.py +304 -0
- nirium-0.9.2/src/nirium/__init__.py +0 -6
- nirium-0.9.2/src/nirium.egg-info/requires.txt +0 -3
- {nirium-0.9.2 → nirium-0.11.0}/setup.cfg +0 -0
- {nirium-0.9.2 → nirium-0.11.0}/src/nirium.egg-info/dependency_links.txt +0 -0
- {nirium-0.9.2 → nirium-0.11.0}/src/nirium.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nirium
|
|
3
|
-
Version: 0.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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.
|
|
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]] = {
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
690
|
+
an expiration ledger.
|
|
640
691
|
3. The transaction must be re-simulated AFTER signing: the signature
|
|
641
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
838
|
+
rejected = await paid.text()
|
|
739
839
|
raise RuntimeError(
|
|
740
|
-
f"x402 payment rejected (HTTP {paid.status}): {
|
|
840
|
+
f"x402 payment rejected (HTTP {paid.status}): {rejected[:300]}"
|
|
741
841
|
)
|
|
742
|
-
|
|
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
|
|
822
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
|
|
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
|