tensorfeed 2.2.1__tar.gz → 3.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.
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/PKG-INFO +46 -7
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/README.md +45 -6
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/pyproject.toml +1 -1
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed/__init__.py +3 -1
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed/client.py +202 -44
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed/web3_signer.py +31 -12
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed.egg-info/PKG-INFO +46 -7
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed.egg-info/SOURCES.txt +2 -0
- tensorfeed-3.1.0/tests/test_premium_auth_header.py +148 -0
- tensorfeed-3.1.0/tests/test_quote_proof.py +283 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/setup.cfg +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed/_premium_tools.py +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed/crewai.py +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed/langchain.py +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed/llamaindex.py +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed.egg-info/dependency_links.txt +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed.egg-info/requires.txt +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tensorfeed.egg-info/top_level.txt +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tests/test_premium_tools.py +0 -0
- {tensorfeed-2.2.1 → tensorfeed-3.1.0}/tests/test_route_verdict.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tensorfeed
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.1.0
|
|
4
4
|
Summary: Python SDK for the TensorFeed.ai API: AI news, status, model pricing, premium routing + history series + webhook watches, LangChain/CrewAI tool factories, agent payments via USDC on Base (optional web3 auto-send)
|
|
5
5
|
Author-email: "TensorFeed.ai" <feedback@tensorfeed.ai>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -137,19 +137,29 @@ print(preview["recommendation"])
|
|
|
137
137
|
## Premium Tier (paid, USDC on Base)
|
|
138
138
|
|
|
139
139
|
```python
|
|
140
|
+
import os
|
|
141
|
+
from eth_account import Account # pip install eth-account
|
|
142
|
+
from eth_account.messages import encode_defunct
|
|
140
143
|
from tensorfeed import TensorFeed, PaymentRequired
|
|
141
144
|
|
|
142
145
|
tf = TensorFeed()
|
|
146
|
+
acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"]) # NEVER hardcode
|
|
143
147
|
|
|
144
148
|
# Step 1: get a 30-minute quote. sender_wallet is the Base wallet you will
|
|
145
149
|
# send the USDC from (required); quote['wallet'] below is where you send it.
|
|
146
|
-
|
|
150
|
+
# The server only quotes for a message signed by sender_wallet, so pass a
|
|
151
|
+
# signer: the SDK builds the message and calls it once.
|
|
152
|
+
quote = tf.buy_credits(
|
|
153
|
+
amount_usd=1.00,
|
|
154
|
+
sender_wallet=acct.address,
|
|
155
|
+
sign_message=lambda m: acct.sign_message(encode_defunct(text=m)).signature.hex(),
|
|
156
|
+
)
|
|
147
157
|
print(f"Send {quote['amount_usd']} USDC on Base to {quote['wallet']}")
|
|
148
158
|
print(f"Memo: {quote['memo']} (expires in {quote['ttl_seconds']}s)")
|
|
149
159
|
print(f"Will get: {quote['credits']} credits")
|
|
150
160
|
|
|
151
|
-
# Step 2: send the USDC tx
|
|
152
|
-
# (
|
|
161
|
+
# Step 2: send the USDC tx from sender_wallet
|
|
162
|
+
# (or let tf.purchase_credits() below do steps 1 to 3 for you)
|
|
153
163
|
|
|
154
164
|
# Step 3: confirm with the tx hash
|
|
155
165
|
result = tf.confirm(tx_hash="0xYOUR_TX_HASH", nonce=quote["memo"])
|
|
@@ -206,6 +216,34 @@ tf.delete_watch(watch_id) # remove when done
|
|
|
206
216
|
print(tf.balance())
|
|
207
217
|
```
|
|
208
218
|
|
|
219
|
+
### Signing the quote request
|
|
220
|
+
|
|
221
|
+
`buy_credits()` requires proof that you control `sender_wallet`: an EIP-191 (`personal_sign`) signature by that wallet over this exact message, lines joined with `\n`:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
I am requesting a TensorFeed credit quote for this wallet.
|
|
225
|
+
|
|
226
|
+
wallet: <sender_wallet, 0x address>
|
|
227
|
+
timestamp: <ISO 8601, within 10 minutes of now>
|
|
228
|
+
nonce: <16 to 64 hex chars, fresh per quote>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Pass `sign_message=` (any callable that takes the message string and returns a 0x hex signature) and the SDK fills in the timestamp and a random nonce. If your signer lives elsewhere (a hardware wallet, a remote signing service), build the message yourself and pass both halves:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
from tensorfeed import build_quote_message
|
|
235
|
+
|
|
236
|
+
message = build_quote_message(wallet) # timestamp=now, nonce=random
|
|
237
|
+
signature = my_remote_signer.personal_sign(message)
|
|
238
|
+
quote = tf.buy_credits(
|
|
239
|
+
amount_usd=1.00, sender_wallet=wallet, message=message, signature=signature
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Each signed message is good for one quote. The server answers `sender_proof_expired` for a stale timestamp, `sender_proof_invalid` when the signature does not match the wallet, and `sender_proof_replayed` for a reused nonce. Calling `buy_credits()` with no signer and no message raises `ValueError` before any request is sent.
|
|
244
|
+
|
|
245
|
+
Only an externally owned account (a plain private key) can produce this signature. Smart contract wallets cannot, and should pay per call through the x402 `X-PAYMENT` flow instead.
|
|
246
|
+
|
|
209
247
|
## Auto-Send (optional, web3 extra)
|
|
210
248
|
|
|
211
249
|
The base SDK keeps you dependency-free, but if you want to skip the manual tx step, install with the `web3` extra:
|
|
@@ -214,7 +252,7 @@ The base SDK keeps you dependency-free, but if you want to skip the manual tx st
|
|
|
214
252
|
pip install 'tensorfeed[web3]'
|
|
215
253
|
```
|
|
216
254
|
|
|
217
|
-
Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call:
|
|
255
|
+
Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call. The same key signs the quote request message and the USDC transfer:
|
|
218
256
|
|
|
219
257
|
```python
|
|
220
258
|
import os
|
|
@@ -317,7 +355,8 @@ except TensorFeedError as e:
|
|
|
317
355
|
| `tf.routing_preview(task=)` | Top-1 routing recommendation (5/day/IP) |
|
|
318
356
|
| `tf.health()` | API health check |
|
|
319
357
|
| `tf.payment_info()` | Wallet, pricing, supported payment flows |
|
|
320
|
-
| `tf.buy_credits(amount_usd=, sender_wallet=)` | Generate a 30-min payment quote |
|
|
358
|
+
| `tf.buy_credits(amount_usd=, sender_wallet=, sign_message=)` | Generate a 30-min payment quote (or pass `message=` and `signature=` instead of `sign_message=`) |
|
|
359
|
+
| `build_quote_message(wallet, timestamp=, nonce=)` | Build the exact quote message `sender_wallet` signs |
|
|
321
360
|
| `tf.confirm(tx_hash=, nonce=)` | Verify USDC tx, mint credit token |
|
|
322
361
|
|
|
323
362
|
### Token-required
|
|
@@ -367,7 +406,7 @@ Premium API responses are licensed for inference use only. Use of TensorFeed pre
|
|
|
367
406
|
|
|
368
407
|
## No refunds; credits do not expire
|
|
369
408
|
|
|
370
|
-
All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire and
|
|
409
|
+
All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire while the token holds them, and a zero-balance token is deleted 30 days after its last use. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
|
|
371
410
|
|
|
372
411
|
## Sanctions
|
|
373
412
|
|
|
@@ -104,19 +104,29 @@ print(preview["recommendation"])
|
|
|
104
104
|
## Premium Tier (paid, USDC on Base)
|
|
105
105
|
|
|
106
106
|
```python
|
|
107
|
+
import os
|
|
108
|
+
from eth_account import Account # pip install eth-account
|
|
109
|
+
from eth_account.messages import encode_defunct
|
|
107
110
|
from tensorfeed import TensorFeed, PaymentRequired
|
|
108
111
|
|
|
109
112
|
tf = TensorFeed()
|
|
113
|
+
acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"]) # NEVER hardcode
|
|
110
114
|
|
|
111
115
|
# Step 1: get a 30-minute quote. sender_wallet is the Base wallet you will
|
|
112
116
|
# send the USDC from (required); quote['wallet'] below is where you send it.
|
|
113
|
-
|
|
117
|
+
# The server only quotes for a message signed by sender_wallet, so pass a
|
|
118
|
+
# signer: the SDK builds the message and calls it once.
|
|
119
|
+
quote = tf.buy_credits(
|
|
120
|
+
amount_usd=1.00,
|
|
121
|
+
sender_wallet=acct.address,
|
|
122
|
+
sign_message=lambda m: acct.sign_message(encode_defunct(text=m)).signature.hex(),
|
|
123
|
+
)
|
|
114
124
|
print(f"Send {quote['amount_usd']} USDC on Base to {quote['wallet']}")
|
|
115
125
|
print(f"Memo: {quote['memo']} (expires in {quote['ttl_seconds']}s)")
|
|
116
126
|
print(f"Will get: {quote['credits']} credits")
|
|
117
127
|
|
|
118
|
-
# Step 2: send the USDC tx
|
|
119
|
-
# (
|
|
128
|
+
# Step 2: send the USDC tx from sender_wallet
|
|
129
|
+
# (or let tf.purchase_credits() below do steps 1 to 3 for you)
|
|
120
130
|
|
|
121
131
|
# Step 3: confirm with the tx hash
|
|
122
132
|
result = tf.confirm(tx_hash="0xYOUR_TX_HASH", nonce=quote["memo"])
|
|
@@ -173,6 +183,34 @@ tf.delete_watch(watch_id) # remove when done
|
|
|
173
183
|
print(tf.balance())
|
|
174
184
|
```
|
|
175
185
|
|
|
186
|
+
### Signing the quote request
|
|
187
|
+
|
|
188
|
+
`buy_credits()` requires proof that you control `sender_wallet`: an EIP-191 (`personal_sign`) signature by that wallet over this exact message, lines joined with `\n`:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
I am requesting a TensorFeed credit quote for this wallet.
|
|
192
|
+
|
|
193
|
+
wallet: <sender_wallet, 0x address>
|
|
194
|
+
timestamp: <ISO 8601, within 10 minutes of now>
|
|
195
|
+
nonce: <16 to 64 hex chars, fresh per quote>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Pass `sign_message=` (any callable that takes the message string and returns a 0x hex signature) and the SDK fills in the timestamp and a random nonce. If your signer lives elsewhere (a hardware wallet, a remote signing service), build the message yourself and pass both halves:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from tensorfeed import build_quote_message
|
|
202
|
+
|
|
203
|
+
message = build_quote_message(wallet) # timestamp=now, nonce=random
|
|
204
|
+
signature = my_remote_signer.personal_sign(message)
|
|
205
|
+
quote = tf.buy_credits(
|
|
206
|
+
amount_usd=1.00, sender_wallet=wallet, message=message, signature=signature
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Each signed message is good for one quote. The server answers `sender_proof_expired` for a stale timestamp, `sender_proof_invalid` when the signature does not match the wallet, and `sender_proof_replayed` for a reused nonce. Calling `buy_credits()` with no signer and no message raises `ValueError` before any request is sent.
|
|
211
|
+
|
|
212
|
+
Only an externally owned account (a plain private key) can produce this signature. Smart contract wallets cannot, and should pay per call through the x402 `X-PAYMENT` flow instead.
|
|
213
|
+
|
|
176
214
|
## Auto-Send (optional, web3 extra)
|
|
177
215
|
|
|
178
216
|
The base SDK keeps you dependency-free, but if you want to skip the manual tx step, install with the `web3` extra:
|
|
@@ -181,7 +219,7 @@ The base SDK keeps you dependency-free, but if you want to skip the manual tx st
|
|
|
181
219
|
pip install 'tensorfeed[web3]'
|
|
182
220
|
```
|
|
183
221
|
|
|
184
|
-
Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call:
|
|
222
|
+
Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call. The same key signs the quote request message and the USDC transfer:
|
|
185
223
|
|
|
186
224
|
```python
|
|
187
225
|
import os
|
|
@@ -284,7 +322,8 @@ except TensorFeedError as e:
|
|
|
284
322
|
| `tf.routing_preview(task=)` | Top-1 routing recommendation (5/day/IP) |
|
|
285
323
|
| `tf.health()` | API health check |
|
|
286
324
|
| `tf.payment_info()` | Wallet, pricing, supported payment flows |
|
|
287
|
-
| `tf.buy_credits(amount_usd=, sender_wallet=)` | Generate a 30-min payment quote |
|
|
325
|
+
| `tf.buy_credits(amount_usd=, sender_wallet=, sign_message=)` | Generate a 30-min payment quote (or pass `message=` and `signature=` instead of `sign_message=`) |
|
|
326
|
+
| `build_quote_message(wallet, timestamp=, nonce=)` | Build the exact quote message `sender_wallet` signs |
|
|
288
327
|
| `tf.confirm(tx_hash=, nonce=)` | Verify USDC tx, mint credit token |
|
|
289
328
|
|
|
290
329
|
### Token-required
|
|
@@ -334,7 +373,7 @@ Premium API responses are licensed for inference use only. Use of TensorFeed pre
|
|
|
334
373
|
|
|
335
374
|
## No refunds; credits do not expire
|
|
336
375
|
|
|
337
|
-
All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire and
|
|
376
|
+
All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire while the token holds them, and a zero-balance token is deleted 30 days after its last use. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
|
|
338
377
|
|
|
339
378
|
## Sanctions
|
|
340
379
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tensorfeed"
|
|
7
|
-
version = "
|
|
7
|
+
version = "3.1.0"
|
|
8
8
|
description = "Python SDK for the TensorFeed.ai API: AI news, status, model pricing, premium routing + history series + webhook watches, LangChain/CrewAI tool factories, agent payments via USDC on Base (optional web3 auto-send)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -13,6 +13,7 @@ from .client import (
|
|
|
13
13
|
TensorFeedError,
|
|
14
14
|
PaymentRequired,
|
|
15
15
|
RateLimited,
|
|
16
|
+
build_quote_message,
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
__all__ = [
|
|
@@ -20,5 +21,6 @@ __all__ = [
|
|
|
20
21
|
"TensorFeedError",
|
|
21
22
|
"PaymentRequired",
|
|
22
23
|
"RateLimited",
|
|
24
|
+
"build_quote_message",
|
|
23
25
|
]
|
|
24
|
-
__version__ = "
|
|
26
|
+
__version__ = "3.1.0"
|
|
@@ -6,19 +6,106 @@ agent activity) plus the paid premium tier (routing, payment flow).
|
|
|
6
6
|
|
|
7
7
|
Premium endpoints are paid via USDC on Base. No accounts, no API keys,
|
|
8
8
|
no traditional payment processors. See ``buy_credits()`` and ``confirm()``.
|
|
9
|
+
|
|
10
|
+
A credit quote requires proof that you control the paying wallet:
|
|
11
|
+
``buy_credits()`` sends a short message signed by ``sender_wallet``
|
|
12
|
+
(EIP-191 personal_sign). Pass ``sign_message=`` and the SDK builds the
|
|
13
|
+
message for you, or build it yourself with ``build_quote_message()``.
|
|
14
|
+
Smart contract wallets cannot produce this signature and should pay
|
|
15
|
+
through the x402 X-PAYMENT flow instead.
|
|
9
16
|
"""
|
|
10
17
|
|
|
11
18
|
from __future__ import annotations
|
|
12
19
|
|
|
13
20
|
import json
|
|
21
|
+
import secrets
|
|
14
22
|
import urllib.error
|
|
15
23
|
import urllib.parse
|
|
16
24
|
import urllib.request
|
|
17
|
-
from
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Any, Callable
|
|
18
27
|
|
|
19
28
|
|
|
20
29
|
DEFAULT_BASE_URL = "https://tensorfeed.ai/api"
|
|
21
|
-
DEFAULT_USER_AGENT = "TensorFeed-SDK-Python/
|
|
30
|
+
DEFAULT_USER_AGENT = "TensorFeed-SDK-Python/3.1"
|
|
31
|
+
|
|
32
|
+
# First line of the credit quote message. The server requires it to
|
|
33
|
+
# match exactly, so a message signed for another flow is never accepted.
|
|
34
|
+
QUOTE_PROOF_HEADER = "I am requesting a TensorFeed credit quote for this wallet."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _utc_now_iso() -> str:
|
|
38
|
+
"""Current UTC time as ISO 8601 with milliseconds and a Z suffix."""
|
|
39
|
+
return (
|
|
40
|
+
datetime.now(timezone.utc)
|
|
41
|
+
.isoformat(timespec="milliseconds")
|
|
42
|
+
.replace("+00:00", "Z")
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_quote_message(
|
|
47
|
+
wallet: str,
|
|
48
|
+
timestamp: str | None = None,
|
|
49
|
+
nonce: str | None = None,
|
|
50
|
+
) -> str:
|
|
51
|
+
"""Build the exact message ``sender_wallet`` signs for a credit quote.
|
|
52
|
+
|
|
53
|
+
The layout is fixed and must be signed byte for byte (lines joined
|
|
54
|
+
with LF)::
|
|
55
|
+
|
|
56
|
+
I am requesting a TensorFeed credit quote for this wallet.
|
|
57
|
+
|
|
58
|
+
wallet: 0x...
|
|
59
|
+
timestamp: 2026-11-06T12:00:00.000Z
|
|
60
|
+
nonce: 3f9c...
|
|
61
|
+
|
|
62
|
+
Sign the returned string with EIP-191 personal_sign using the key
|
|
63
|
+
for ``wallet``, then pass it to ``TensorFeed.buy_credits()`` as
|
|
64
|
+
``message`` with the resulting ``signature``. Each message works for
|
|
65
|
+
one quote only.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
wallet: The 0x address that will send the USDC (the same value
|
|
69
|
+
you pass as ``sender_wallet``).
|
|
70
|
+
timestamp: ISO 8601 time. Defaults to now in UTC. The server
|
|
71
|
+
accepts it within 10 minutes of its clock, and at most 60
|
|
72
|
+
seconds in the future.
|
|
73
|
+
nonce: 16 to 64 hex characters, fresh per quote. Defaults to
|
|
74
|
+
``secrets.token_hex(16)``.
|
|
75
|
+
"""
|
|
76
|
+
if timestamp is None:
|
|
77
|
+
timestamp = _utc_now_iso()
|
|
78
|
+
if nonce is None:
|
|
79
|
+
nonce = secrets.token_hex(16)
|
|
80
|
+
return "\n".join(
|
|
81
|
+
[
|
|
82
|
+
QUOTE_PROOF_HEADER,
|
|
83
|
+
"",
|
|
84
|
+
f"wallet: {wallet}",
|
|
85
|
+
f"timestamp: {timestamp}",
|
|
86
|
+
f"nonce: {nonce}",
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_signature(signature: Any) -> str:
|
|
92
|
+
"""Return a signature as a 0x-prefixed hex string.
|
|
93
|
+
|
|
94
|
+
Signers differ: eth_account returns HexBytes, whose ``.hex()`` has
|
|
95
|
+
the 0x prefix on some versions and not on others. Accept raw bytes
|
|
96
|
+
or hex with or without the prefix.
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(signature, (bytes, bytearray)):
|
|
99
|
+
return "0x" + bytes(signature).hex()
|
|
100
|
+
if not isinstance(signature, str):
|
|
101
|
+
raise TypeError(
|
|
102
|
+
"signature must be a 0x-prefixed hex string (or bytes), "
|
|
103
|
+
f"got {type(signature).__name__}"
|
|
104
|
+
)
|
|
105
|
+
sig = signature.strip()
|
|
106
|
+
if sig[:2].lower() != "0x":
|
|
107
|
+
sig = "0x" + sig
|
|
108
|
+
return sig
|
|
22
109
|
|
|
23
110
|
|
|
24
111
|
class TensorFeedError(Exception):
|
|
@@ -59,9 +146,20 @@ class TensorFeed:
|
|
|
59
146
|
news = tf.news(limit=10)
|
|
60
147
|
preview = tf.routing_preview(task="code")
|
|
61
148
|
|
|
62
|
-
# Paid: buy credits, then call routing
|
|
63
|
-
|
|
64
|
-
|
|
149
|
+
# Paid: buy credits, then call routing. The quote needs a
|
|
150
|
+
# message signed by sender_wallet (see buy_credits()).
|
|
151
|
+
from eth_account import Account
|
|
152
|
+
from eth_account.messages import encode_defunct
|
|
153
|
+
|
|
154
|
+
acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"])
|
|
155
|
+
quote = tf.buy_credits(
|
|
156
|
+
amount_usd=1.00,
|
|
157
|
+
sender_wallet=acct.address,
|
|
158
|
+
sign_message=lambda m: acct.sign_message(
|
|
159
|
+
encode_defunct(text=m)
|
|
160
|
+
).signature.hex(),
|
|
161
|
+
)
|
|
162
|
+
# ... send USDC tx from acct.address ...
|
|
65
163
|
result = tf.confirm(tx_hash="0x...", nonce=quote["memo"])
|
|
66
164
|
# token is auto-stored on the client
|
|
67
165
|
rec = tf.routing(task="code", top_n=5)
|
|
@@ -105,10 +203,18 @@ class TensorFeed:
|
|
|
105
203
|
# Premium endpoints, balance, and explicit-require-token paths get
|
|
106
204
|
# the Authorization header. Free endpoints do not advertise a token
|
|
107
205
|
# (avoids accidentally leaking it to public-data endpoints).
|
|
206
|
+
# Paths handed to _request are RELATIVE: base_url already ends in /api,
|
|
207
|
+
# and url is built as f"{base_url}{path}". The old fallback tested for
|
|
208
|
+
# "/api/premium/" and "/api/payment/balance" in that relative path, so
|
|
209
|
+
# neither could ever match and the whole fallback was dead code. Every
|
|
210
|
+
# premium method happened to pass require_token=True explicitly, except
|
|
211
|
+
# attention_series, which therefore called a paid endpoint with no
|
|
212
|
+
# Authorization header at all and 402'd for customers holding credits.
|
|
213
|
+
# Matching the relative form makes the safety net real.
|
|
108
214
|
needs_auth = (
|
|
109
215
|
require_token
|
|
110
|
-
or "/
|
|
111
|
-
or path == "/
|
|
216
|
+
or path.startswith("/premium/")
|
|
217
|
+
or path == "/payment/balance"
|
|
112
218
|
)
|
|
113
219
|
if needs_auth and self.token:
|
|
114
220
|
headers["Authorization"] = f"Bearer {self.token}"
|
|
@@ -380,7 +486,9 @@ class TensorFeed:
|
|
|
380
486
|
kwargs["from"] = from_date
|
|
381
487
|
if to_date is not None:
|
|
382
488
|
kwargs["to"] = to_date
|
|
383
|
-
return self.
|
|
489
|
+
return self._request(
|
|
490
|
+
"GET", "/premium/attention/series", params=kwargs, require_token=True
|
|
491
|
+
)
|
|
384
492
|
|
|
385
493
|
def agent_activity(self) -> dict[str, Any]:
|
|
386
494
|
"""Get agent traffic metrics. Free."""
|
|
@@ -510,7 +618,7 @@ class TensorFeed:
|
|
|
510
618
|
|
|
511
619
|
For windows up to 90 days plus ``incident_count`` and
|
|
512
620
|
``mttr_minutes`` per provider, use ``status_leaderboard()``
|
|
513
|
-
(premium,
|
|
621
|
+
(premium, 5 credits).
|
|
514
622
|
|
|
515
623
|
Args:
|
|
516
624
|
days: 1 to 7 (default 7).
|
|
@@ -598,28 +706,101 @@ class TensorFeed:
|
|
|
598
706
|
"""
|
|
599
707
|
return self._get("/payment/info")
|
|
600
708
|
|
|
601
|
-
def buy_credits(
|
|
709
|
+
def buy_credits(
|
|
710
|
+
self,
|
|
711
|
+
*,
|
|
712
|
+
amount_usd: float,
|
|
713
|
+
sender_wallet: str,
|
|
714
|
+
sign_message: Callable[[str], str] | None = None,
|
|
715
|
+
message: str | None = None,
|
|
716
|
+
signature: str | None = None,
|
|
717
|
+
) -> dict[str, Any]:
|
|
602
718
|
"""Generate a 30-min payment quote bound to a sender wallet.
|
|
603
719
|
|
|
604
720
|
Returns a dict with ``wallet``, ``memo``, ``amount_usd``, ``credits``,
|
|
605
721
|
``sender_wallet``, ``expires_at``, ``ttl_seconds``, ``next_step``.
|
|
606
722
|
|
|
723
|
+
The server issues a quote only for a message signed by
|
|
724
|
+
``sender_wallet`` (EIP-191 personal_sign), so supply one of:
|
|
725
|
+
|
|
726
|
+
* ``sign_message``: a callable that takes the message string and
|
|
727
|
+
returns its signature as 0x hex. The SDK builds the message
|
|
728
|
+
(UTC timestamp, fresh random nonce) and calls it once.
|
|
729
|
+
* ``message`` plus ``signature``: a message you built with
|
|
730
|
+
``build_quote_message()`` and signed yourself. Each signed
|
|
731
|
+
message is single use.
|
|
732
|
+
|
|
733
|
+
With eth_account::
|
|
734
|
+
|
|
735
|
+
from eth_account import Account
|
|
736
|
+
from eth_account.messages import encode_defunct
|
|
737
|
+
|
|
738
|
+
acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"])
|
|
739
|
+
quote = tf.buy_credits(
|
|
740
|
+
amount_usd=1.00,
|
|
741
|
+
sender_wallet=acct.address,
|
|
742
|
+
sign_message=lambda m: acct.sign_message(
|
|
743
|
+
encode_defunct(text=m)
|
|
744
|
+
).signature.hex(),
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
Only an externally owned account (a plain private key) can sign
|
|
748
|
+
this. A smart contract wallet cannot, and should pay per call
|
|
749
|
+
through the x402 X-PAYMENT flow instead.
|
|
750
|
+
|
|
607
751
|
Send the USDC on Base FROM ``sender_wallet`` TO ``wallet``, then
|
|
608
752
|
call ``confirm()`` with the tx hash and the returned ``memo`` as
|
|
609
753
|
nonce. The on-chain sender must match ``sender_wallet`` exactly
|
|
610
|
-
or ``confirm()`` will reject with ``sender_mismatch``.
|
|
611
|
-
the post-2026-05-05 binding that closes the public-mempool tx
|
|
612
|
-
sniper attack.
|
|
754
|
+
or ``confirm()`` will reject with ``sender_mismatch``.
|
|
613
755
|
|
|
614
756
|
Args:
|
|
615
757
|
amount_usd: USD value of credits to buy. Must be 0.5 to 10000.
|
|
616
758
|
Volume discounts apply at $5 (10%), $30 (25%), $200 (40%).
|
|
617
759
|
sender_wallet: 0x-prefixed 40-char EVM address that will sign
|
|
618
760
|
the on-chain USDC transfer. Required.
|
|
761
|
+
sign_message: Callable that signs the quote message with the
|
|
762
|
+
key for ``sender_wallet`` and returns the signature (0x
|
|
763
|
+
hex string; raw bytes are also accepted).
|
|
764
|
+
message: A pre-built quote message from
|
|
765
|
+
``build_quote_message()``. Use with ``signature``.
|
|
766
|
+
signature: The EIP-191 signature of ``message`` by
|
|
767
|
+
``sender_wallet`` (0x followed by 130 hex characters).
|
|
768
|
+
|
|
769
|
+
Raises:
|
|
770
|
+
ValueError: if no signer and no message plus signature is
|
|
771
|
+
supplied, or if both are. Raised before any HTTP call.
|
|
772
|
+
TensorFeedError: on a server-side rejection, for example
|
|
773
|
+
``sender_proof_expired`` (timestamp outside the 10 minute
|
|
774
|
+
window), ``sender_proof_invalid`` (signature does not
|
|
775
|
+
match), or ``sender_proof_replayed`` (nonce already used).
|
|
619
776
|
"""
|
|
777
|
+
if sign_message is not None:
|
|
778
|
+
if message is not None or signature is not None:
|
|
779
|
+
raise ValueError(
|
|
780
|
+
"buy_credits(): pass either sign_message or message plus "
|
|
781
|
+
"signature, not both."
|
|
782
|
+
)
|
|
783
|
+
message = build_quote_message(sender_wallet.strip())
|
|
784
|
+
signature = sign_message(message)
|
|
785
|
+
elif message is None or signature is None:
|
|
786
|
+
raise ValueError(
|
|
787
|
+
"buy_credits() needs proof that you control sender_wallet. "
|
|
788
|
+
"TensorFeed now issues a credit quote only for a message "
|
|
789
|
+
"signed by sender_wallet (EIP-191 personal_sign). Pass "
|
|
790
|
+
"sign_message=<callable that signs a str and returns a 0x hex "
|
|
791
|
+
"signature>, or pass message= (from build_quote_message()) "
|
|
792
|
+
"and signature=. Smart contract wallets cannot sign this; "
|
|
793
|
+
"pay through the x402 X-PAYMENT flow instead. See "
|
|
794
|
+
"https://tensorfeed.ai/developers/agent-payments#sender-proof"
|
|
795
|
+
)
|
|
620
796
|
return self._post(
|
|
621
797
|
"/payment/buy-credits",
|
|
622
|
-
{
|
|
798
|
+
{
|
|
799
|
+
"amount_usd": amount_usd,
|
|
800
|
+
"sender_wallet": sender_wallet,
|
|
801
|
+
"message": message,
|
|
802
|
+
"signature": _normalize_signature(signature),
|
|
803
|
+
},
|
|
623
804
|
)
|
|
624
805
|
|
|
625
806
|
def confirm(
|
|
@@ -699,9 +880,11 @@ class TensorFeed:
|
|
|
699
880
|
"""Buy credits end-to-end: quote, sign USDC tx, broadcast, confirm.
|
|
700
881
|
|
|
701
882
|
Convenience wrapper around buy_credits() + raw signing + confirm()
|
|
702
|
-
that handles the whole flow in one call. The
|
|
703
|
-
|
|
704
|
-
|
|
883
|
+
that handles the whole flow in one call. The same key signs the
|
|
884
|
+
quote request message and the USDC transfer, so it must be an
|
|
885
|
+
externally owned account (not a smart contract wallet). The token
|
|
886
|
+
is auto-stored on this client on success, so subsequent
|
|
887
|
+
``routing()`` calls work immediately.
|
|
705
888
|
|
|
706
889
|
Requires the optional ``web3`` extra:
|
|
707
890
|
pip install 'tensorfeed[web3]'
|
|
@@ -1338,8 +1521,8 @@ class TensorFeed:
|
|
|
1338
1521
|
|
|
1339
1522
|
Free, no auth. Single edge-cached endpoint that fans out across
|
|
1340
1523
|
every daily TensorFeed feed (news, 3 paper feeds, HF
|
|
1341
|
-
models/datasets/Spaces, hot GitHub issues,
|
|
1342
|
-
|
|
1524
|
+
models/datasets/Spaces, hot GitHub issues, OpenRouter catalog
|
|
1525
|
+
summary, provider status) and returns a
|
|
1343
1526
|
structured response. Saves an agent from orchestrating 9
|
|
1344
1527
|
separate calls.
|
|
1345
1528
|
|
|
@@ -1455,31 +1638,6 @@ class TensorFeed:
|
|
|
1455
1638
|
"""
|
|
1456
1639
|
return self._request("GET", "/openrouter/models")
|
|
1457
1640
|
|
|
1458
|
-
def get_reddit_trending(self) -> dict[str, Any]:
|
|
1459
|
-
"""Currently-hot Reddit threads in 7 AI-relevant subreddits.
|
|
1460
|
-
|
|
1461
|
-
Free, no auth. Subreddits queried: LocalLLaMA, MachineLearning,
|
|
1462
|
-
ClaudeAI, OpenAI, singularity, artificial, AI_Agents. Pulls each
|
|
1463
|
-
subreddit's "hot" listing, dedups by post id, drops stickied and
|
|
1464
|
-
NSFW posts, ranks by score, top 30. Refreshed daily at 13:00 UTC.
|
|
1465
|
-
Companion to ``get_hot_issues()``: that surfaces developer-side
|
|
1466
|
-
conversation on GitHub, this surfaces community-side conversation
|
|
1467
|
-
on Reddit.
|
|
1468
|
-
|
|
1469
|
-
Titles pass through TensorFeed's prompt-injection sanitization at
|
|
1470
|
-
capture time, so role-confusion tokens and bidi/zero-width
|
|
1471
|
-
spoofing characters in Reddit posts cannot reach an agent.
|
|
1472
|
-
|
|
1473
|
-
Returns:
|
|
1474
|
-
Dict with ``ok`` and ``snapshot`` keys. Snapshot includes
|
|
1475
|
-
``date``, ``capturedAt``, ``total_posts``, ``subreddits_queried``,
|
|
1476
|
-
``posts`` (each with id, subreddit, title, author, score,
|
|
1477
|
-
upvote_ratio, num_comments, permalink, url, created_utc,
|
|
1478
|
-
flair, is_self, is_video), and ``summary`` (by_subreddit,
|
|
1479
|
-
top_authors).
|
|
1480
|
-
"""
|
|
1481
|
-
return self._request("GET", "/reddit/trending")
|
|
1482
|
-
|
|
1483
1641
|
def get_hot_issues(self) -> dict[str, Any]:
|
|
1484
1642
|
"""Currently-hot GitHub issues across the AI ecosystem.
|
|
1485
1643
|
|
|
@@ -14,10 +14,12 @@ Security:
|
|
|
14
14
|
Cloudflare Workers and similar environments should keep the key in
|
|
15
15
|
a secret manager.
|
|
16
16
|
|
|
17
|
-
For higher security, sign
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
For higher security, sign externally (e.g., via a hardware wallet or
|
|
18
|
+
remote signer): pass that signer to
|
|
19
|
+
``TensorFeed.buy_credits(sign_message=...)`` for the quote, send the
|
|
20
|
+
USDC transfer yourself, and call ``TensorFeed.confirm()`` with the
|
|
21
|
+
resulting tx hash. The auto-send path here is for server-side agents
|
|
22
|
+
that hold their own keys.
|
|
21
23
|
"""
|
|
22
24
|
|
|
23
25
|
from __future__ import annotations
|
|
@@ -63,14 +65,19 @@ def auto_purchase_credits(
|
|
|
63
65
|
) -> dict[str, Any]:
|
|
64
66
|
"""Buy credits end-to-end: quote, sign USDC tx, broadcast, confirm.
|
|
65
67
|
|
|
68
|
+
The same key signs the quote request message (EIP-191, required by
|
|
69
|
+
``buy_credits()``) and the on-chain USDC transfer, so the key must
|
|
70
|
+
belong to an externally owned account. Smart contract wallets cannot
|
|
71
|
+
use this flow; pay through the x402 X-PAYMENT flow instead.
|
|
72
|
+
|
|
66
73
|
Args:
|
|
67
74
|
tf: The TensorFeed client. Token is auto-stored on success so
|
|
68
75
|
subsequent calls to ``tf.routing()`` etc. work immediately.
|
|
69
76
|
amount_usd: USD value of credits to buy. Must be 0.5 to 10000.
|
|
70
77
|
private_key: 0x-prefixed Ethereum private key for signing the
|
|
71
|
-
USDC transfer. NEVER hardcode this;
|
|
72
|
-
var. The key is held in memory only
|
|
73
|
-
this call.
|
|
78
|
+
quote message and the USDC transfer. NEVER hardcode this;
|
|
79
|
+
read from a secure env var. The key is held in memory only
|
|
80
|
+
for the duration of this call.
|
|
74
81
|
rpc_url: Base mainnet RPC endpoint. Defaults to the free public
|
|
75
82
|
Base RPC, which is fine for occasional use. For production
|
|
76
83
|
use Alchemy or QuickNode.
|
|
@@ -91,11 +98,13 @@ def auto_purchase_credits(
|
|
|
91
98
|
try:
|
|
92
99
|
from web3 import Web3
|
|
93
100
|
from eth_account import Account
|
|
101
|
+
from eth_account.messages import encode_defunct
|
|
94
102
|
except ImportError as e:
|
|
95
103
|
raise Web3NotInstalled(
|
|
96
104
|
"web3 is not installed. Run: pip install 'tensorfeed[web3]'\n"
|
|
97
|
-
"Then retry, or use the manual flow: tf.buy_credits(
|
|
98
|
-
"the USDC tx yourself +
|
|
105
|
+
"Then retry, or use the manual flow: tf.buy_credits(..., "
|
|
106
|
+
"sign_message=...) + sign the USDC tx yourself + "
|
|
107
|
+
"tf.confirm(tx_hash=..., nonce=...)."
|
|
99
108
|
) from e
|
|
100
109
|
|
|
101
110
|
rpc = rpc_url or DEFAULT_BASE_RPC
|
|
@@ -107,9 +116,19 @@ def auto_purchase_credits(
|
|
|
107
116
|
sender = account.address
|
|
108
117
|
|
|
109
118
|
# Step 1: TensorFeed-side quote, bound to this sender wallet so the
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
119
|
+
# confirm accepts the on-chain transfer. Must pass the exact
|
|
120
|
+
# lowercased address that will sign that transfer. The server only
|
|
121
|
+
# quotes for a message signed by that wallet, so sign it here with
|
|
122
|
+
# the same key (EIP-191 personal_sign).
|
|
123
|
+
def _sign_quote_message(message: str) -> str:
|
|
124
|
+
signed = account.sign_message(encode_defunct(text=message))
|
|
125
|
+
return "0x" + bytes(signed.signature).hex()
|
|
126
|
+
|
|
127
|
+
quote = tf.buy_credits(
|
|
128
|
+
amount_usd=amount_usd,
|
|
129
|
+
sender_wallet=sender.lower(),
|
|
130
|
+
sign_message=_sign_quote_message,
|
|
131
|
+
)
|
|
113
132
|
receiving_wallet = quote["wallet"]
|
|
114
133
|
memo = quote["memo"]
|
|
115
134
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tensorfeed
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.1.0
|
|
4
4
|
Summary: Python SDK for the TensorFeed.ai API: AI news, status, model pricing, premium routing + history series + webhook watches, LangChain/CrewAI tool factories, agent payments via USDC on Base (optional web3 auto-send)
|
|
5
5
|
Author-email: "TensorFeed.ai" <feedback@tensorfeed.ai>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -137,19 +137,29 @@ print(preview["recommendation"])
|
|
|
137
137
|
## Premium Tier (paid, USDC on Base)
|
|
138
138
|
|
|
139
139
|
```python
|
|
140
|
+
import os
|
|
141
|
+
from eth_account import Account # pip install eth-account
|
|
142
|
+
from eth_account.messages import encode_defunct
|
|
140
143
|
from tensorfeed import TensorFeed, PaymentRequired
|
|
141
144
|
|
|
142
145
|
tf = TensorFeed()
|
|
146
|
+
acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"]) # NEVER hardcode
|
|
143
147
|
|
|
144
148
|
# Step 1: get a 30-minute quote. sender_wallet is the Base wallet you will
|
|
145
149
|
# send the USDC from (required); quote['wallet'] below is where you send it.
|
|
146
|
-
|
|
150
|
+
# The server only quotes for a message signed by sender_wallet, so pass a
|
|
151
|
+
# signer: the SDK builds the message and calls it once.
|
|
152
|
+
quote = tf.buy_credits(
|
|
153
|
+
amount_usd=1.00,
|
|
154
|
+
sender_wallet=acct.address,
|
|
155
|
+
sign_message=lambda m: acct.sign_message(encode_defunct(text=m)).signature.hex(),
|
|
156
|
+
)
|
|
147
157
|
print(f"Send {quote['amount_usd']} USDC on Base to {quote['wallet']}")
|
|
148
158
|
print(f"Memo: {quote['memo']} (expires in {quote['ttl_seconds']}s)")
|
|
149
159
|
print(f"Will get: {quote['credits']} credits")
|
|
150
160
|
|
|
151
|
-
# Step 2: send the USDC tx
|
|
152
|
-
# (
|
|
161
|
+
# Step 2: send the USDC tx from sender_wallet
|
|
162
|
+
# (or let tf.purchase_credits() below do steps 1 to 3 for you)
|
|
153
163
|
|
|
154
164
|
# Step 3: confirm with the tx hash
|
|
155
165
|
result = tf.confirm(tx_hash="0xYOUR_TX_HASH", nonce=quote["memo"])
|
|
@@ -206,6 +216,34 @@ tf.delete_watch(watch_id) # remove when done
|
|
|
206
216
|
print(tf.balance())
|
|
207
217
|
```
|
|
208
218
|
|
|
219
|
+
### Signing the quote request
|
|
220
|
+
|
|
221
|
+
`buy_credits()` requires proof that you control `sender_wallet`: an EIP-191 (`personal_sign`) signature by that wallet over this exact message, lines joined with `\n`:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
I am requesting a TensorFeed credit quote for this wallet.
|
|
225
|
+
|
|
226
|
+
wallet: <sender_wallet, 0x address>
|
|
227
|
+
timestamp: <ISO 8601, within 10 minutes of now>
|
|
228
|
+
nonce: <16 to 64 hex chars, fresh per quote>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Pass `sign_message=` (any callable that takes the message string and returns a 0x hex signature) and the SDK fills in the timestamp and a random nonce. If your signer lives elsewhere (a hardware wallet, a remote signing service), build the message yourself and pass both halves:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
from tensorfeed import build_quote_message
|
|
235
|
+
|
|
236
|
+
message = build_quote_message(wallet) # timestamp=now, nonce=random
|
|
237
|
+
signature = my_remote_signer.personal_sign(message)
|
|
238
|
+
quote = tf.buy_credits(
|
|
239
|
+
amount_usd=1.00, sender_wallet=wallet, message=message, signature=signature
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Each signed message is good for one quote. The server answers `sender_proof_expired` for a stale timestamp, `sender_proof_invalid` when the signature does not match the wallet, and `sender_proof_replayed` for a reused nonce. Calling `buy_credits()` with no signer and no message raises `ValueError` before any request is sent.
|
|
244
|
+
|
|
245
|
+
Only an externally owned account (a plain private key) can produce this signature. Smart contract wallets cannot, and should pay per call through the x402 `X-PAYMENT` flow instead.
|
|
246
|
+
|
|
209
247
|
## Auto-Send (optional, web3 extra)
|
|
210
248
|
|
|
211
249
|
The base SDK keeps you dependency-free, but if you want to skip the manual tx step, install with the `web3` extra:
|
|
@@ -214,7 +252,7 @@ The base SDK keeps you dependency-free, but if you want to skip the manual tx st
|
|
|
214
252
|
pip install 'tensorfeed[web3]'
|
|
215
253
|
```
|
|
216
254
|
|
|
217
|
-
Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call:
|
|
255
|
+
Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call. The same key signs the quote request message and the USDC transfer:
|
|
218
256
|
|
|
219
257
|
```python
|
|
220
258
|
import os
|
|
@@ -317,7 +355,8 @@ except TensorFeedError as e:
|
|
|
317
355
|
| `tf.routing_preview(task=)` | Top-1 routing recommendation (5/day/IP) |
|
|
318
356
|
| `tf.health()` | API health check |
|
|
319
357
|
| `tf.payment_info()` | Wallet, pricing, supported payment flows |
|
|
320
|
-
| `tf.buy_credits(amount_usd=, sender_wallet=)` | Generate a 30-min payment quote |
|
|
358
|
+
| `tf.buy_credits(amount_usd=, sender_wallet=, sign_message=)` | Generate a 30-min payment quote (or pass `message=` and `signature=` instead of `sign_message=`) |
|
|
359
|
+
| `build_quote_message(wallet, timestamp=, nonce=)` | Build the exact quote message `sender_wallet` signs |
|
|
321
360
|
| `tf.confirm(tx_hash=, nonce=)` | Verify USDC tx, mint credit token |
|
|
322
361
|
|
|
323
362
|
### Token-required
|
|
@@ -367,7 +406,7 @@ Premium API responses are licensed for inference use only. Use of TensorFeed pre
|
|
|
367
406
|
|
|
368
407
|
## No refunds; credits do not expire
|
|
369
408
|
|
|
370
|
-
All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire and
|
|
409
|
+
All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire while the token holds them, and a zero-balance token is deleted 30 days after its last use. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
|
|
371
410
|
|
|
372
411
|
## Sanctions
|
|
373
412
|
|
|
@@ -12,5 +12,7 @@ tensorfeed.egg-info/SOURCES.txt
|
|
|
12
12
|
tensorfeed.egg-info/dependency_links.txt
|
|
13
13
|
tensorfeed.egg-info/requires.txt
|
|
14
14
|
tensorfeed.egg-info/top_level.txt
|
|
15
|
+
tests/test_premium_auth_header.py
|
|
15
16
|
tests/test_premium_tools.py
|
|
17
|
+
tests/test_quote_proof.py
|
|
16
18
|
tests/test_route_verdict.py
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Every premium method must send the Authorization header.
|
|
2
|
+
|
|
3
|
+
A customer buys credits, calls a documented premium method, and gets a 402
|
|
4
|
+
because the SDK never attached their token. That shipped in 2.2.1:
|
|
5
|
+
``attention_series`` was the only premium method of twenty that omitted
|
|
6
|
+
``require_token=True``, and the fallback meant to catch exactly that mistake
|
|
7
|
+
was dead code.
|
|
8
|
+
|
|
9
|
+
The fallback tested ``"/api/premium/" in path``. Paths handed to ``_request``
|
|
10
|
+
are relative, because ``base_url`` already ends in ``/api`` and the URL is
|
|
11
|
+
built as ``f"{base_url}{path}"``. So the substring could never match and the
|
|
12
|
+
safety net never fired for anything.
|
|
13
|
+
|
|
14
|
+
Two guards here, because either one alone is escapable:
|
|
15
|
+
|
|
16
|
+
1. Behavioral. Drive every premium method through a stub transport and assert
|
|
17
|
+
the header is actually present on the wire. Catches a missing flag no
|
|
18
|
+
matter how the method is written.
|
|
19
|
+
2. Static. Assert the fallback still matches the relative path form, so the
|
|
20
|
+
net cannot silently go dead again while every method happens to pass the
|
|
21
|
+
flag explicitly.
|
|
22
|
+
|
|
23
|
+
Network-free. Run from sdk/python::
|
|
24
|
+
|
|
25
|
+
python -m unittest discover -s tests -t .
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import inspect
|
|
31
|
+
import json
|
|
32
|
+
import unittest
|
|
33
|
+
from unittest import mock
|
|
34
|
+
|
|
35
|
+
from tensorfeed import TensorFeed
|
|
36
|
+
from tensorfeed import client as client_mod
|
|
37
|
+
|
|
38
|
+
TOKEN = "tf_live_testtoken0000000000"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _StubResponse:
|
|
42
|
+
"""Minimal stand-in for the urlopen context manager."""
|
|
43
|
+
|
|
44
|
+
status = 200
|
|
45
|
+
headers: dict[str, str] = {}
|
|
46
|
+
|
|
47
|
+
def read(self) -> bytes:
|
|
48
|
+
return json.dumps({"ok": True}).encode()
|
|
49
|
+
|
|
50
|
+
def __enter__(self):
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *exc):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _premium_methods():
|
|
58
|
+
"""Public methods whose source calls a /premium/ path."""
|
|
59
|
+
for name, fn in inspect.getmembers(TensorFeed, predicate=inspect.isfunction):
|
|
60
|
+
if name.startswith("_"):
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
src = inspect.getsource(fn)
|
|
64
|
+
except OSError: # pragma: no cover
|
|
65
|
+
continue
|
|
66
|
+
if '"/premium/' in src or "'/premium/" in src:
|
|
67
|
+
yield name, fn
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PremiumMethodsSendTheToken(unittest.TestCase):
|
|
71
|
+
def test_at_least_a_dozen_premium_methods_are_discovered(self):
|
|
72
|
+
found = list(_premium_methods())
|
|
73
|
+
self.assertGreater(
|
|
74
|
+
len(found), 12, f"expected to discover many premium methods, got {len(found)}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_every_premium_method_attaches_authorization(self):
|
|
78
|
+
tf = TensorFeed(token=TOKEN)
|
|
79
|
+
offenders: list[str] = []
|
|
80
|
+
|
|
81
|
+
for name, fn in _premium_methods():
|
|
82
|
+
sig = inspect.signature(fn)
|
|
83
|
+
# Supply a placeholder for every required positional parameter so the
|
|
84
|
+
# method reaches the transport. Values are irrelevant: the stub
|
|
85
|
+
# answers everything with 200 and we only inspect the request.
|
|
86
|
+
args: list[object] = []
|
|
87
|
+
kwargs: dict[str, object] = {}
|
|
88
|
+
for pname, p in list(sig.parameters.items())[1:]:
|
|
89
|
+
if p.default is not inspect.Parameter.empty:
|
|
90
|
+
continue
|
|
91
|
+
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
92
|
+
continue
|
|
93
|
+
# Numeric-sounding params need a number; everything else takes a
|
|
94
|
+
# plausible string. The value never reaches a real service.
|
|
95
|
+
filler: object = 1000 if "tokens" in pname or pname.endswith("_per_day") else "anthropic"
|
|
96
|
+
if p.kind is p.KEYWORD_ONLY:
|
|
97
|
+
kwargs[pname] = filler
|
|
98
|
+
else:
|
|
99
|
+
args.append(filler)
|
|
100
|
+
|
|
101
|
+
captured: dict[str, object] = {}
|
|
102
|
+
|
|
103
|
+
def _fake_urlopen(req, *a, **kw):
|
|
104
|
+
captured["headers"] = {k.lower(): v for k, v in req.headers.items()}
|
|
105
|
+
captured["url"] = req.full_url
|
|
106
|
+
return _StubResponse()
|
|
107
|
+
|
|
108
|
+
with mock.patch.object(client_mod.urllib.request, "urlopen", _fake_urlopen):
|
|
109
|
+
try:
|
|
110
|
+
getattr(tf, name)(*args, **kwargs)
|
|
111
|
+
except Exception as exc: # noqa: BLE001
|
|
112
|
+
offenders.append(f"{name}: raised before transport ({exc!r})")
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
headers = captured.get("headers") or {}
|
|
116
|
+
if headers.get("authorization") != f"Bearer {TOKEN}":
|
|
117
|
+
offenders.append(
|
|
118
|
+
f"{name}: no Authorization header on {captured.get('url')}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self.assertEqual(
|
|
122
|
+
offenders,
|
|
123
|
+
[],
|
|
124
|
+
"These premium methods call a paid endpoint without the customer's token, so a "
|
|
125
|
+
"customer holding credits gets a 402 instead of data:\n " + "\n ".join(offenders),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TheAuthFallbackIsNotDeadCode(unittest.TestCase):
|
|
130
|
+
def test_fallback_matches_the_relative_path_form(self):
|
|
131
|
+
src = inspect.getsource(client_mod.TensorFeed._request)
|
|
132
|
+
self.assertIn(
|
|
133
|
+
'path.startswith("/premium/")',
|
|
134
|
+
src,
|
|
135
|
+
"The premium auth fallback must match the RELATIVE path form. base_url already "
|
|
136
|
+
"ends in /api, so a check for '/api/premium/' in path can never be true and the "
|
|
137
|
+
"safety net is dead code.",
|
|
138
|
+
)
|
|
139
|
+
self.assertNotIn(
|
|
140
|
+
'"/api/premium/" in path',
|
|
141
|
+
src,
|
|
142
|
+
"This is the dead check that let attention_series ship without a token.",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def test_base_url_still_ends_in_api(self):
|
|
146
|
+
# The premise of the test above. If this ever changes, the relative-path
|
|
147
|
+
# assumption changes with it.
|
|
148
|
+
self.assertTrue(client_mod.DEFAULT_BASE_URL.endswith("/api"))
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Tests for the signed credit quote request (sender proof).
|
|
2
|
+
|
|
3
|
+
/api/payment/buy-credits only issues a quote for a message signed by
|
|
4
|
+
sender_wallet (EIP-191 personal_sign). The server parses the message
|
|
5
|
+
strictly (exact header line, wallet / timestamp / nonce fields), so the
|
|
6
|
+
SDK's builder has to match the template byte for byte, and buy_credits()
|
|
7
|
+
has to post the same message it had signed.
|
|
8
|
+
|
|
9
|
+
Network-free: urlopen is patched and every outgoing request is captured.
|
|
10
|
+
The signature recovery tests need eth_account and skip without it.
|
|
11
|
+
|
|
12
|
+
Run from sdk/python::
|
|
13
|
+
|
|
14
|
+
python -m pytest tests
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import types
|
|
23
|
+
import unittest
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Any
|
|
26
|
+
from unittest import mock
|
|
27
|
+
|
|
28
|
+
import tensorfeed
|
|
29
|
+
from tensorfeed import TensorFeed, build_quote_message
|
|
30
|
+
from tensorfeed import client as client_mod
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from eth_account import Account
|
|
34
|
+
from eth_account.messages import encode_defunct
|
|
35
|
+
|
|
36
|
+
HAVE_ETH_ACCOUNT = True
|
|
37
|
+
except ImportError: # pragma: no cover (depends on the environment)
|
|
38
|
+
HAVE_ETH_ACCOUNT = False
|
|
39
|
+
|
|
40
|
+
WALLET = "0x1111111111111111111111111111111111111111"
|
|
41
|
+
FAKE_SIG = "0x" + "ab" * 65
|
|
42
|
+
# Throwaway key built in code so no key-shaped literal sits in source.
|
|
43
|
+
TEST_KEY = "0x" + "11" * 32
|
|
44
|
+
|
|
45
|
+
QUOTE_RESPONSE = {
|
|
46
|
+
"ok": True,
|
|
47
|
+
"wallet": "0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1",
|
|
48
|
+
"sender_wallet": WALLET,
|
|
49
|
+
"memo": "tfq_test",
|
|
50
|
+
"amount_usd": 1.0,
|
|
51
|
+
"credits": 50,
|
|
52
|
+
"ttl_seconds": 1800,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _StubResponse:
|
|
57
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
58
|
+
self._body = json.dumps(payload).encode()
|
|
59
|
+
|
|
60
|
+
def read(self) -> bytes:
|
|
61
|
+
return self._body
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> "_StubResponse":
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *exc: object) -> None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _capturing_urlopen(calls: list[dict[str, Any]], payload: dict[str, Any] | None = None):
|
|
71
|
+
def fake_urlopen(req, *a, **kw):
|
|
72
|
+
calls.append(
|
|
73
|
+
{
|
|
74
|
+
"url": req.full_url,
|
|
75
|
+
"method": req.get_method(),
|
|
76
|
+
"headers": {k.lower(): v for k, v in req.header_items()},
|
|
77
|
+
"body": json.loads(req.data.decode()) if req.data else None,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
return _StubResponse(payload or QUOTE_RESPONSE)
|
|
81
|
+
|
|
82
|
+
return fake_urlopen
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BuildQuoteMessage(unittest.TestCase):
|
|
86
|
+
def test_exact_output(self):
|
|
87
|
+
msg = build_quote_message(
|
|
88
|
+
WALLET, timestamp="2026-11-06T12:00:00.000Z", nonce="0123456789abcdef"
|
|
89
|
+
)
|
|
90
|
+
self.assertEqual(
|
|
91
|
+
msg,
|
|
92
|
+
"I am requesting a TensorFeed credit quote for this wallet.\n"
|
|
93
|
+
"\n"
|
|
94
|
+
"wallet: 0x1111111111111111111111111111111111111111\n"
|
|
95
|
+
"timestamp: 2026-11-06T12:00:00.000Z\n"
|
|
96
|
+
"nonce: 0123456789abcdef",
|
|
97
|
+
)
|
|
98
|
+
self.assertNotIn("\r", msg)
|
|
99
|
+
self.assertFalse(msg.endswith("\n"))
|
|
100
|
+
|
|
101
|
+
def test_defaults_use_utc_timestamp_and_fresh_hex_nonce(self):
|
|
102
|
+
before = datetime.now(timezone.utc)
|
|
103
|
+
lines = build_quote_message(WALLET).split("\n")
|
|
104
|
+
after = datetime.now(timezone.utc)
|
|
105
|
+
|
|
106
|
+
self.assertEqual(lines[0], client_mod.QUOTE_PROOF_HEADER)
|
|
107
|
+
self.assertEqual(lines[1], "")
|
|
108
|
+
self.assertEqual(lines[2], f"wallet: {WALLET}")
|
|
109
|
+
|
|
110
|
+
self.assertTrue(lines[3].startswith("timestamp: "))
|
|
111
|
+
ts = lines[3].removeprefix("timestamp: ")
|
|
112
|
+
# Millisecond precision with a Z suffix, the form Date.parse reads.
|
|
113
|
+
self.assertRegex(ts, r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
|
|
114
|
+
parsed = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
|
|
115
|
+
# isoformat truncates to the millisecond, so compare at that precision.
|
|
116
|
+
self.assertLessEqual(before.replace(microsecond=before.microsecond // 1000 * 1000), parsed)
|
|
117
|
+
self.assertLessEqual(parsed, after)
|
|
118
|
+
|
|
119
|
+
self.assertTrue(lines[4].startswith("nonce: "))
|
|
120
|
+
nonce = lines[4].removeprefix("nonce: ")
|
|
121
|
+
self.assertRegex(nonce, r"^[0-9a-f]{16,64}$")
|
|
122
|
+
self.assertNotEqual(nonce, build_quote_message(WALLET).split("\n")[4].removeprefix("nonce: "))
|
|
123
|
+
|
|
124
|
+
def test_exported_from_package_root(self):
|
|
125
|
+
self.assertIn("build_quote_message", tensorfeed.__all__)
|
|
126
|
+
self.assertIs(tensorfeed.build_quote_message, client_mod.build_quote_message)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class BuyCreditsSendsTheProof(unittest.TestCase):
|
|
130
|
+
def test_sign_message_posts_all_four_fields(self):
|
|
131
|
+
tf = TensorFeed()
|
|
132
|
+
calls: list[dict[str, Any]] = []
|
|
133
|
+
signed: list[str] = []
|
|
134
|
+
|
|
135
|
+
def signer(message: str) -> str:
|
|
136
|
+
signed.append(message)
|
|
137
|
+
return FAKE_SIG
|
|
138
|
+
|
|
139
|
+
with mock.patch.object(client_mod.urllib.request, "urlopen", _capturing_urlopen(calls)):
|
|
140
|
+
result = tf.buy_credits(amount_usd=1.0, sender_wallet=WALLET, sign_message=signer)
|
|
141
|
+
|
|
142
|
+
self.assertEqual(result, QUOTE_RESPONSE)
|
|
143
|
+
self.assertEqual(len(calls), 1)
|
|
144
|
+
self.assertEqual(len(signed), 1, "the signer must be called exactly once")
|
|
145
|
+
call = calls[0]
|
|
146
|
+
self.assertEqual(call["method"], "POST")
|
|
147
|
+
self.assertTrue(call["url"].endswith("/api/payment/buy-credits"))
|
|
148
|
+
self.assertEqual(
|
|
149
|
+
set(call["body"]), {"amount_usd", "sender_wallet", "message", "signature"}
|
|
150
|
+
)
|
|
151
|
+
self.assertEqual(call["body"]["amount_usd"], 1.0)
|
|
152
|
+
self.assertEqual(call["body"]["sender_wallet"], WALLET)
|
|
153
|
+
self.assertEqual(call["body"]["message"], signed[0])
|
|
154
|
+
self.assertEqual(call["body"]["signature"], FAKE_SIG)
|
|
155
|
+
self.assertEqual(signed[0].split("\n")[2], f"wallet: {WALLET}")
|
|
156
|
+
self.assertNotIn("authorization", call["headers"])
|
|
157
|
+
|
|
158
|
+
def test_signature_is_normalized_to_0x_hex(self):
|
|
159
|
+
tf = TensorFeed()
|
|
160
|
+
bare = "ab" * 65
|
|
161
|
+
for returned in (bare, bytes.fromhex(bare), "0x" + bare):
|
|
162
|
+
calls: list[dict[str, Any]] = []
|
|
163
|
+
with mock.patch.object(client_mod.urllib.request, "urlopen", _capturing_urlopen(calls)):
|
|
164
|
+
tf.buy_credits(
|
|
165
|
+
amount_usd=1.0, sender_wallet=WALLET, sign_message=lambda m, r=returned: r
|
|
166
|
+
)
|
|
167
|
+
self.assertEqual(calls[0]["body"]["signature"], "0x" + bare)
|
|
168
|
+
|
|
169
|
+
def test_prebuilt_message_and_signature_are_posted_unchanged(self):
|
|
170
|
+
tf = TensorFeed()
|
|
171
|
+
msg = build_quote_message(WALLET)
|
|
172
|
+
calls: list[dict[str, Any]] = []
|
|
173
|
+
with mock.patch.object(client_mod.urllib.request, "urlopen", _capturing_urlopen(calls)):
|
|
174
|
+
tf.buy_credits(amount_usd=2.5, sender_wallet=WALLET, message=msg, signature=FAKE_SIG)
|
|
175
|
+
self.assertEqual(calls[0]["body"]["message"], msg)
|
|
176
|
+
self.assertEqual(calls[0]["body"]["signature"], FAKE_SIG)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class BuyCreditsRefusesWithoutProof(unittest.TestCase):
|
|
180
|
+
def _assert_raises_without_http(self, **kwargs: Any) -> ValueError:
|
|
181
|
+
tf = TensorFeed()
|
|
182
|
+
urlopen = mock.MagicMock(side_effect=AssertionError("no HTTP call expected"))
|
|
183
|
+
with mock.patch.object(client_mod.urllib.request, "urlopen", urlopen):
|
|
184
|
+
with self.assertRaises(ValueError) as ctx:
|
|
185
|
+
tf.buy_credits(amount_usd=1.0, sender_wallet=WALLET, **kwargs)
|
|
186
|
+
urlopen.assert_not_called()
|
|
187
|
+
return ctx.exception
|
|
188
|
+
|
|
189
|
+
def test_neither_signer_nor_message(self):
|
|
190
|
+
exc = self._assert_raises_without_http()
|
|
191
|
+
text = str(exc)
|
|
192
|
+
self.assertIn("sign_message", text)
|
|
193
|
+
self.assertIn("signature", text)
|
|
194
|
+
self.assertIn("x402", text)
|
|
195
|
+
|
|
196
|
+
def test_message_without_signature(self):
|
|
197
|
+
self._assert_raises_without_http(message=build_quote_message(WALLET))
|
|
198
|
+
|
|
199
|
+
def test_signature_without_message(self):
|
|
200
|
+
self._assert_raises_without_http(signature=FAKE_SIG)
|
|
201
|
+
|
|
202
|
+
def test_signer_and_message_together(self):
|
|
203
|
+
self._assert_raises_without_http(
|
|
204
|
+
sign_message=lambda m: FAKE_SIG,
|
|
205
|
+
message=build_quote_message(WALLET),
|
|
206
|
+
signature=FAKE_SIG,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@unittest.skipUnless(HAVE_ETH_ACCOUNT, "eth_account is not installed")
|
|
211
|
+
class SignatureRecoversTheWallet(unittest.TestCase):
|
|
212
|
+
def test_eth_account_signer_recovers_sender_wallet(self):
|
|
213
|
+
acct = Account.from_key(TEST_KEY)
|
|
214
|
+
tf = TensorFeed()
|
|
215
|
+
calls: list[dict[str, Any]] = []
|
|
216
|
+
|
|
217
|
+
def signer(message: str) -> str:
|
|
218
|
+
return acct.sign_message(encode_defunct(text=message)).signature.hex()
|
|
219
|
+
|
|
220
|
+
with mock.patch.object(client_mod.urllib.request, "urlopen", _capturing_urlopen(calls)):
|
|
221
|
+
tf.buy_credits(amount_usd=1.0, sender_wallet=acct.address, sign_message=signer)
|
|
222
|
+
|
|
223
|
+
body = calls[0]["body"]
|
|
224
|
+
# Same shape check the server applies before recovery.
|
|
225
|
+
self.assertRegex(body["signature"], r"^0x[0-9a-fA-F]{130}$")
|
|
226
|
+
recovered = Account.recover_message(
|
|
227
|
+
encode_defunct(text=body["message"]), signature=body["signature"]
|
|
228
|
+
)
|
|
229
|
+
self.assertEqual(recovered, acct.address)
|
|
230
|
+
wallet_line = body["message"].split("\n")[2]
|
|
231
|
+
self.assertEqual(wallet_line, f"wallet: {acct.address}")
|
|
232
|
+
|
|
233
|
+
def test_auto_purchase_signs_the_quote_with_its_key(self):
|
|
234
|
+
"""purchase_credits() must send a quote proof signed by its own key.
|
|
235
|
+
|
|
236
|
+
web3 is replaced with a stub (only the RPC connectivity check runs
|
|
237
|
+
before the quote), and the transport stops the flow right after
|
|
238
|
+
the buy-credits request, so nothing touches a chain.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
class _StopAfterQuote(Exception):
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
class _FakeWeb3:
|
|
245
|
+
class HTTPProvider:
|
|
246
|
+
def __init__(self, *a: object, **kw: object) -> None:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
def __init__(self, provider: object) -> None:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
def is_connected(self) -> bool:
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
fake_web3 = types.ModuleType("web3")
|
|
256
|
+
fake_web3.Web3 = _FakeWeb3 # type: ignore[attr-defined]
|
|
257
|
+
|
|
258
|
+
captured: list[dict[str, Any]] = []
|
|
259
|
+
|
|
260
|
+
def fake_urlopen(req, *a, **kw):
|
|
261
|
+
captured.append(json.loads(req.data.decode()))
|
|
262
|
+
raise _StopAfterQuote()
|
|
263
|
+
|
|
264
|
+
acct = Account.from_key(TEST_KEY)
|
|
265
|
+
tf = TensorFeed()
|
|
266
|
+
with mock.patch.dict(sys.modules, {"web3": fake_web3}):
|
|
267
|
+
with mock.patch.object(client_mod.urllib.request, "urlopen", fake_urlopen):
|
|
268
|
+
with self.assertRaises(_StopAfterQuote):
|
|
269
|
+
tf.purchase_credits(amount_usd=1.0, private_key=TEST_KEY)
|
|
270
|
+
|
|
271
|
+
self.assertEqual(len(captured), 1)
|
|
272
|
+
body = captured[0]
|
|
273
|
+
self.assertEqual(body["sender_wallet"], acct.address.lower())
|
|
274
|
+
self.assertRegex(body["signature"], r"^0x[0-9a-fA-F]{130}$")
|
|
275
|
+
recovered = Account.recover_message(
|
|
276
|
+
encode_defunct(text=body["message"]), signature=body["signature"]
|
|
277
|
+
)
|
|
278
|
+
self.assertEqual(recovered.lower(), body["sender_wallet"])
|
|
279
|
+
self.assertTrue(re.match(r"^wallet: 0x[0-9a-f]{40}$", body["message"].split("\n")[2]))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|