cabalspy-x402 0.1.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.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .mypy_cache/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .env.*
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CabalSpy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: cabalspy-x402
3
+ Version: 0.1.0
4
+ Summary: x402 client for the CabalSpy KOL API — AI agents pay per request in USDC, no account or API key. Query KOL, smart money and whale wallets on Solana, Base, BNB Chain, Ethereum and Robinhood Chain, with built-in spend limits.
5
+ Project-URL: Homepage, https://docs.cabalspy.xyz
6
+ Project-URL: Documentation, https://docs.cabalspy.xyz
7
+ Project-URL: Repository, https://github.com/CabalSpy/cabalspy-x402-python
8
+ Project-URL: Issues, https://github.com/CabalSpy/cabalspy-x402-python/issues
9
+ Author-email: CabalSpy <contact@cabalspy.xyz>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent-payments,ai-agent,autonomous-agent,base,bnb,bundle-detection,cabalspy,ethereum,http-402,kol,kol-api,machine-payments,memecoin,micropayments,onchain-data,pay-per-call,robinhood-chain,sdk,smart-money,solana,trading-signals,usdc,wallet-tracker,whale-tracker,x402
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Financial and Insurance Industry
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business :: Financial
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: cabalspy>=0.2.0
26
+ Requires-Dist: httpx>=0.24
27
+ Requires-Dist: requests>=2.28
28
+ Requires-Dist: x402>=2.17
29
+ Provides-Extra: all
30
+ Requires-Dist: solana<0.40,>=0.38; extra == 'all'
31
+ Requires-Dist: x402[evm,svm]>=2.17; extra == 'all'
32
+ Provides-Extra: dev
33
+ Requires-Dist: mypy>=1.8; extra == 'dev'
34
+ Requires-Dist: pytest>=8.0; extra == 'dev'
35
+ Provides-Extra: evm
36
+ Requires-Dist: x402[evm]>=2.17; extra == 'evm'
37
+ Provides-Extra: solana
38
+ Requires-Dist: solana<0.40,>=0.38; extra == 'solana'
39
+ Requires-Dist: x402[svm]>=2.17; extra == 'solana'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # cabalspy-x402 — pay-per-call KOL data for AI agents
43
+
44
+ x402 client for the **CabalSpy KOL API**. An AI agent pays in USDC per request and gets labeled wallet data on **Solana, Base, BNB Chain, Ethereum and Robinhood Chain** — no account, no signup, no API key. The wallet is the credential.
45
+
46
+ ```bash
47
+ pip install "cabalspy-x402[all]"
48
+ ```
49
+
50
+ ## Quick start
51
+
52
+ ```python
53
+ from cabalspy_x402 import create_client
54
+
55
+ cabalspy = create_client(
56
+ svm_signer=signer, # Solana
57
+ max_spend_total="$5.00", # hard cap for this process
58
+ )
59
+
60
+ wallet = cabalspy.wallets.lookup("As7HjL7dzzvbRbaD3WCun47robib2kmAKRXMvjHkSMB5")
61
+ print(wallet["name"], wallet["type"]) # Otta kol
62
+
63
+ print(cabalspy.x402.summary())
64
+ # {'calls': 1, 'spent_usd': 0.01, 'remaining_usd': 4.99}
65
+ ```
66
+
67
+ The returned object is a full [`cabalspy`](https://pypi.org/project/cabalspy/) client: every endpoint, every type, every error class. Only the payment differs.
68
+
69
+ For agents that already run an event loop:
70
+
71
+ ```python
72
+ from cabalspy_x402 import create_async_client
73
+
74
+ async with await create_async_client(svm_signer=signer) as cabalspy:
75
+ signals = await cabalspy.signals.list(blockchain="solana", type="kol", mode="cluster")
76
+ ```
77
+
78
+ ## Spend limits are on by default
79
+
80
+ A funded wallet handed to an autonomous agent is a standing authorisation to spend. An agent in a retry loop against a paid endpoint will drain it, and the generic x402 client libraries do not cap this.
81
+
82
+ So both limits are on unless you turn them off:
83
+
84
+ | Option | Default | Effect |
85
+ |---|---|---|
86
+ | `max_spend_per_call` | `$0.10` | refuses any single call priced above it |
87
+ | `max_spend_total` | `$10.00` | refuses further calls once reached |
88
+
89
+ The price is read from the 402 response and checked **before the wallet signs anything**. A server asking for more than expected is refused without spending a cent.
90
+
91
+ ```python
92
+ from cabalspy_x402 import SpendLimitError
93
+
94
+ try:
95
+ cabalspy.signals.list(blockchain="solana", type="kol", mode="cluster")
96
+ except SpendLimitError as err:
97
+ print(err.kind, err.amount_usd, err.limit_usd) # 'total' 9.99 10.0
98
+ ```
99
+
100
+ ## Which chains you can pay on
101
+
102
+ CabalSpy accepts payment on **Solana, Base, Polygon and Arbitrum**, and offers all of them in the same response. Register whichever you hold USDC on:
103
+
104
+ ```python
105
+ create_client(svm_signer=solana_signer) # Solana only
106
+ create_client(evm_signer=evm_signer) # Base, Polygon, Arbitrum
107
+ create_client(svm_signer=..., evm_signer=...) # all four, recommended
108
+ create_client(svm_signer=..., networks=["solana"]) # restrict deliberately
109
+ ```
110
+
111
+ Registering more than one is worth it. The server offers every chain at once, and a client that can only pay on one is turned away whenever that chain has a problem.
112
+
113
+ The payment chain has nothing to do with the data: a question about a Base wallet can be paid for in Solana USDC.
114
+
115
+ ## Testnet first
116
+
117
+ ```python
118
+ create_client(svm_signer=signer, networks=["solana-devnet"])
119
+ ```
120
+
121
+ No real money moves, and the whole flow is identical.
122
+
123
+ ## Installing the payment schemes
124
+
125
+ The chain packages are optional extras, so you only pull in what you use:
126
+
127
+ ```bash
128
+ pip install "cabalspy-x402[solana]" # Solana
129
+ pip install "cabalspy-x402[evm]" # Base, Polygon, Arbitrum
130
+ pip install "cabalspy-x402[all]" # both
131
+ ```
132
+
133
+ The Solana extra pins `solana<0.40`. That release removed `solana/rpc/api.py`, which the x402 SVM mechanism imports; 0.38 and 0.39 still have it and allow the `solders 0.27` that x402 needs. Without the pin the install resolves to something that imports but cannot register a Solana scheme.
134
+
135
+ ## Your own ledger
136
+
137
+ Every settled payment is reported, so you can record it alongside whatever else your agent does:
138
+
139
+ ```python
140
+ create_client(
141
+ svm_signer=signer,
142
+ on_payment=lambda p: log.info("paid %s on %s for %s", p.amount_usd, p.network, p.url),
143
+ )
144
+ ```
145
+
146
+ `PaymentRecord` carries the timestamp, the amount, the onchain transaction, the network and the URL that was paid for.
147
+
148
+ ## What you get for the money
149
+
150
+ | Ask | Call |
151
+ |---|---|
152
+ | Who is this wallet? | `wallets.lookup` |
153
+ | How has this KOL performed? | `wallets.tracker` |
154
+ | What are KOLs buying right now? | `transactions.latest` |
155
+ | Was this launch bundled? | `bundle.get` — Solana only |
156
+ | Who still holds this token? | `tokens.holders` |
157
+ | Which wallets moved together? | `signals.list` |
158
+
159
+ Nearly 2,000 labeled wallets, sub-500ms from trade to stream. Full reference at [docs.cabalspy.xyz](https://docs.cabalspy.xyz).
160
+
161
+ ## Prefer an API key?
162
+
163
+ Then you do not need this package. Use [`cabalspy`](https://pypi.org/project/cabalspy/) directly with a key from [apidashboard.cabalspy.xyz](https://apidashboard.cabalspy.xyz/) — 1,000 requests free. Pay-per-call exists for agents that cannot sign up for anything.
164
+
165
+ ## Tests
166
+
167
+ ```bash
168
+ python3 test_x402_client.py
169
+ ```
170
+
171
+ Thirteen checks covering price parsing, both spend limits, the CAIP-2 identifiers, and reading a price from a real 402 body. No wallet, no chain.
172
+
173
+ ## Related
174
+
175
+ - [`cabalspy`](https://pypi.org/project/cabalspy/) — the SDK, Python
176
+ - [`cabalspy`](https://www.npmjs.com/package/cabalspy) on npm · [`cabalspy-x402`](https://www.npmjs.com/package/cabalspy-x402) — TypeScript
177
+ - [`cabalspy`](https://crates.io/crates/cabalspy) on crates.io — Rust
178
+ - [CabalSpy MCP Server](https://mcp.cabalspy.xyz/mcp) — for Claude, Cursor and other assistants
179
+
180
+ ## License
181
+
182
+ MIT
@@ -0,0 +1,141 @@
1
+ # cabalspy-x402 — pay-per-call KOL data for AI agents
2
+
3
+ x402 client for the **CabalSpy KOL API**. An AI agent pays in USDC per request and gets labeled wallet data on **Solana, Base, BNB Chain, Ethereum and Robinhood Chain** — no account, no signup, no API key. The wallet is the credential.
4
+
5
+ ```bash
6
+ pip install "cabalspy-x402[all]"
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```python
12
+ from cabalspy_x402 import create_client
13
+
14
+ cabalspy = create_client(
15
+ svm_signer=signer, # Solana
16
+ max_spend_total="$5.00", # hard cap for this process
17
+ )
18
+
19
+ wallet = cabalspy.wallets.lookup("As7HjL7dzzvbRbaD3WCun47robib2kmAKRXMvjHkSMB5")
20
+ print(wallet["name"], wallet["type"]) # Otta kol
21
+
22
+ print(cabalspy.x402.summary())
23
+ # {'calls': 1, 'spent_usd': 0.01, 'remaining_usd': 4.99}
24
+ ```
25
+
26
+ The returned object is a full [`cabalspy`](https://pypi.org/project/cabalspy/) client: every endpoint, every type, every error class. Only the payment differs.
27
+
28
+ For agents that already run an event loop:
29
+
30
+ ```python
31
+ from cabalspy_x402 import create_async_client
32
+
33
+ async with await create_async_client(svm_signer=signer) as cabalspy:
34
+ signals = await cabalspy.signals.list(blockchain="solana", type="kol", mode="cluster")
35
+ ```
36
+
37
+ ## Spend limits are on by default
38
+
39
+ A funded wallet handed to an autonomous agent is a standing authorisation to spend. An agent in a retry loop against a paid endpoint will drain it, and the generic x402 client libraries do not cap this.
40
+
41
+ So both limits are on unless you turn them off:
42
+
43
+ | Option | Default | Effect |
44
+ |---|---|---|
45
+ | `max_spend_per_call` | `$0.10` | refuses any single call priced above it |
46
+ | `max_spend_total` | `$10.00` | refuses further calls once reached |
47
+
48
+ The price is read from the 402 response and checked **before the wallet signs anything**. A server asking for more than expected is refused without spending a cent.
49
+
50
+ ```python
51
+ from cabalspy_x402 import SpendLimitError
52
+
53
+ try:
54
+ cabalspy.signals.list(blockchain="solana", type="kol", mode="cluster")
55
+ except SpendLimitError as err:
56
+ print(err.kind, err.amount_usd, err.limit_usd) # 'total' 9.99 10.0
57
+ ```
58
+
59
+ ## Which chains you can pay on
60
+
61
+ CabalSpy accepts payment on **Solana, Base, Polygon and Arbitrum**, and offers all of them in the same response. Register whichever you hold USDC on:
62
+
63
+ ```python
64
+ create_client(svm_signer=solana_signer) # Solana only
65
+ create_client(evm_signer=evm_signer) # Base, Polygon, Arbitrum
66
+ create_client(svm_signer=..., evm_signer=...) # all four, recommended
67
+ create_client(svm_signer=..., networks=["solana"]) # restrict deliberately
68
+ ```
69
+
70
+ Registering more than one is worth it. The server offers every chain at once, and a client that can only pay on one is turned away whenever that chain has a problem.
71
+
72
+ The payment chain has nothing to do with the data: a question about a Base wallet can be paid for in Solana USDC.
73
+
74
+ ## Testnet first
75
+
76
+ ```python
77
+ create_client(svm_signer=signer, networks=["solana-devnet"])
78
+ ```
79
+
80
+ No real money moves, and the whole flow is identical.
81
+
82
+ ## Installing the payment schemes
83
+
84
+ The chain packages are optional extras, so you only pull in what you use:
85
+
86
+ ```bash
87
+ pip install "cabalspy-x402[solana]" # Solana
88
+ pip install "cabalspy-x402[evm]" # Base, Polygon, Arbitrum
89
+ pip install "cabalspy-x402[all]" # both
90
+ ```
91
+
92
+ The Solana extra pins `solana<0.40`. That release removed `solana/rpc/api.py`, which the x402 SVM mechanism imports; 0.38 and 0.39 still have it and allow the `solders 0.27` that x402 needs. Without the pin the install resolves to something that imports but cannot register a Solana scheme.
93
+
94
+ ## Your own ledger
95
+
96
+ Every settled payment is reported, so you can record it alongside whatever else your agent does:
97
+
98
+ ```python
99
+ create_client(
100
+ svm_signer=signer,
101
+ on_payment=lambda p: log.info("paid %s on %s for %s", p.amount_usd, p.network, p.url),
102
+ )
103
+ ```
104
+
105
+ `PaymentRecord` carries the timestamp, the amount, the onchain transaction, the network and the URL that was paid for.
106
+
107
+ ## What you get for the money
108
+
109
+ | Ask | Call |
110
+ |---|---|
111
+ | Who is this wallet? | `wallets.lookup` |
112
+ | How has this KOL performed? | `wallets.tracker` |
113
+ | What are KOLs buying right now? | `transactions.latest` |
114
+ | Was this launch bundled? | `bundle.get` — Solana only |
115
+ | Who still holds this token? | `tokens.holders` |
116
+ | Which wallets moved together? | `signals.list` |
117
+
118
+ Nearly 2,000 labeled wallets, sub-500ms from trade to stream. Full reference at [docs.cabalspy.xyz](https://docs.cabalspy.xyz).
119
+
120
+ ## Prefer an API key?
121
+
122
+ Then you do not need this package. Use [`cabalspy`](https://pypi.org/project/cabalspy/) directly with a key from [apidashboard.cabalspy.xyz](https://apidashboard.cabalspy.xyz/) — 1,000 requests free. Pay-per-call exists for agents that cannot sign up for anything.
123
+
124
+ ## Tests
125
+
126
+ ```bash
127
+ python3 test_x402_client.py
128
+ ```
129
+
130
+ Thirteen checks covering price parsing, both spend limits, the CAIP-2 identifiers, and reading a price from a real 402 body. No wallet, no chain.
131
+
132
+ ## Related
133
+
134
+ - [`cabalspy`](https://pypi.org/project/cabalspy/) — the SDK, Python
135
+ - [`cabalspy`](https://www.npmjs.com/package/cabalspy) on npm · [`cabalspy-x402`](https://www.npmjs.com/package/cabalspy-x402) — TypeScript
136
+ - [`cabalspy`](https://crates.io/crates/cabalspy) on crates.io — Rust
137
+ - [CabalSpy MCP Server](https://mcp.cabalspy.xyz/mcp) — for Claude, Cursor and other assistants
138
+
139
+ ## License
140
+
141
+ MIT
@@ -0,0 +1,402 @@
1
+ """cabalspy-x402 — pay-per-call access to the CabalSpy API.
2
+
3
+ Lets an AI agent query labeled wallet data on Solana, Base, BNB Chain, Ethereum
4
+ and Robinhood Chain by paying in USDC per request. No account, no signup, no API
5
+ key — the agent's wallet is the credential.
6
+
7
+ from cabalspy_x402 import create_client
8
+
9
+ cabalspy = create_client(svm_signer=signer, max_spend_total="$5.00")
10
+ wallet = cabalspy.wallets.lookup("As7HjL7dzzv...")
11
+ print(cabalspy.x402.summary())
12
+
13
+ The returned object is a full ``cabalspy`` client: every endpoint, every type,
14
+ every error class. Only the payment differs.
15
+
16
+ ── WHY THE SPEND LIMITS EXIST ────────────────────────────────────────────────
17
+ A funded wallet handed to an autonomous agent is a standing authorisation to
18
+ spend. An agent in a retry loop against a paid endpoint will drain it, and the
19
+ x402 client libraries do not cap this on their own. Both limits are on by
20
+ default for that reason: unbounded spending has to be asked for, not discovered
21
+ afterwards.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime, timezone
29
+ from typing import Any, Callable
30
+
31
+ from cabalspy import AsyncCabalSpy, CabalSpy
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "__version__",
37
+ "create_client",
38
+ "create_async_client",
39
+ "create_payment_client",
40
+ "SpendTracker",
41
+ "SpendLimitError",
42
+ "PaymentRecord",
43
+ "parse_usd",
44
+ "NETWORKS",
45
+ "CabalSpy",
46
+ "AsyncCabalSpy",
47
+ ]
48
+
49
+ #: Networks CabalSpy accepts payment on, as CAIP-2.
50
+ #:
51
+ #: Solana's identifier is the chain's genesis hash, not the word "mainnet".
52
+ #: Registering ``solana:mainnet`` looks right and silently never matches,
53
+ #: because the server offers the genesis-hash form.
54
+ NETWORKS: dict[str, str] = {
55
+ "solana": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
56
+ "solana-devnet": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
57
+ "base": "eip155:8453",
58
+ "polygon": "eip155:137",
59
+ "arbitrum": "eip155:42161",
60
+ }
61
+
62
+ _EVM_NETWORKS = {"base", "polygon", "arbitrum"}
63
+ _DEFAULT_NETWORKS = ["solana", "base", "polygon", "arbitrum"]
64
+
65
+ #: USDC has six decimals everywhere x402 settles.
66
+ _USDC_DECIMALS = 1_000_000
67
+
68
+
69
+ class SpendLimitError(RuntimeError):
70
+ """Raised before signing when a payment would breach a configured limit."""
71
+
72
+ def __init__(self, kind: str, amount_usd: float, limit_usd: float, message: str) -> None:
73
+ super().__init__(message)
74
+ #: ``"per-call"`` or ``"total"``.
75
+ self.kind = kind
76
+ self.amount_usd = amount_usd
77
+ self.limit_usd = limit_usd
78
+
79
+
80
+ @dataclass
81
+ class PaymentRecord:
82
+ """One payment this process made."""
83
+
84
+ at: str
85
+ amount_usd: float | None
86
+ transaction: str | None
87
+ network: str | None
88
+ url: str
89
+
90
+
91
+ def parse_usd(value: str | float | int) -> float:
92
+ """Parses ``"$0.01"``, ``"0.01"`` or ``0.01`` into dollars."""
93
+ if isinstance(value, (int, float)):
94
+ parsed = float(value)
95
+ else:
96
+ cleaned = str(value).strip().lstrip("$").replace(",", "")
97
+ try:
98
+ parsed = float(cleaned)
99
+ except ValueError as exc:
100
+ raise TypeError(f"Not a usable amount: {value!r}") from exc
101
+ if parsed < 0 or parsed != parsed: # NaN
102
+ raise TypeError(f"Not a usable amount: {value!r}")
103
+ return parsed
104
+
105
+
106
+ @dataclass
107
+ class SpendTracker:
108
+ """Tracks spending and refuses to go over budget.
109
+
110
+ Kept separate from the transport so it can be inspected and tested on its
111
+ own, and so a caller can read ``spent_usd`` at any time.
112
+ """
113
+
114
+ max_per_call_usd: float = 0.10
115
+ max_total_usd: float = 10.00
116
+ spent_usd: float = 0.0
117
+ payments: list[PaymentRecord] = field(default_factory=list)
118
+
119
+ def authorise(self, price_usd: float | None, url: str) -> None:
120
+ """Raises when a proposed payment would breach either limit."""
121
+ if price_usd is None:
122
+ return # price unreadable, so the limits cannot be applied
123
+ if price_usd > self.max_per_call_usd:
124
+ raise SpendLimitError(
125
+ "per-call", price_usd, self.max_per_call_usd,
126
+ f"{url} asks for ${price_usd:.4f}, above the per-call limit of "
127
+ f"${self.max_per_call_usd:.4f}. Raise max_spend_per_call if that is expected.",
128
+ )
129
+ if self.spent_usd + price_usd > self.max_total_usd:
130
+ raise SpendLimitError(
131
+ "total", self.spent_usd, self.max_total_usd,
132
+ f"Paying ${price_usd:.4f} would take total spend to "
133
+ f"${self.spent_usd + price_usd:.4f}, past the limit of "
134
+ f"${self.max_total_usd:.2f}. Raise max_spend_total or start a new client.",
135
+ )
136
+
137
+ def record(self, payment: PaymentRecord) -> None:
138
+ self.payments.append(payment)
139
+ if payment.amount_usd is not None:
140
+ self.spent_usd += payment.amount_usd
141
+
142
+ def summary(self) -> dict[str, Any]:
143
+ """What has been spent, and what is left."""
144
+ return {
145
+ "calls": len(self.payments),
146
+ "spent_usd": round(self.spent_usd, 6),
147
+ "remaining_usd": round(max(0.0, self.max_total_usd - self.spent_usd), 6),
148
+ }
149
+
150
+
151
+ def price_of(body: Any) -> float | None:
152
+ """Reads the price out of a 402 body, or None when it cannot be read."""
153
+ try:
154
+ accepts = body.get("accepts") if isinstance(body, dict) else None
155
+ if not accepts:
156
+ return None
157
+ raw = accepts[0].get("amount") or accepts[0].get("maxAmountRequired")
158
+ return None if raw is None else float(raw) / _USDC_DECIMALS
159
+ except Exception:
160
+ return None
161
+
162
+
163
+ def _register_schemes(
164
+ client: Any,
165
+ networks: list[str],
166
+ svm_signer: Any,
167
+ evm_signer: Any,
168
+ rpc_url: str | None,
169
+ ) -> list[str]:
170
+ """Registers one payment scheme per network that has a matching signer."""
171
+ registered: list[str] = []
172
+ for network in networks:
173
+ caip2 = NETWORKS.get(network, network)
174
+ is_evm = network in _EVM_NETWORKS or caip2.startswith("eip155")
175
+ signer = evm_signer if is_evm else svm_signer
176
+ if signer is None:
177
+ continue # no signer for that family, so do not offer it
178
+ try:
179
+ if is_evm:
180
+ from x402.mechanisms.evm.exact import ExactEvmClientScheme
181
+
182
+ client.register(caip2, ExactEvmClientScheme(signer))
183
+ else:
184
+ from x402.mechanisms.svm.exact import ExactSvmClientScheme
185
+
186
+ kwargs = {"rpc_url": rpc_url} if rpc_url else {}
187
+ client.register(caip2, ExactSvmClientScheme(signer, **kwargs))
188
+ registered.append(network)
189
+ except ImportError as exc:
190
+ raise ImportError(
191
+ f"{network} needs an extra package: "
192
+ f'pip install "x402[svm]" or "x402[evm]" ({exc})'
193
+ ) from exc
194
+ return registered
195
+
196
+
197
+ def create_payment_client(
198
+ *,
199
+ svm_signer: Any = None,
200
+ evm_signer: Any = None,
201
+ networks: list[str] | None = None,
202
+ rpc_url: str | None = None,
203
+ ) -> tuple[Any, list[str]]:
204
+ """Builds a configured x402 client and reports which networks it can pay on.
205
+
206
+ Use this when you want the payment behaviour but not the CabalSpy client —
207
+ for any other x402 endpoint, or to wire it into your own HTTP layer.
208
+ """
209
+ from x402.client import x402Client
210
+
211
+ wanted = networks or _DEFAULT_NETWORKS
212
+ payer = x402Client()
213
+ registered = _register_schemes(payer, wanted, svm_signer, evm_signer, rpc_url)
214
+ if not registered:
215
+ raise TypeError(
216
+ "No signer supplied. Pass svm_signer= for Solana, evm_signer= for Base, "
217
+ "Polygon and Arbitrum, or both."
218
+ )
219
+ return payer, registered
220
+
221
+
222
+ def _wrap_httpx(payer: Any, tracker: SpendTracker, on_payment: Callable | None) -> Any:
223
+ """An httpx client that pays for 402 responses, within the given limits."""
224
+ import httpx
225
+ from x402.http.clients.httpx import x402HttpxClient
226
+
227
+ paying = x402HttpxClient(payer)
228
+ plain = httpx.AsyncClient(timeout=45.0)
229
+
230
+ class GuardedClient:
231
+ """Asks first, without paying, so the price can be checked against the
232
+ limits before the wallet is ever asked to sign."""
233
+
234
+ async def request(self, method: str, url: str, **kwargs: Any) -> Any:
235
+ probe = await plain.request(method, url, **kwargs)
236
+ if probe.status_code != 402:
237
+ return probe
238
+ try:
239
+ tracker.authorise(price_of(probe.json()), str(url))
240
+ except json.JSONDecodeError:
241
+ tracker.authorise(None, str(url))
242
+ paid = await paying.request(method, url, **kwargs)
243
+ if paid.status_code < 400:
244
+ record = _read_receipt(paid, str(url))
245
+ tracker.record(record)
246
+ if on_payment:
247
+ on_payment(record)
248
+ return paid
249
+
250
+ async def get(self, url: str, **kwargs: Any) -> Any:
251
+ return await self.request("GET", url, **kwargs)
252
+
253
+ async def post(self, url: str, **kwargs: Any) -> Any:
254
+ return await self.request("POST", url, **kwargs)
255
+
256
+ async def aclose(self) -> None:
257
+ await plain.aclose()
258
+ close = getattr(paying, "aclose", None)
259
+ if close:
260
+ await close()
261
+
262
+ return GuardedClient()
263
+
264
+
265
+ def _read_receipt(response: Any, url: str) -> PaymentRecord:
266
+ """Reads the settlement receipt, tolerating one that cannot be decoded.
267
+
268
+ A receipt that fails to parse must not fail the request: the data was
269
+ delivered and the payment settled. It only means this record is thinner.
270
+ """
271
+ amount_usd = transaction = network = None
272
+ try:
273
+ header = response.headers.get("x-payment-response") or response.headers.get(
274
+ "payment-response"
275
+ )
276
+ if header:
277
+ from x402.http.utils import decode_payment_response_header
278
+
279
+ decoded = decode_payment_response_header(header)
280
+ data = decoded if isinstance(decoded, dict) else decoded.__dict__
281
+ transaction = data.get("transaction")
282
+ network = data.get("network")
283
+ raw = data.get("amount") or data.get("value")
284
+ if raw is not None:
285
+ amount_usd = float(raw) / _USDC_DECIMALS
286
+ except Exception:
287
+ pass
288
+ return PaymentRecord(
289
+ at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
290
+ amount_usd=amount_usd,
291
+ transaction=transaction,
292
+ network=network,
293
+ url=url,
294
+ )
295
+
296
+
297
+ def create_async_client(
298
+ *,
299
+ svm_signer: Any = None,
300
+ evm_signer: Any = None,
301
+ networks: list[str] | None = None,
302
+ max_spend_per_call: str | float = "$0.10",
303
+ max_spend_total: str | float = "$10.00",
304
+ on_payment: Callable[[PaymentRecord], None] | None = None,
305
+ rpc_url: str | None = None,
306
+ **cabalspy_options: Any,
307
+ ) -> AsyncCabalSpy:
308
+ """An async CabalSpy client that pays per call.
309
+
310
+ Everything the ``cabalspy`` package offers, with an agent's wallet in place
311
+ of an API key. Read ``client.x402.summary()`` to see what has been spent.
312
+ """
313
+ payer, _registered = create_payment_client(
314
+ svm_signer=svm_signer, evm_signer=evm_signer, networks=networks, rpc_url=rpc_url
315
+ )
316
+ tracker = SpendTracker(parse_usd(max_spend_per_call), parse_usd(max_spend_total))
317
+ http = _wrap_httpx(payer, tracker, on_payment)
318
+
319
+ client = AsyncCabalSpy(http_client=http, **cabalspy_options)
320
+ client.x402 = tracker # type: ignore[attr-defined]
321
+ return client
322
+
323
+
324
+ def create_client(
325
+ *,
326
+ svm_signer: Any = None,
327
+ evm_signer: Any = None,
328
+ networks: list[str] | None = None,
329
+ max_spend_per_call: str | float = "$0.10",
330
+ max_spend_total: str | float = "$10.00",
331
+ on_payment: Callable[[PaymentRecord], None] | None = None,
332
+ rpc_url: str | None = None,
333
+ **cabalspy_options: Any,
334
+ ) -> CabalSpy:
335
+ """A synchronous CabalSpy client that pays per call.
336
+
337
+ For scripts and notebooks. Agents that already run an event loop should use
338
+ :func:`create_async_client`.
339
+ """
340
+ from x402.client import x402ClientSync
341
+ from x402.http.clients.requests import x402_requests
342
+
343
+ wanted = networks or _DEFAULT_NETWORKS
344
+ payer = x402ClientSync()
345
+ registered = _register_schemes(payer, wanted, svm_signer, evm_signer, rpc_url)
346
+ if not registered:
347
+ raise TypeError(
348
+ "No signer supplied. Pass svm_signer= for Solana, evm_signer= for Base, "
349
+ "Polygon and Arbitrum, or both."
350
+ )
351
+
352
+ tracker = SpendTracker(parse_usd(max_spend_per_call), parse_usd(max_spend_total))
353
+ session = x402_requests(payer)
354
+
355
+ import requests as _requests
356
+
357
+ plain = _requests.Session()
358
+
359
+ class GuardedSession:
360
+ """Mirrors the httpx surface the SDK uses, over a requests session."""
361
+
362
+ def request(self, method: str, url: str, params=None, json=None, headers=None):
363
+ probe = plain.request(method, url, params=params, json=json, headers=headers)
364
+ if probe.status_code != 402:
365
+ return _Adapted(probe)
366
+ try:
367
+ tracker.authorise(price_of(probe.json()), url)
368
+ except ValueError:
369
+ tracker.authorise(None, url)
370
+ paid = session.request(method, url, params=params, json=json, headers=headers)
371
+ if paid.status_code < 400:
372
+ record = _read_receipt(paid, url)
373
+ tracker.record(record)
374
+ if on_payment:
375
+ on_payment(record)
376
+ return _Adapted(paid)
377
+
378
+ def close(self) -> None:
379
+ plain.close()
380
+ session.close()
381
+
382
+ client = CabalSpy(http_client=GuardedSession(), **cabalspy_options)
383
+ client.x402 = tracker # type: ignore[attr-defined]
384
+ return client
385
+
386
+
387
+ class _Adapted:
388
+ """Presents a requests response with the few attributes the SDK reads.
389
+
390
+ The SDK speaks httpx; requests names two things differently. Adapting here
391
+ keeps that difference out of the SDK, which should not know how it is being
392
+ driven.
393
+ """
394
+
395
+ def __init__(self, response: Any) -> None:
396
+ self._r = response
397
+ self.status_code = response.status_code
398
+ self.headers = response.headers
399
+ self.content = response.content
400
+
401
+ def json(self) -> Any:
402
+ return self._r.json()
File without changes
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cabalspy-x402"
7
+ version = "0.1.0"
8
+ description = "x402 client for the CabalSpy KOL API — AI agents pay per request in USDC, no account or API key. Query KOL, smart money and whale wallets on Solana, Base, BNB Chain, Ethereum and Robinhood Chain, with built-in spend limits."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "CabalSpy", email = "contact@cabalspy.xyz" }]
13
+ keywords = [
14
+ "x402", "pay-per-call", "agent-payments", "micropayments", "usdc",
15
+ "ai-agent", "autonomous-agent", "machine-payments", "http-402",
16
+ "cabalspy", "kol", "kol-api", "smart-money", "whale-tracker", "wallet-tracker",
17
+ "onchain-data", "memecoin", "trading-signals", "bundle-detection",
18
+ "solana", "base", "bnb", "ethereum", "robinhood-chain", "sdk",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 4 - Beta",
22
+ "Intended Audience :: Developers",
23
+ "Intended Audience :: Financial and Insurance Industry",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Office/Business :: Financial",
31
+ "Typing :: Typed",
32
+ ]
33
+ dependencies = [
34
+ "cabalspy>=0.2.0",
35
+ "x402>=2.17",
36
+ "httpx>=0.24",
37
+ "requests>=2.28",
38
+ ]
39
+
40
+ [project.optional-dependencies]
41
+ # Payment schemes need chain packages. solana is pinned below 0.40 because that
42
+ # release removed solana/rpc/api.py, which x402's SVM mechanism imports.
43
+ solana = ["x402[svm]>=2.17", "solana>=0.38,<0.40"]
44
+ evm = ["x402[evm]>=2.17"]
45
+ all = ["x402[svm,evm]>=2.17", "solana>=0.38,<0.40"]
46
+ dev = ["pytest>=8.0", "mypy>=1.8"]
47
+
48
+ [project.urls]
49
+ Homepage = "https://docs.cabalspy.xyz"
50
+ Documentation = "https://docs.cabalspy.xyz"
51
+ Repository = "https://github.com/CabalSpy/cabalspy-x402-python"
52
+ Issues = "https://github.com/CabalSpy/cabalspy-x402-python/issues"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["cabalspy_x402"]
56
+
57
+ [tool.hatch.build]
58
+ include = ["cabalspy_x402/**", "README.md", "LICENSE"]