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,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"]
|