toll-sdk 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,7 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ .venv/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .test-build/
toll_sdk-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toll
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,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: toll-sdk
3
+ Version: 0.1.0
4
+ Summary: Client for Toll's coin checks (Robinhood Chain, Solana, BSC) over MCP. Standard library only.
5
+ Project-URL: Homepage, https://t0ll.xyz/agents
6
+ Project-URL: Documentation, https://t0ll.xyz/llms.txt
7
+ Project-URL: Repository, https://github.com/t0ll-xyz/toll-plugins
8
+ Author: Toll
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agent,bsc,larp,mcp,robinhood-chain,rug-check,solana,toll,x402
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+
25
+ # toll-sdk
26
+
27
+ Python client for [Toll](https://t0ll.xyz/agents)'s coin checks, read from the chain (Robinhood Chain, Solana, BSC), over Toll's remote MCP server. Who is behind a coin, the dev's record, and whether there is a real product behind it. Standard library only, Python >= 3.9, typed.
28
+
29
+ Information only, not financial advice.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ pip install toll-sdk # import name: toll_sdk
35
+ ```
36
+
37
+ ## Example
38
+
39
+ ```python
40
+ from toll_sdk import Toll
41
+
42
+ toll = Toll()
43
+ check = toll.check_coin("0xff9e86e6b21fd688027981bee4b64c8b6b804ed3", chain="rh")
44
+ print(check.text) # markdown facts from the chain
45
+ larp = toll.larp_check("0xff9e86e6b21fd688027981bee4b64c8b6b804ed3", wait=True)
46
+ print(larp.text) # product found / partly found / not found / not read
47
+ ```
48
+
49
+ Every method returns `TollResult(text, paid_url, pending)`. `pending` (`{"retry_after_s": n}` or None) means part of the answer is still being read: ask again after that many seconds. `paid_url` is set when the free coins are used up (see below).
50
+
51
+ ## API
52
+
53
+ `Toll(base_url="https://t0ll.xyz", opener=None, timeout=30.0, sign=None, pay_timeout=300.0)`
54
+
55
+ - `check_coin(address, chain=None)` - chain is `rh`, `sol` or `bsc`
56
+ - `larp_check(token, chain=None, wait=False)` - `rh` or `sol`; `wait=True` asks again after the seconds the server gives while part of the answer is still being read or Toll is busy with many new LARP checks (max 3 tries, max 120 s)
57
+ - `dev_record(wallet, chain=None)`, `launch_record(address)`, `wallet_privacy(wallet)`, `fresh_launches(limit=None)`, `chain_pulse()`
58
+ - `tools()` lists the server's tools, `call(name, args)` calls any of them
59
+ - `pay(paid_url, sign=None, timeout=None)` - pays a paid URL once and returns its JSON (see below)
60
+ - `is_valid_address(x)`, `is_valid_wallet(x)` - the checks run before anything is sent
61
+ - Failures (HTTP, JSON-RPC, timeouts, server-side input errors) raise `TollError`.
62
+
63
+ ## Free limit and the paid path
64
+
65
+ 10 new coins a day per caller are free (coins already read stay free). Past that, the result carries `paid_url`, the x402 endpoint for that coin (0.05 USDG on Robinhood Chain, charged only for a whole answer).
66
+
67
+ `pay(paid_url)` runs the x402 flow itself and asks your `sign` function for one payment:
68
+
69
+ 1. GET the URL without payment. Toll answers 402 with what to pay: x402 v2 in the `PAYMENT-REQUIRED` header, v1 in the body.
70
+ 2. Call `sign(requirements, url)` **once**. It returns the payment header value.
71
+ 3. GET again with it (`PAYMENT-SIGNATURE` for v2, `X-PAYMENT` for v1, following what the 402 offered). While Toll is still reading the coin it answers 402 `{"pending": true}` (nothing charged): `pay` resends **exactly the same header** after `Retry-After` (at least 1 s), up to 5 tries and 300 s. A 200 is the answer. Any other 402 after paying raises `TollError` "payment not accepted"; `pay` never signs a second payment for one call.
72
+
73
+ The whole call has its own timeout (`pay_timeout`, default 300 s). If it times out or the connection drops after the payment was sent, the error says the payment may have settled: do not retry that call with a new signature; resend the same header.
74
+
75
+ ### The `sign` contract
76
+
77
+ ```python
78
+ from toll_sdk import PaymentRequirements, Toll
79
+
80
+ def sign(req: PaymentRequirements, url: str) -> str:
81
+ # req.x402_version: 2 when the 402 had a PAYMENT-REQUIRED header, else 1
82
+ # req.payment_required: the decoded 402 object; req.accepts: scheme, network "eip155:4663",
83
+ # asset (USDG), amount (v2) / maxAmountRequired (v1), payTo, maxTimeoutSeconds, extra
84
+ # Check the amount and payTo, sign an EIP-3009 USDG transfer authorization with your x402 client,
85
+ # and return the header value for req.x402_version (the base64 payment payload).
86
+ ...
87
+
88
+ toll = Toll(sign=sign)
89
+ r = toll.check_coin(address)
90
+ if r.paid_url:
91
+ print(toll.pay(r.paid_url))
92
+ ```
93
+
94
+ Your key never touches this SDK. Without a `sign`, `pay` raises before any request. The JavaScript SDK's README has a worked `sign` built on `@priors/x402`. Docs: <https://t0ll.xyz/agents>, <https://t0ll.xyz/llms.txt>, <https://t0ll.xyz/agents/x402>.
95
+
96
+ MIT.
@@ -0,0 +1,72 @@
1
+ # toll-sdk
2
+
3
+ Python client for [Toll](https://t0ll.xyz/agents)'s coin checks, read from the chain (Robinhood Chain, Solana, BSC), over Toll's remote MCP server. Who is behind a coin, the dev's record, and whether there is a real product behind it. Standard library only, Python >= 3.9, typed.
4
+
5
+ Information only, not financial advice.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install toll-sdk # import name: toll_sdk
11
+ ```
12
+
13
+ ## Example
14
+
15
+ ```python
16
+ from toll_sdk import Toll
17
+
18
+ toll = Toll()
19
+ check = toll.check_coin("0xff9e86e6b21fd688027981bee4b64c8b6b804ed3", chain="rh")
20
+ print(check.text) # markdown facts from the chain
21
+ larp = toll.larp_check("0xff9e86e6b21fd688027981bee4b64c8b6b804ed3", wait=True)
22
+ print(larp.text) # product found / partly found / not found / not read
23
+ ```
24
+
25
+ Every method returns `TollResult(text, paid_url, pending)`. `pending` (`{"retry_after_s": n}` or None) means part of the answer is still being read: ask again after that many seconds. `paid_url` is set when the free coins are used up (see below).
26
+
27
+ ## API
28
+
29
+ `Toll(base_url="https://t0ll.xyz", opener=None, timeout=30.0, sign=None, pay_timeout=300.0)`
30
+
31
+ - `check_coin(address, chain=None)` - chain is `rh`, `sol` or `bsc`
32
+ - `larp_check(token, chain=None, wait=False)` - `rh` or `sol`; `wait=True` asks again after the seconds the server gives while part of the answer is still being read or Toll is busy with many new LARP checks (max 3 tries, max 120 s)
33
+ - `dev_record(wallet, chain=None)`, `launch_record(address)`, `wallet_privacy(wallet)`, `fresh_launches(limit=None)`, `chain_pulse()`
34
+ - `tools()` lists the server's tools, `call(name, args)` calls any of them
35
+ - `pay(paid_url, sign=None, timeout=None)` - pays a paid URL once and returns its JSON (see below)
36
+ - `is_valid_address(x)`, `is_valid_wallet(x)` - the checks run before anything is sent
37
+ - Failures (HTTP, JSON-RPC, timeouts, server-side input errors) raise `TollError`.
38
+
39
+ ## Free limit and the paid path
40
+
41
+ 10 new coins a day per caller are free (coins already read stay free). Past that, the result carries `paid_url`, the x402 endpoint for that coin (0.05 USDG on Robinhood Chain, charged only for a whole answer).
42
+
43
+ `pay(paid_url)` runs the x402 flow itself and asks your `sign` function for one payment:
44
+
45
+ 1. GET the URL without payment. Toll answers 402 with what to pay: x402 v2 in the `PAYMENT-REQUIRED` header, v1 in the body.
46
+ 2. Call `sign(requirements, url)` **once**. It returns the payment header value.
47
+ 3. GET again with it (`PAYMENT-SIGNATURE` for v2, `X-PAYMENT` for v1, following what the 402 offered). While Toll is still reading the coin it answers 402 `{"pending": true}` (nothing charged): `pay` resends **exactly the same header** after `Retry-After` (at least 1 s), up to 5 tries and 300 s. A 200 is the answer. Any other 402 after paying raises `TollError` "payment not accepted"; `pay` never signs a second payment for one call.
48
+
49
+ The whole call has its own timeout (`pay_timeout`, default 300 s). If it times out or the connection drops after the payment was sent, the error says the payment may have settled: do not retry that call with a new signature; resend the same header.
50
+
51
+ ### The `sign` contract
52
+
53
+ ```python
54
+ from toll_sdk import PaymentRequirements, Toll
55
+
56
+ def sign(req: PaymentRequirements, url: str) -> str:
57
+ # req.x402_version: 2 when the 402 had a PAYMENT-REQUIRED header, else 1
58
+ # req.payment_required: the decoded 402 object; req.accepts: scheme, network "eip155:4663",
59
+ # asset (USDG), amount (v2) / maxAmountRequired (v1), payTo, maxTimeoutSeconds, extra
60
+ # Check the amount and payTo, sign an EIP-3009 USDG transfer authorization with your x402 client,
61
+ # and return the header value for req.x402_version (the base64 payment payload).
62
+ ...
63
+
64
+ toll = Toll(sign=sign)
65
+ r = toll.check_coin(address)
66
+ if r.paid_url:
67
+ print(toll.pay(r.paid_url))
68
+ ```
69
+
70
+ Your key never touches this SDK. Without a `sign`, `pay` raises before any request. The JavaScript SDK's README has a worked `sign` built on `@priors/x402`. Docs: <https://t0ll.xyz/agents>, <https://t0ll.xyz/llms.txt>, <https://t0ll.xyz/agents/x402>.
71
+
72
+ MIT.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "toll-sdk"
7
+ version = "0.1.0"
8
+ description = "Client for Toll's coin checks (Robinhood Chain, Solana, BSC) over MCP. Standard library only."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Toll" }]
13
+ keywords = ["robinhood-chain", "solana", "bsc", "x402", "mcp", "ai-agent", "rug-check", "larp", "toll"]
14
+ dependencies = []
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Typing :: Typed",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://t0ll.xyz/agents"
30
+ Documentation = "https://t0ll.xyz/llms.txt"
31
+ Repository = "https://github.com/t0ll-xyz/toll-plugins"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/toll_sdk"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ pythonpath = ["src"]
@@ -0,0 +1,397 @@
1
+ """toll-sdk (import toll_sdk): client for Toll's remote MCP server (https://t0ll.xyz/mcp).
2
+
3
+ Information only, not financial advice.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import json
9
+ import re
10
+ import socket
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from dataclasses import dataclass
15
+ from typing import Any, Callable, Dict, List, Optional
16
+
17
+ __all__ = [
18
+ "PaymentRequirements",
19
+ "SignPayment",
20
+ "Toll",
21
+ "TollError",
22
+ "TollResult",
23
+ "is_valid_address",
24
+ "is_valid_wallet",
25
+ "parse_paid_url",
26
+ "parse_pending",
27
+ "read_requirements",
28
+ ]
29
+
30
+ _EVM = re.compile(r"0x[0-9a-fA-F]{40}")
31
+ _BASE58 = re.compile(r"[1-9A-HJ-NP-Za-km-z]{32,44}")
32
+ # Only the upsell line counts: a finished LARP answer also lists Toll's own x402 endpoint as a "working part".
33
+ _PAID_URL = re.compile(r"paid (?:LARP )?check of this coin \(x402\):\s*(https://t0ll\.xyz/api/x402/[^\s)\"'<>]+)", re.I)
34
+ _AGAIN = re.compile(r"again in (?:about )?(\d+)\s*(?:s\b|sec)", re.I)
35
+ _PAID_PREFIX = "https://t0ll.xyz/api/x402/"
36
+ _TIMEOUTS = (TimeoutError, socket.timeout)
37
+ _SETTLE_NOTE = (
38
+ " The payment may have settled: do not retry this call with a new signature;"
39
+ " resend the same payment header to get the answer."
40
+ )
41
+
42
+
43
+ class TollError(Exception):
44
+ """Any failure: HTTP, JSON-RPC, timeout, server-side input error, bad input."""
45
+
46
+ def __init__(
47
+ self,
48
+ message: str,
49
+ status: Optional[int] = None,
50
+ code: Optional[int] = None,
51
+ retry_after_s: Optional[int] = None,
52
+ ) -> None:
53
+ super().__init__(message)
54
+ self.status = status
55
+ self.code = code
56
+ self.retry_after_s = retry_after_s
57
+
58
+
59
+ @dataclass
60
+ class TollResult:
61
+ text: str
62
+ paid_url: Optional[str]
63
+ pending: Optional[Dict[str, int]] # {"retry_after_s": n}: part of the answer is still being read, ask again then
64
+
65
+
66
+ @dataclass
67
+ class PaymentRequirements:
68
+ """What a paid URL asks for, read from its 402 answer."""
69
+
70
+ x402_version: int
71
+ """2 when the 402 carried a PAYMENT-REQUIRED header (sent back as PAYMENT-SIGNATURE), else 1 (X-PAYMENT)."""
72
+ payment_required: Dict[str, Any]
73
+ """The decoded 402 object: base64 JSON of the PAYMENT-REQUIRED header (v2) or the JSON body (v1)."""
74
+ accepts: List[Dict[str, Any]]
75
+ """payment_required["accepts"]: scheme, network, asset, amount (v2) or maxAmountRequired (v1), payTo, ..."""
76
+
77
+
78
+ SignPayment = Callable[[PaymentRequirements, str], str]
79
+ """sign(requirements, url) -> the payment header value. pay() calls it at most once per call."""
80
+
81
+
82
+ def _clamp_seconds(value: Any, fallback: int) -> int:
83
+ """Seconds to wait, never below 1 (a 0 or junk value must not become a tight loop)."""
84
+ try:
85
+ n = float(value)
86
+ except (TypeError, ValueError):
87
+ return fallback
88
+ if n != n or n in (float("inf"), float("-inf")):
89
+ return fallback
90
+ return max(1, int(-(-n // 1)))
91
+
92
+
93
+ def is_valid_address(value: object) -> bool:
94
+ """Token address: 0x + 40 hex, or a Solana base58 mint (32-44 chars)."""
95
+ return isinstance(value, str) and bool(_EVM.fullmatch(value) or _BASE58.fullmatch(value))
96
+
97
+
98
+ def is_valid_wallet(value: object) -> bool:
99
+ """Wallet address: 0x + 40 hex."""
100
+ return isinstance(value, str) and bool(_EVM.fullmatch(value))
101
+
102
+
103
+ def parse_paid_url(text: str) -> Optional[str]:
104
+ m = _PAID_URL.search(text)
105
+ return m.group(1).rstrip(".,;:") if m else None
106
+
107
+
108
+ def parse_pending(text: str) -> Optional[Dict[str, int]]:
109
+ m = _AGAIN.search(text)
110
+ return {"retry_after_s": _clamp_seconds(m.group(1), 5)} if m else None
111
+
112
+
113
+ def _check_address(value: object, what: str = "address") -> None:
114
+ if not is_valid_address(value):
115
+ raise TollError(f"Invalid {what}: expected 0x + 40 hex characters or a Solana base58 mint (32-44 characters).")
116
+
117
+
118
+ def _check_wallet(value: object) -> None:
119
+ if not is_valid_wallet(value):
120
+ raise TollError("Invalid wallet: expected 0x + 40 hex characters.")
121
+
122
+
123
+ def _check_chain(chain: Optional[str], allowed: tuple) -> None:
124
+ if chain is not None and chain not in allowed:
125
+ raise TollError(f'Invalid chain "{chain}": use {", ".join(allowed)}.')
126
+
127
+
128
+ def _parse_body(raw: str) -> Any:
129
+ t = raw.strip()
130
+ if t.startswith("{"):
131
+ return json.loads(t)
132
+ for line in t.splitlines():
133
+ if line.startswith("data:"):
134
+ return json.loads(line[5:].strip())
135
+ raise ValueError("no data")
136
+
137
+
138
+ class Toll:
139
+ """Client. `opener` is a urllib OpenerDirector (or anything with .open(request, timeout=...))."""
140
+
141
+ def __init__(
142
+ self,
143
+ base_url: str = "https://t0ll.xyz",
144
+ opener: Any = None,
145
+ timeout: float = 30.0,
146
+ sleep: Callable[[float], None] = time.sleep,
147
+ sign: Optional[SignPayment] = None,
148
+ pay_timeout: float = 300.0,
149
+ ) -> None:
150
+ self.base_url = base_url.rstrip("/")
151
+ self._opener = opener or urllib.request.build_opener()
152
+ self.timeout = timeout
153
+ self.pay_timeout = pay_timeout
154
+ self._sign = sign
155
+ self._sleep = sleep
156
+ self._next_id = 1
157
+
158
+ # -- transport ---------------------------------------------------------
159
+ def _http(self, req: urllib.request.Request, timeout: float, note: str = "") -> tuple:
160
+ """One request, body read inside the timeout. Returns (status, headers, text); HTTP errors are returned too."""
161
+ try:
162
+ with self._opener.open(req, timeout=timeout) as res:
163
+ raw = res.read().decode("utf-8", "replace")
164
+ return int(getattr(res, "status", 200) or 200), getattr(res, "headers", None) or {}, raw
165
+ except urllib.error.HTTPError as e:
166
+ try:
167
+ raw = e.read().decode("utf-8", "replace") if hasattr(e, "read") else ""
168
+ except Exception:
169
+ raw = ""
170
+ return int(e.code), e.headers or {}, raw
171
+ except _TIMEOUTS:
172
+ raise TollError(f"Request to {req.full_url} timed out after {timeout:g} s.{note}") from None
173
+ except urllib.error.URLError as e:
174
+ if isinstance(e.reason, _TIMEOUTS):
175
+ raise TollError(f"Request to {req.full_url} timed out after {timeout:g} s.{note}") from None
176
+ raise TollError(f"Request to {req.full_url} failed: {e}.{note}") from None
177
+ except OSError as e:
178
+ raise TollError(f"Request to {req.full_url} failed: {e}.{note}") from None
179
+
180
+ def _rpc(self, method: str, params: Dict[str, Any]) -> Any:
181
+ payload = json.dumps({"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params}).encode()
182
+ self._next_id += 1
183
+ req = urllib.request.Request(
184
+ f"{self.base_url}/mcp",
185
+ data=payload,
186
+ method="POST",
187
+ headers={"content-type": "application/json", "accept": "application/json, text/event-stream"},
188
+ )
189
+ status, _headers, raw = self._http(req, self.timeout)
190
+ if not 200 <= status < 300:
191
+ raise TollError(f"Toll answered HTTP {status}" + (f": {raw[:200]}" if raw else ""), status=status)
192
+ try:
193
+ body = _parse_body(raw)
194
+ except ValueError:
195
+ raise TollError("Toll sent an answer that is not JSON-RPC.") from None
196
+ if isinstance(body, dict) and body.get("error"):
197
+ err = body["error"]
198
+ raise TollError(f"Toll JSON-RPC error {err.get('code')}: {err.get('message')}", code=err.get("code"))
199
+ return body.get("result") if isinstance(body, dict) else None
200
+
201
+ # -- tools -------------------------------------------------------------
202
+ def tools(self) -> List[Dict[str, Any]]:
203
+ r = self._rpc("tools/list", {})
204
+ return (r or {}).get("tools", [])
205
+
206
+ def call(self, name: str, args: Optional[Dict[str, Any]] = None) -> TollResult:
207
+ """Call any tool by its server name. isError answers raise TollError."""
208
+ r = self._rpc("tools/call", {"name": name, "arguments": args or {}}) or {}
209
+ content = r.get("content")
210
+ text = "\n".join(str(c.get("text", "")) for c in content if isinstance(c, dict) and c.get("type") == "text") if isinstance(content, list) else ""
211
+ if r.get("isError"):
212
+ p = parse_pending(text)
213
+ raise TollError(text.split("\n\n")[0] or "Toll reported an error.", retry_after_s=p["retry_after_s"] if p else None)
214
+ pending = parse_pending(text) if re.search(r"still reading", text, re.I) else None
215
+ return TollResult(text=text, paid_url=parse_paid_url(text), pending=pending)
216
+
217
+ def check_coin(self, address: str, chain: Optional[str] = None) -> TollResult:
218
+ _check_address(address)
219
+ _check_chain(chain, ("rh", "sol", "bsc"))
220
+ return self.call("check_coin", {"address": address, **({"chain": chain} if chain else {})})
221
+
222
+ def larp_check(self, token: str, chain: Optional[str] = None, wait: bool = False) -> TollResult:
223
+ """LARP check.
224
+
225
+ wait=True asks again while part of the answer is still being read, or while Toll answers that it is
226
+ busy with many new LARP checks (a TollError with retry_after_s): max 3 tries, max 120 s waiting.
227
+ """
228
+ _check_address(token, "token")
229
+ _check_chain(chain, ("rh", "sol"))
230
+ args = {"token": token, **({"chain": chain} if chain else {})}
231
+ if not wait:
232
+ return self.call("larp_check", args)
233
+ waited = 0
234
+ tries = 0
235
+ while True:
236
+ tries += 1
237
+ res: Optional[TollResult] = None
238
+ busy: Optional[TollError] = None
239
+ try:
240
+ res = self.call("larp_check", args)
241
+ except TollError as e:
242
+ if e.retry_after_s is None:
243
+ raise
244
+ busy = e
245
+ if res is not None:
246
+ if not res.pending:
247
+ return res
248
+ s = res.pending["retry_after_s"]
249
+ else:
250
+ assert busy is not None and busy.retry_after_s is not None
251
+ s = busy.retry_after_s
252
+ if tries >= 3 or waited + s > 120:
253
+ if busy is not None:
254
+ raise busy
255
+ assert res is not None
256
+ return res
257
+ self._sleep(s)
258
+ waited += s
259
+
260
+ def dev_record(self, wallet: str, chain: Optional[str] = None) -> TollResult:
261
+ _check_wallet(wallet)
262
+ _check_chain(chain, ("rh", "bsc"))
263
+ return self.call("dev_record", {"wallet": wallet, **({"chain": chain} if chain else {})})
264
+
265
+ def launch_record(self, address: str) -> TollResult:
266
+ _check_wallet(address)
267
+ return self.call("launch_record", {"address": address})
268
+
269
+ def wallet_privacy(self, wallet: str) -> TollResult:
270
+ _check_wallet(wallet)
271
+ return self.call("wallet_privacy", {"wallet": wallet})
272
+
273
+ def fresh_launches(self, limit: Optional[int] = None) -> TollResult:
274
+ if limit is not None and (isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 20):
275
+ raise TollError("Invalid limit: use a whole number from 1 to 20.")
276
+ return self.call("fresh_launches", {} if limit is None else {"limit": limit})
277
+
278
+ def chain_pulse(self) -> TollResult:
279
+ return self.call("chain_pulse", {})
280
+
281
+ # -- paying ------------------------------------------------------------
282
+ def pay(self, paid_url: str, sign: Optional[SignPayment] = None, timeout: Optional[float] = None) -> Any:
283
+ """Pay a paid x402 URL (the paid_url of a result) and return its JSON.
284
+
285
+ 1. GET without payment: 402 with the requirements (v2 PAYMENT-REQUIRED header, else the v1 body).
286
+ 2. Call sign(requirements, url) ONCE for the payment header value.
287
+ 3. GET with it (v2: PAYMENT-SIGNATURE, v1: X-PAYMENT). On 402 {"pending": true} resend EXACTLY the
288
+ same header after Retry-After (at least 1 s), max 5 tries and 300 s of waiting. 200 JSON is the
289
+ answer; any other 402 after paying raises TollError "payment not accepted". It never signs twice.
290
+ The whole call is bounded by `timeout` (default pay_timeout, 300 s).
291
+ """
292
+ if not isinstance(paid_url, str) or not paid_url.startswith(_PAID_PREFIX):
293
+ raise TollError("pay() only takes https://t0ll.xyz/api/x402/... URLs.")
294
+ signer = sign or self._sign
295
+ if signer is None:
296
+ raise TollError("pay() needs a sign function: Toll(sign=...) or pay(url, sign=...). See the README (paying).")
297
+ total = self.pay_timeout if timeout is None else timeout
298
+ deadline = time.monotonic() + total
299
+
300
+ def left() -> float:
301
+ return max(0.001, deadline - time.monotonic())
302
+
303
+ def get(headers: Dict[str, str], note: str = "") -> tuple:
304
+ req = urllib.request.Request(paid_url, method="GET", headers={"accept": "application/json", **headers})
305
+ return self._http(req, left(), note)
306
+
307
+ # 1. Unpaid GET: read what to pay.
308
+ status, headers, raw = get({})
309
+ if 200 <= status < 300:
310
+ return _json_answer(raw, status)
311
+ if status != 402:
312
+ raise _http_error(status, raw)
313
+ requirements = read_requirements(headers, raw)
314
+
315
+ # 2. One signature for this call.
316
+ try:
317
+ header = signer(requirements, paid_url)
318
+ except Exception as e:
319
+ raise TollError(f"sign() failed, nothing was sent: {e}") from None
320
+ if not isinstance(header, str) or not header.strip() or "\r" in header or "\n" in header:
321
+ raise TollError("sign() must return the payment header value as one non-empty line; nothing was sent.")
322
+ name = "PAYMENT-SIGNATURE" if requirements.x402_version == 2 else "X-PAYMENT"
323
+
324
+ # 3. Paid GET, resending the same header while the answer is pending.
325
+ waited = 0
326
+ tries = 0
327
+ while True:
328
+ tries += 1
329
+ if time.monotonic() >= deadline:
330
+ raise TollError(f"pay() timed out after {total:g} s.{_SETTLE_NOTE}")
331
+ status, headers, raw = get({name: header}, _SETTLE_NOTE)
332
+ data = _try_json(raw)
333
+ if status == 402 and isinstance(data, dict) and data.get("pending") is True:
334
+ s = _clamp_seconds(_header(headers, "retry-after"), 5)
335
+ if tries >= 5 or waited + s > 300 or time.monotonic() + s > deadline:
336
+ raise TollError(
337
+ f"The paid answer is still being read after {tries} tries (nothing is charged while it is pending)."
338
+ " Resend the same payment header later; do not sign a new one for this call.",
339
+ status=402,
340
+ retry_after_s=s,
341
+ )
342
+ self._sleep(s)
343
+ waited += s
344
+ continue
345
+ if status == 402:
346
+ why = f" ({data['error']})" if isinstance(data, dict) and isinstance(data.get("error"), str) else ""
347
+ raise TollError(f"payment not accepted: Toll answered 402 to the signed payment{why}. pay() does not sign again.", status=402)
348
+ if not 200 <= status < 300:
349
+ raise _http_error(status, raw, _SETTLE_NOTE)
350
+ return _json_answer(raw, status)
351
+
352
+
353
+ def read_requirements(headers: Any, raw: str) -> PaymentRequirements:
354
+ """Read a 402: v2 from the PAYMENT-REQUIRED header when it decodes, else v1 from the JSON body."""
355
+ h = _header(headers, "payment-required")
356
+ if h:
357
+ try:
358
+ pr = json.loads(base64.b64decode(h.strip()).decode("utf-8"))
359
+ if isinstance(pr, dict) and isinstance(pr.get("accepts"), list) and pr["accepts"]:
360
+ return PaymentRequirements(x402_version=2, payment_required=pr, accepts=pr["accepts"])
361
+ except (ValueError, UnicodeDecodeError):
362
+ pass
363
+ body = _try_json(raw)
364
+ if isinstance(body, dict) and isinstance(body.get("accepts"), list) and body["accepts"]:
365
+ return PaymentRequirements(x402_version=1, payment_required=body, accepts=body["accepts"])
366
+ raise TollError("Toll answered 402 without x402 payment requirements; nothing was signed.", status=402)
367
+
368
+
369
+ def _try_json(raw: str) -> Any:
370
+ try:
371
+ return json.loads(raw) if raw else None
372
+ except ValueError:
373
+ return None
374
+
375
+
376
+ def _json_answer(raw: str, status: int) -> Any:
377
+ data = _try_json(raw)
378
+ if data is None:
379
+ raise TollError("The paid answer was not JSON.", status=status)
380
+ return data
381
+
382
+
383
+ def _http_error(status: int, raw: str, note: str = "") -> TollError:
384
+ return TollError(f"Toll answered HTTP {status}" + (f": {raw[:200]}" if raw else "") + "." + note, status=status)
385
+
386
+
387
+ def _header(headers: Any, name: str) -> Optional[str]:
388
+ if not headers:
389
+ return None
390
+ get = getattr(headers, "get", None)
391
+ if get is not None and hasattr(headers, "get_all"): # http.client.HTTPMessage: case-insensitive
392
+ v = get(name)
393
+ return None if v is None else str(v)
394
+ for k in headers:
395
+ if str(k).lower() == name:
396
+ return str(headers[k])
397
+ return None
File without changes
@@ -0,0 +1 @@
1
+ {"result":{"content":[{"type":"text","text":"Robinhood Chain, right now, as our engine counts it.\n\n- Launches, last 5 min: 1 (Pons)\n- Launches, last hour: 40 (Pons and Long)\n- Of those, graduated: 0 (moved from the curve to a pool)\n- Launches by launchpad, last hour: Pons 27, Long 13; and, not in that total, pools.trade 1\n- Trades on the chain, last minute: 434 (every Uniswap v4 swap)\n- Trades we price, last minute: 214 (the pools we follow plus launch-curve trades)\n- Volume in the pools we follow, only 40 min so far, not a full hour: $3.55M\n- Volume on every venue, last 20 min: $5.37M\n\nRobinhood Chain only. Counted live from the chain by our engine; a figure it did not send is left out, never shown as zero.\n\nLive terminal: https://t0ll.xyz/terminal?utm_source=mcp"}]},"jsonrpc":"2.0","id":1}
@@ -0,0 +1 @@
1
+ {"result":{"content":[{"type":"text","text":"$TOLL (Toll), a Robinhood Chain token\nDeclared bundle of 1 wallet · 8 flagged wallets bought 5.9% · dev holds 1.2%\nToken: 0xff9e86e6b21fd688027981bee4b64c8b6b804ed3\nLaunched on Pons, 22 days ago. Paired with ETH. On its curve, 25% to graduation.\nMaker: 0x5388872a6bbb5fd94533a4dab2a7a1cab02fa5bc\n\nFindings: 2 against, 1 careful, 5 clear\nCoins that launched like this one (bundle under 1%, snipers under 1%, dev's first launch): 54% dead within 24 h, 0% graduated · 4870 coins, last 30 days\nAt launch:\n- against: 3 wallets sold in the same tenth of a second by the same route, 18 min after launch: 7.7% of supply in one block. 3 of them moved together more than once\n- against: 5 wallets bought in the same tenth of a second by the same route, 7 min after launch: 5.88% of supply in one block\n- careful: first launch from this wallet; who funded it was not read in time\n- clear: the only wallet exempt from the opening tax is the dev's own\n- clear: nobody but the dev bought in the first 3 s\n- clear: 3.24% of supply burned\n- clear: socials declared at launch\n- clear: creator fees go to a Toll fee vault: 90% to the wallet 0x5388…a5bc, 10% buys back and burns $TOLL\n\nNow:\n- Still reading who holds it now: ask again in 20 s.\n\nFigures:\n- Maker's launches: 1 launch\n- Buyers on the curve: at least 296\n- Wallets exempt from tax: 1\n- Flagged wallets: 8, bought 5.9%\n- Creator tax: 1%\n- In the curve: $2k\n- Maker holds now: 1.2% of supply\n- Burned: 3.2% of supply\n\nRead-out:\n- This is the first coin we can find from the wallet that made it.\n- No wallet besides the maker was exempt from the opening tax.\n- The maker has not sold any of its own buy.\n- At least 296 wallets have bought on the curve. It is 25% of the way to graduating.\n\nCould not read: the dev's funder.\nRobinhood Chain only. Read from the chain at block 99,050,751. What it means is yours to decide.\n\nFull report: https://t0ll.xyz/check/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3?utm_source=mcp"}]},"jsonrpc":"2.0","id":1}
@@ -0,0 +1 @@
1
+ {"result": {"content": [{"type": "text", "text": "free checks for today are used (10 coins). unlimited: hold 1M $TOLL, or pay per check with x402 at t0ll.xyz/api/x402.\n\npaid check of this coin (x402): https://t0ll.xyz/api/x402/check/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3?chain=rh\nhow to pay: GET it, sign the 0.05 USDG payment on Robinhood Chain that the 402 answer asks for, and send it in the PAYMENT-SIGNATURE header; charged only for a whole answer."}]}, "jsonrpc": "2.0", "id": 1}
@@ -0,0 +1 @@
1
+ {"result":{"content":[{"type":"text","text":"LARP check of $TOLL on Robinhood Chain: product found.\n2 claims read from its site. Four different working parts of it answered our own reads, among them: its API at t0ll.xyz/api/check answered with data (fields token, sym, name, supply); its MCP server at t0ll.xyz/mcp answered and listed 7 tools (check_coin, dev_record, wallet_privacy, fresh_launches).\nRead 2026-10-26 16:57 UTC.\n\nWhat it says, and what we found:\n- found: \"Read from the chain for any coin on Robinhood Chain, Solana, BSC and Arc. Free.\" (its site). its API at t0ll.xyz/api/check answered with data (fields token, sym, name, supply); its MCP server at t0ll.xyz/mcp answered and listed 7 tools (check_coin, dev_record, wallet_privacy, fresh_launches)\n- found: \"131 coins checked 20 launchpads 53% dead within 24 h\" (its site). its API at t0ll.xyz/api/check answered with data (fields token, sym, name, supply); its MCP server at t0ll.xyz/mcp answered and listed 7 tools (check_coin, dev_record, wallet_privacy, fresh_launches)\n\nWorking parts we tested:\n- API: answered. its API at t0ll.xyz/api/check answered with data (fields token, sym, name, supply) (https://t0ll.xyz/api/check?addr=0xff9e86e6b21fd688027981bee4b64c8b6b804ed3)\n- MCP: answered. its MCP server at t0ll.xyz/mcp answered and listed 7 tools (check_coin, dev_record, wallet_privacy, fresh_launches) (https://t0ll.xyz/mcp)\n- x402: answered. its x402 endpoint at t0ll.xyz/api/x402/larp/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3 asks 0.05 USDG on Robinhood Chain; 0 payments in 3 days (chain) (https://t0ll.xyz/api/x402/larp/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3)\n- Chrome extension: answered. its Chrome extension “Toll: coin check for Solana, Robinhood Chain and Arc” is listed on the Chrome Web Store (https://chromewebstore.google.com/detail/aclfobbnomcddmcldacomchepinloeeh)\n- Telegram bot: exists. its Telegram bot @t0ll_bot exists, no monthly users shown (https://t.me/t0ll_bot)\n- X account @Tollxyz_: exists (an account is not a working part; it backs no claim).\n- The page we read: https://t0ll.xyz/ (declared at launch).\n\nThe claims are their own words, quoted by Claude Haiku 5.5; Claude Sonnet 5.5 checked each result against our evidence and Claude Opus 5.5 had the final say; every check of them is our own read, and chain facts come from the chain.\nFacts, not a verdict: a quiet contract can be a product that launched yesterday, and a busy one can still lose you money. Not advice.\nHolders of 1M $TOLL get re-reads, the second reader's notes and the dev's full record.\n\nFull LARP check: https://t0ll.xyz/larp/rh/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3?utm_source=mcp"}]},"jsonrpc":"2.0","id":1}
@@ -0,0 +1 @@
1
+ {"result": {"content": [{"type": "text", "text": "Many new LARP checks are being asked right now. Nothing is wrong with the address. Try again in about 20 seconds.\n\nFull LARP check: https://t0ll.xyz/larp/rh/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3?utm_source=mcp"}], "isError": true}, "jsonrpc": "2.0", "id": 1}
@@ -0,0 +1 @@
1
+ {"result": {"content": [{"type": "text", "text": "free checks for today are used (10 coins). unlimited: hold 1M $TOLL, or pay per check with x402 at t0ll.xyz/api/x402.\n\npaid LARP check of this coin (x402): https://t0ll.xyz/api/x402/larp/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3?chain=rh\nhow to pay: GET it, sign the 0.05 USDG payment on Robinhood Chain that the 402 answer asks for, and send it in the PAYMENT-SIGNATURE header; charged only for a whole answer."}]}, "jsonrpc": "2.0", "id": 1}
@@ -0,0 +1 @@
1
+ {"result": {"content": [{"type": "text", "text": "Still reading 0xff9e86e6b21fd688027981bee4b64c8b6b804ed3 on Robinhood Chain: it is in the LARP check queue with 2 ahead. The read keeps going on our side.\nCall larp_check again and try again in about 30 seconds.\n\nFull LARP check: https://t0ll.xyz/larp/rh/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3?utm_source=mcp"}]}, "jsonrpc": "2.0", "id": 1}
@@ -0,0 +1 @@
1
+ {"result":{"tools":[{"name":"check_coin","title":"Check a coin","description":"Check one token on Robinhood Chain, Arc, BSC or Solana from the chain itself: who is behind it and how it launched. On Robinhood Chain: where and when it launched, the maker (dev) and its launch record, snipers in the first 3 seconds, wallets the launch made exempt from tax (bundles), flagged wallets, what the maker holds and sold, burned supply, the curve or pool. On Arc, BSC: the launch, the dev's record, the first buyers, wallets that moved together, who holds the liquidity and whether it can be pulled, and the market. On BSC (four.meme and flap.sh coins): also how far along its curve it is and the tax the token declares. On Solana (pump.fun coins): mint and freeze authority, metadata, Token-2022 extensions, the creation slot and first 3 seconds, the dev's launch buy and whether it sold, its other pump.fun coins, the curve or graduation, the top holders and who first funded them. Toll's findings are tallied as against / careful / clear. The answer's first line names the chain. Reads Robinhood Chain (4663), Arc (5042), BSC (56) and Solana.","inputSchema":{"type":"object","properties":{"address":{"type":"string","pattern":"^(0x[0-9a-fA-F]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$","description":"The token contract address (a Solana mint address works too): 0x followed by 40 hex characters, or a Solana mint address (base58, case-sensitive)"},"chain":{"type":"string","enum":["rh","arc","bsc","sol"],"description":"Which chain: rh = Robinhood Chain (4663), arc = Arc (5042), bsc = BSC (56), sol = Solana. Leave it out and Toll finds which chain the token is on."}},"required":["address"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"annotations":{"title":"Check a coin","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true},"execution":{"taskSupport":"forbidden"}},{"name":"larp_check","title":"LARP check","description":"LARP check: is there a real product behind a coin on Robinhood Chain or Solana? Toll reads the coin's own site, launch post and code README, lists each thing it says it offers, and checks every claim against the chain and against its working parts that Toll tries itself (app page, API, MCP server, x402 payments, Chrome extension, Telegram bot, public code). Answers the label (product found / partly found / not found / not read: what we could check, never a verdict), each claim with found / partly / not found and the line that shows it, and the working parts that answered. A coin nobody asked about yet is queued and read within minutes: the answer says how many seconds to wait before calling again. Free: 10 new coins a day per caller, coins already read are always free.","inputSchema":{"type":"object","properties":{"token":{"type":"string","pattern":"^(0x[0-9a-fA-F]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$","description":"The coin: its token contract on Robinhood Chain, or its Solana mint: 0x followed by 40 hex characters, or a Solana mint address (base58, case-sensitive)"},"chain":{"type":"string","enum":["rh","sol"],"description":"Which chain: rh = Robinhood Chain, sol = Solana. Leave it out and Toll tells them apart by the address (0x is Robinhood Chain, base58 is Solana)."}},"required":["token"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"annotations":{"title":"LARP check","readOnlyHint":true,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},"execution":{"taskSupport":"forbidden"}},{"name":"dev_record","title":"Deployer record","description":"Every coin one wallet launched on Robinhood Chain, Arc or BSC. On Robinhood Chain (Pons and Long launchpads): how many, how many graduated from the bonding curve, whether the dev sold, and the latest launches with how each ended. On Arc, BSC: how many it launched through the launchpads we know and which of them it still holds. Reads Robinhood Chain unless chain names another.","inputSchema":{"type":"object","properties":{"wallet":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$","description":"The deployer (dev) wallet address: 0x followed by 40 hex characters"},"chain":{"type":"string","enum":["rh","arc","bsc"],"description":"Which chain: rh = Robinhood Chain (4663), arc = Arc (5042), bsc = BSC (56). Leave it out for Robinhood Chain."}},"required":["wallet"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"annotations":{"title":"Deployer record","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true},"execution":{"taskSupport":"forbidden"}},{"name":"wallet_privacy","title":"Wallet privacy check","description":"What anyone can learn about a wallet from Robinhood Chain alone: a privacy grade (A to F), a score out of 100, and each leak found (who funded it, linked wallets, holdings, active hours, launches, trading record, linked accounts) with tips. The grade measures what anyone can see, not whether the wallet is good or bad. Robinhood Chain only.","inputSchema":{"type":"object","properties":{"wallet":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$","description":"The wallet address on Robinhood Chain: 0x followed by 40 hex characters"}},"required":["wallet"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"annotations":{"title":"Wallet privacy check","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true},"execution":{"taskSupport":"forbidden"}},{"name":"fresh_launches","title":"Newest coins","description":"The newest coins launched on Robinhood Chain, newest first, from Toll's live engine: launchpad, age, pair, and the engine's short notes on each (dev record, first buyers, buyers, curve), with a link to the full check. At most 20. Robinhood Chain only.","inputSchema":{"type":"object","properties":{"limit":{"type":"integer","minimum":1,"maximum":20,"description":"How many coins, 1 to 20 (default 10)"}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"annotations":{"title":"Newest coins","readOnlyHint":true,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},"execution":{"taskSupport":"forbidden"}},{"name":"chain_pulse","title":"Chain pulse","description":"How busy Robinhood Chain is right now, as Toll's live engine counts it: launches in the last 5 minutes and hour, graduations, launches by launchpad, new pools, trades a minute and volume. Robinhood Chain only.","inputSchema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{}},"annotations":{"title":"Chain pulse","readOnlyHint":true,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},"execution":{"taskSupport":"forbidden"}},{"name":"launch_record","title":"Call at launch","description":"The call Toll made 3 minutes after a Robinhood Chain coin launched, frozen and never edited: against / careful / clear (or launch facts only), the findings tallied, why, the dev's and funder's other coins of the week, and how it ended 24 hours later. Not the coin now: use check_coin for that. Robinhood Chain only.","inputSchema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$","description":"The token contract address on Robinhood Chain: 0x followed by 40 hex characters"}},"required":["address"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"annotations":{"title":"Call at launch","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true},"execution":{"taskSupport":"forbidden"}}]},"jsonrpc":"2.0","id":1}
@@ -0,0 +1,9 @@
1
+ {
2
+ "_note": "Real unpaid GET https://t0ll.xyz/api/x402/check/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3 on 2026-10-27 (no payment header sent).",
3
+ "status": 402,
4
+ "headers": {
5
+ "payment-required": "eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly90MGxsLnh5ei9hcGkveDQwMi9jaGVjay8weGZmOWU4NmU2YjIxZmQ2ODgwMjc5ODFiZWU0YjY0YzhiNmI4MDRlZDMiLCJkZXNjcmlwdGlvbiI6IkNvaW4gY2hlY2sgZm9yIGFueSB0b2tlbiBvbiBSb2Jpbmhvb2QgQ2hhaW4sIFNvbGFuYSwgQlNDIG9yIEFyYzogc25pcGVycyBpbiB0aGUgZmlyc3QgMyBzLCBidW5kbGVzLCB3aG8gZnVuZGVkIHRoZSBkZXYsIHRoZSBkZXYncyBwYXN0IGxhdW5jaGVzLCBsaXF1aWRpdHkuIFJlcGxhY2Uge3Rva2VufSB3aXRoIGFueSBhZGRyZXNzOyA/Y2hhaW49cmh8c29sfGJzY3xhcmMiLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJzZXJ2aWNlTmFtZSI6IlRvbGwgY29pbiBjaGVjayBBUEkiLCJ0YWdzIjpbInJ1Zy1jaGVjayIsIm1lbWVjb2luIiwiZHVlLWRpbGlnZW5jZSIsInNvbGFuYSIsInJvYmluaG9vZC1jaGFpbiJdLCJpY29uVXJsIjoiaHR0cHM6Ly90MGxsLnh5ei9hcHBsZS1pY29uLnBuZyJ9LCJhY2NlcHRzIjpbeyJzY2hlbWUiOiJleGFjdCIsIm5ldHdvcmsiOiJlaXAxNTU6NDY2MyIsImFzc2V0IjoiMHg1ZmM1MzYwRDA0MDBhMEZkNGYyYWY1NTJBREQwNDJENzE2RjFkMTY4IiwiYW1vdW50IjoiNTAwMDAiLCJwYXlUbyI6IjB4ZEMzNkQ1MTk2M2QwODg2MmM1MjUwNDVjY0ZEMzlGQWU2YTBDN0Y0MCIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImV4dHJhIjp7Im5hbWUiOiJHbG9iYWwgRG9sbGFyIiwidmVyc2lvbiI6IjEifX1dLCJleHRlbnNpb25zIjp7ImJhemFhciI6eyJpbmZvIjp7ImlucHV0Ijp7InR5cGUiOiJodHRwIiwibWV0aG9kIjoiR0VUIiwicGF0aFBhcmFtcyI6eyJ0b2tlbiI6IjB4ZmY5ZTg2ZTZiMjFmZDY4ODAyNzk4MWJlZTRiNjRjOGI2YjgwNGVkMyJ9LCJxdWVyeVBhcmFtcyI6e319LCJvdXRwdXQiOnsidHlwZSI6Impzb24iLCJleGFtcGxlIjp7InRva2VuIjoiMHgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwic3ltIjoiRVhBTVBMRSIsImhlYWRsaW5lIjoiKHRoZSBvbmUtbGluZSBzdW1tYXJ5IG9mIHRoZSBsYXVuY2gsIHRoZSBMUCBhbmQgdGhlIGhvbGRlcnMpIiwiZmxhZ3MiOlt7Imdvb2QiOmZhbHNlLCJ0ZXh0IjoiKG9uZSBmaW5kaW5nKSJ9XSwidW5yZWFkIjpbXX19fSwicm91dGVUZW1wbGF0ZSI6Ii9hcGkveDQwMi9jaGVjay86dG9rZW4iLCJzY2hlbWEiOnsiJHNjaGVtYSI6Imh0dHBzOi8vanNvbi1zY2hlbWEub3JnL2RyYWZ0LzIwMjAtMTIvc2NoZW1hIiwidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsiaW5wdXQiOnsidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsidHlwZSI6eyJ0eXBlIjoic3RyaW5nIiwiY29uc3QiOiJodHRwIn0sIm1ldGhvZCI6eyJ0eXBlIjoic3RyaW5nIiwiZW51bSI6WyJHRVQiXX0sInBhdGhQYXJhbXMiOnsidHlwZSI6Im9iamVjdCIsInByb3BlcnRpZXMiOnsidG9rZW4iOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeKDB4WzAtOWEtZkEtRl17NDB9fFsxLTlBLUhKLU5QLVphLWttLXpdezMyLDQ0fSkkIiwiZGVzY3JpcHRpb24iOiJhbnkgdG9rZW4gYWRkcmVzczogMHggKyA0MCBoZXggKFJvYmluaG9vZCBDaGFpbiwgQlNDLCBBcmMpIG9yIGEgU29sYW5hIG1pbnQgKGJhc2U1OCkifX0sInJlcXVpcmVkIjpbInRva2VuIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX0sInF1ZXJ5UGFyYW1zIjp7InR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImNoYWluIjp7InR5cGUiOiJzdHJpbmciLCJlbnVtIjpbInJoIiwic29sIiwiYnNjIiwiYXJjIl0sImRlc2NyaXB0aW9uIjoidGhlIGNvaW4ncyBjaGFpbjogcmggPSBSb2Jpbmhvb2QgQ2hhaW4gKGRlZmF1bHQgZm9yIDB4KSwgc29sID0gU29sYW5hIChkZWZhdWx0IGZvciBiYXNlNTgpLCBic2MgPSBCU0MsIGFyYyA9IEFyYyJ9fSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlfX0sInJlcXVpcmVkIjpbInR5cGUiLCJtZXRob2QiLCJwYXRoUGFyYW1zIl0sImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZX0sIm91dHB1dCI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJ0eXBlIjp7InR5cGUiOiJzdHJpbmcifSwiZXhhbXBsZSI6eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJjaGFpbiI6eyJ0eXBlIjoic3RyaW5nIn0sInRva2VuIjp7InR5cGUiOiJzdHJpbmcifSwic3ltIjp7InR5cGUiOiJzdHJpbmcifSwiaGVhZGxpbmUiOnsidHlwZSI6WyJzdHJpbmciLCJudWxsIl19LCJsYXVuY2giOnsidHlwZSI6WyJvYmplY3QiLCJudWxsIl19LCJkZXYiOnsidHlwZSI6WyJvYmplY3QiLCJudWxsIl19LCJkZXZSZWNvcmQiOnsidHlwZSI6WyJvYmplY3QiLCJudWxsIl19LCJjdXJ2ZSI6eyJ0eXBlIjpbIm9iamVjdCIsIm51bGwiXX0sImVhcmx5Ijp7InR5cGUiOlsib2JqZWN0IiwibnVsbCJdfSwiaG9sZGluZ3MiOnsidHlwZSI6Im9iamVjdCJ9LCJob2xkZXJzIjp7InR5cGUiOlsib2JqZWN0IiwibnVsbCJdfSwibHAiOnsidHlwZSI6WyJhcnJheSIsIm9iamVjdCIsIm51bGwiXX0sIm1hcmtldCI6eyJ0eXBlIjpbIm9iamVjdCIsIm51bGwiXX0sImZsYWdzIjp7InR5cGUiOiJhcnJheSJ9LCJmaW5kaW5ncyI6eyJ0eXBlIjoiYXJyYXkifSwibm93Ijp7InR5cGUiOiJvYmplY3QifSwidW5yZWFkIjp7InR5cGUiOiJhcnJheSJ9LCJmYWlsZWQiOnsidHlwZSI6ImFycmF5In0sInJlYWRfdHMiOnsidHlwZSI6Im51bWJlciJ9LCJjaGFyZ2VkIjp7InR5cGUiOiJib29sZWFuIn19LCJhZGRpdGlvbmFsUHJvcGVydGllcyI6dHJ1ZX19LCJyZXF1aXJlZCI6WyJ0eXBlIl19fSwicmVxdWlyZWQiOlsiaW5wdXQiXX19fX0=",
6
+ "content-type": "application/json"
7
+ },
8
+ "body": "{\"x402Version\":1,\"error\":\"Payment required\",\"accepts\":[{\"scheme\":\"exact\",\"network\":\"robinhood\",\"maxAmountRequired\":\"50000\",\"resource\":\"https://t0ll.xyz/api/x402/check/0xff9e86e6b21fd688027981bee4b64c8b6b804ed3\",\"description\":\"Coin check for any token on Robinhood Chain, Solana, BSC or Arc: snipers in the first 3 s, bundles, who funded the dev, the dev's past launches, liquidity. Replace {token} with any address; ?chain=rh|sol|bsc|arc\",\"mimeType\":\"application/json\",\"payTo\":\"0xdC36D51963d08862c525045ccFD39FAe6a0C7F40\",\"maxTimeoutSeconds\":300,\"asset\":\"0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168\",\"outputSchema\":{\"input\":{\"type\":\"http\",\"method\":\"GET\",\"discoverable\":true,\"resourceTemplate\":\"https://t0ll.xyz/api/x402/check/{token}\",\"pathParams\":{\"token\":{\"type\":\"string\",\"pattern\":\"^(0x[0-9a-fA-F]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$\",\"description\":\"any token address: 0x + 40 hex (Robinhood Chain, BSC, Arc) or a Solana mint (base58)\",\"required\":true}},\"queryParams\":{\"chain\":{\"type\":\"string\",\"enum\":[\"rh\",\"sol\",\"bsc\",\"arc\"],\"description\":\"the coin's chain: rh = Robinhood Chain (default for 0x), sol = Solana (default for base58), bsc = BSC, arc = Arc\"}}},\"output\":{\"type\":\"object\",\"description\":\"The same JSON as https://t0ll.xyz/api/check?addr=...&chain=... (Robinhood Chain answers carry flags/curve/holdings/now; Arc, BSC and Solana answers carry chain/findings/holders/lp/failed).\",\"properties\":{\"chain\":{\"type\":\"string\",\"description\":\"the chain read: arc, bsc or sol (absent on a Robinhood Chain answer)\"},\"token\":{\"type\":\"string\",\"description\":\"the token address\"},\"sym\":{\"type\":\"string\",\"description\":\"the ticker (Robinhood Chain; meta.sym elsewhere)\"},\"headline\":{\"type\":[\"string\",\"null\"],\"description\":\"one line: the launch facts, the LP and the holder cluster\"},\"launch\":{\"type\":[\"object\",\"null\"],\"description\":\"where and when it launched: launchpad, block, age, pair, dev\"},\"dev\":{\"type\":[\"object\",\"null\"],\"description\":\"the dev wallet's record: launches, graduated, partial (Robinhood Chain)\"},\"devRecord\":{\"type\":[\"object\",\"null\"],\"description\":\"the dev wallet's past launches (Arc, BSC, Solana)\"},\"curve\":{\"type\":[\"object\",\"null\"],\"description\":\"the bonding curve: snipers in the first 3 s, bundle_pct, dev bought/sold, progress, graduated\"},\"early\":{\"type\":[\"object\",\"null\"],\"description\":\"the first seconds: first buyers, snipers, wallets that moved together\"},\"holdings\":{\"type\":\"object\",\"description\":\"what the dev holds, burned supply\"},\"holders\":{\"type\":[\"object\",\"null\"],\"description\":\"top holders and who first funded them\"},\"lp\":{\"type\":[\"array\",\"object\",\"null\"],\"description\":\"who holds the liquidity and whether it can be pulled\"},\"market\":{\"type\":[\"object\",\"null\"],\"description\":\"mcap, liquidity, 24 h volume (DexScreener)\"},\"flags\":{\"type\":\"array\",\"description\":\"Robinhood Chain findings: { good, note, text }\"},\"findings\":{\"type\":\"array\",\"description\":\"Arc / BSC / Solana findings: { level: against|careful|clear, text }\"},\"now\":{\"type\":\"object\",\"description\":\"Robinhood Chain: who holds it now, which holders share a funder, the last 24 h\"},\"unread\":{\"type\":\"array\",\"description\":\"Robinhood Chain: the parts that could not be read\"},\"failed\":{\"type\":\"array\",\"description\":\"Arc / BSC / Solana: the sections that could not be read\"},\"read_ts\":{\"type\":\"number\",\"description\":\"when the chain was read, unix seconds\"},\"charged\":{\"type\":\"boolean\",\"description\":\"false only on a partial answer given free (then why: \\\"partial\\\" and missing: [...])\"}},\"additionalProperties\":true}},\"extra\":{\"name\":\"Global Dollar\",\"version\":\"1\"}}]}"
9
+ }
@@ -0,0 +1,374 @@
1
+ import email.message
2
+ import io
3
+ import json
4
+ import os
5
+ import socket
6
+ import urllib.error
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from toll_sdk import PaymentRequirements, Toll, TollError, is_valid_address, is_valid_wallet, parse_paid_url, parse_pending
12
+
13
+ FX = Path(__file__).parent / "fixtures"
14
+ TOKEN = "0xff9e86e6b21fd688027981bee4b64c8b6b804ed3"
15
+ SOL = "So11111111111111111111111111111111111111112"
16
+
17
+
18
+ def fx(name: str) -> str:
19
+ return (FX / f"{name}.json").read_text(encoding="utf-8")
20
+
21
+
22
+ class FakeResponse:
23
+ def __init__(self, raw: str):
24
+ self._raw = raw
25
+
26
+ def read(self) -> bytes:
27
+ return self._raw.encode()
28
+
29
+ def __enter__(self):
30
+ return self
31
+
32
+ def __exit__(self, *a):
33
+ return False
34
+
35
+
36
+ class FakeOpener:
37
+ def __init__(self, *responses):
38
+ self.responses = list(responses)
39
+ self.requests = []
40
+
41
+ def open(self, req, timeout=None):
42
+ self.requests.append((req, timeout))
43
+ r = self.responses.pop(0)
44
+ if isinstance(r, Exception):
45
+ raise r
46
+ return FakeResponse(r)
47
+
48
+ def body(self, i=0):
49
+ return json.loads(self.requests[i][0].data)
50
+
51
+
52
+ class Sleeper:
53
+ def __init__(self):
54
+ self.slept = []
55
+
56
+ def __call__(self, s):
57
+ self.slept.append(s)
58
+
59
+
60
+ def test_validators():
61
+ assert is_valid_address(TOKEN) and is_valid_address(SOL)
62
+ assert not is_valid_address("0x123")
63
+ assert not is_valid_address(TOKEN + "\n")
64
+ assert not is_valid_address("0OIl" + "1" * 30)
65
+ assert is_valid_wallet(TOKEN) and not is_valid_wallet(SOL)
66
+
67
+
68
+ def test_tools_from_real_list():
69
+ op = FakeOpener(fx("tools_list"))
70
+ tools = Toll(opener=op).tools()
71
+ assert [t["name"] for t in tools] == ["check_coin", "larp_check", "dev_record", "wallet_privacy", "fresh_launches", "chain_pulse", "launch_record"]
72
+ req, timeout = op.requests[0]
73
+ assert req.full_url == "https://t0ll.xyz/mcp" and timeout == 30.0
74
+ assert req.get_header("Accept") == "application/json, text/event-stream"
75
+
76
+
77
+ def test_check_coin_real_answer():
78
+ op = FakeOpener(fx("check_coin"))
79
+ r = Toll(opener=op).check_coin(TOKEN, chain="rh")
80
+ assert op.body()["params"] == {"name": "check_coin", "arguments": {"address": TOKEN, "chain": "rh"}}
81
+ assert r.text.startswith("$TOLL (Toll)")
82
+ assert r.paid_url is None
83
+ assert r.pending == {"retry_after_s": 20} # "Still reading who holds it now: ask again in 20 s."
84
+
85
+
86
+ def test_larp_check_uses_token_argument():
87
+ op = FakeOpener(fx("larp_check"))
88
+ r = Toll(opener=op).larp_check(TOKEN, chain="rh")
89
+ assert op.body()["params"] == {"name": "larp_check", "arguments": {"token": TOKEN, "chain": "rh"}}
90
+ assert "product found" in r.text
91
+ assert r.pending is None
92
+ assert r.paid_url is None # the answer names t0ll.xyz/api/x402/larp/... as a working part: not an upsell
93
+
94
+
95
+ def test_chain_pulse():
96
+ op = FakeOpener(fx("chain_pulse"))
97
+ r = Toll(opener=op).chain_pulse()
98
+ assert op.body()["params"] == {"name": "chain_pulse", "arguments": {}}
99
+ assert r.text.startswith("Robinhood Chain, right now")
100
+
101
+
102
+ def test_over_quota_paid_url():
103
+ t = Toll(opener=FakeOpener(fx("check_coin_over"), fx("larp_check_over")))
104
+ assert t.check_coin(TOKEN, chain="rh").paid_url == f"https://t0ll.xyz/api/x402/check/{TOKEN}?chain=rh"
105
+ assert t.larp_check(TOKEN, chain="rh").paid_url == f"https://t0ll.xyz/api/x402/larp/{TOKEN}?chain=rh"
106
+
107
+
108
+ def test_parse_paid_url_strict():
109
+ assert parse_paid_url("paid check of this coin (x402): https://evil.example/api/x402/check/x") is None
110
+ assert parse_paid_url("a mention of https://t0ll.xyz/api/x402/check/abc") is None
111
+ assert parse_paid_url("paid check of this coin (x402): https://t0ll.xyz/api/x402/check/abc?chain=sol.") == "https://t0ll.xyz/api/x402/check/abc?chain=sol"
112
+
113
+
114
+ def test_pending_still_reading():
115
+ r = Toll(opener=FakeOpener(fx("larp_check_reading"))).larp_check(TOKEN)
116
+ assert r.pending == {"retry_after_s": 30}
117
+ assert parse_pending("nothing") is None
118
+
119
+
120
+ def test_larp_wait_retries_max_three_tries():
121
+ s = Sleeper()
122
+ op = FakeOpener(fx("larp_check_reading"), fx("larp_check_reading"), fx("larp_check"))
123
+ r = Toll(opener=op, sleep=s).larp_check(TOKEN, wait=True)
124
+ assert len(op.requests) == 3 and s.slept == [30, 30] and r.pending is None
125
+
126
+ op2 = FakeOpener(*[fx("larp_check_reading")] * 4)
127
+ r2 = Toll(opener=op2, sleep=Sleeper()).larp_check(TOKEN, wait=True)
128
+ assert len(op2.requests) == 3 and r2.pending
129
+
130
+
131
+ def test_larp_wait_stops_before_120s():
132
+ long = json.dumps({"result": {"content": [{"type": "text", "text": "Still reading x. Try again in about 100 seconds."}]}})
133
+ s = Sleeper()
134
+ op = FakeOpener(long, long, long)
135
+ r = Toll(opener=op, sleep=s).larp_check(TOKEN, wait=True)
136
+ assert len(op.requests) == 2 and s.slept == [100] and r.pending
137
+
138
+
139
+ def test_larp_wait_treats_busy_like_pending():
140
+ s = Sleeper()
141
+ op = FakeOpener(fx("larp_check_busy"), fx("larp_check_reading"), fx("larp_check"))
142
+ r = Toll(opener=op, sleep=s).larp_check(TOKEN, wait=True)
143
+ assert len(op.requests) == 3 and s.slept == [20, 30] and "product found" in r.text
144
+
145
+ s2 = Sleeper()
146
+ op2 = FakeOpener(*[fx("larp_check_busy")] * 3, fx("larp_check"))
147
+ with pytest.raises(TollError) as e:
148
+ Toll(opener=op2, sleep=s2).larp_check(TOKEN, wait=True)
149
+ assert e.value.retry_after_s == 20 and len(op2.requests) == 3 and s2.slept == [20, 20]
150
+
151
+ bad = json.dumps({"result": {"content": [{"type": "text", "text": "Not a token."}], "isError": True}})
152
+ op3 = FakeOpener(bad, fx("larp_check"))
153
+ with pytest.raises(TollError, match="Not a token"):
154
+ Toll(opener=op3, sleep=Sleeper()).larp_check(TOKEN, wait=True)
155
+ assert len(op3.requests) == 1
156
+
157
+
158
+ def test_errors_become_toll_error():
159
+ with pytest.raises(TollError, match="Many new LARP checks") as e:
160
+ Toll(opener=FakeOpener(fx("larp_check_busy"))).larp_check(TOKEN)
161
+ assert e.value.retry_after_s == 20
162
+
163
+ rpc = json.dumps({"jsonrpc": "2.0", "id": 1, "error": {"code": -32602, "message": "bad params"}})
164
+ with pytest.raises(TollError, match="bad params") as e:
165
+ Toll(opener=FakeOpener(rpc)).chain_pulse()
166
+ assert e.value.code == -32602
167
+
168
+ http = urllib.error.HTTPError("https://t0ll.xyz/mcp", 429, "Too Many", {}, io.BytesIO(b"slow down"))
169
+ with pytest.raises(TollError, match="HTTP 429") as e:
170
+ Toll(opener=FakeOpener(http)).chain_pulse()
171
+ assert e.value.status == 429
172
+
173
+ with pytest.raises(TollError, match="timed out"):
174
+ Toll(opener=FakeOpener(TimeoutError())).chain_pulse()
175
+ with pytest.raises(TollError, match="failed"):
176
+ Toll(opener=FakeOpener(urllib.error.URLError("dns"))).chain_pulse()
177
+ with pytest.raises(TollError, match="not JSON-RPC"):
178
+ Toll(opener=FakeOpener("<html>")).chain_pulse()
179
+
180
+
181
+ def test_bad_input_never_reaches_network():
182
+ op = FakeOpener()
183
+ t = Toll(opener=op)
184
+ for fn in (
185
+ lambda: t.check_coin("not an address"),
186
+ lambda: t.check_coin(TOKEN, chain="eth"),
187
+ lambda: t.larp_check(SOL + "!"),
188
+ lambda: t.dev_record(SOL),
189
+ lambda: t.wallet_privacy("0x1"),
190
+ lambda: t.launch_record(SOL),
191
+ lambda: t.fresh_launches(21),
192
+ ):
193
+ with pytest.raises(TollError):
194
+ fn()
195
+ assert op.requests == []
196
+
197
+
198
+ def test_sse_frame():
199
+ r = Toll(opener=FakeOpener("event: message\ndata: " + fx("chain_pulse") + "\n\n")).chain_pulse()
200
+ assert "right now" in r.text
201
+
202
+
203
+ PAID = f"https://t0ll.xyz/api/x402/check/{TOKEN}?chain=rh"
204
+ # A real unpaid 402 from Toll (captured once, no payment header sent): v2 PAYMENT-REQUIRED header + v1 body.
205
+ REAL_402 = json.loads(fx("x402_required"))
206
+
207
+
208
+ def msg_headers(d):
209
+ m = email.message.Message()
210
+ for k, v in d.items():
211
+ m[k] = v
212
+ return m
213
+
214
+
215
+ class HttpOpener:
216
+ """Answers like urllib: 2xx -> response object, 4xx/5xx -> HTTPError. Items: (status, headers, body) or a callable."""
217
+
218
+ def __init__(self, *answers):
219
+ self.answers = list(answers)
220
+ self.requests = []
221
+
222
+ def open(self, req, timeout=None):
223
+ self.requests.append((req, timeout))
224
+ a = self.answers.pop(0)
225
+ if callable(a):
226
+ a = a(req, timeout)
227
+ status, headers, body = a
228
+ hdrs = msg_headers(headers)
229
+ if status >= 400:
230
+ raise urllib.error.HTTPError(req.full_url, status, "x", hdrs, io.BytesIO(body.encode()))
231
+ r = FakeResponse(body)
232
+ r.status = status
233
+ r.headers = hdrs
234
+ return r
235
+
236
+ def header(self, i, name):
237
+ h = {k.lower(): v for k, v in self.requests[i][0].header_items()}
238
+ return h.get(name.lower())
239
+
240
+
241
+ def required(headers=None):
242
+ return (402, REAL_402["headers"] if headers is None else headers, REAL_402["body"])
243
+
244
+
245
+ def pending(retry_after=None):
246
+ return (402, {} if retry_after is None else {"Retry-After": retry_after}, '{"pending": true}')
247
+
248
+
249
+ class Signer:
250
+ def __init__(self, value="c2lnbmVkLXBheW1lbnQ="):
251
+ self.value = value
252
+ self.seen = []
253
+
254
+ def __call__(self, req, url):
255
+ self.seen.append((req, url))
256
+ return self.value
257
+
258
+
259
+ def test_pay_needs_sign_and_toll_url():
260
+ op = HttpOpener()
261
+ with pytest.raises(TollError, match="sign"):
262
+ Toll(opener=op).pay(PAID)
263
+ with pytest.raises(TollError):
264
+ Toll(opener=op, sign=Signer()).pay("https://evil.example/x")
265
+ assert op.requests == []
266
+
267
+
268
+ def test_pay_signs_once_across_several_pendings():
269
+ s = Sleeper()
270
+ sg = Signer()
271
+ op = HttpOpener(required(), pending("7"), pending("0"), pending(), (200, {}, '{"token": "x", "charged": true}'))
272
+ assert Toll(opener=op, sign=sg, sleep=s).pay(PAID) == {"token": "x", "charged": True}
273
+
274
+ assert len(sg.seen) == 1 # one signature for the whole call
275
+ req, url = sg.seen[0]
276
+ assert isinstance(req, PaymentRequirements) and req.x402_version == 2 and url == PAID
277
+ assert req.accepts[0]["network"] == "eip155:4663" and req.accepts[0]["amount"] == "50000"
278
+ assert req.accepts[0]["asset"] == "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168"
279
+
280
+ assert len(op.requests) == 5
281
+ assert op.header(0, "payment-signature") is None and op.header(0, "x-payment") is None
282
+ for i in range(1, 5):
283
+ assert op.requests[i][0].full_url == PAID
284
+ assert op.header(i, "payment-signature") == "c2lnbmVkLXBheW1lbnQ="
285
+ assert op.header(i, "x-payment") is None
286
+ assert s.slept == [7, 1, 5] # Retry-After 0 is clamped to 1 s, none means 5 s
287
+
288
+
289
+ def test_pay_follows_v1_with_x_payment():
290
+ sg = Signer("djEtcGF5bWVudA==")
291
+ op = HttpOpener(required({"content-type": "application/json"}), (200, {}, '{"ok": 1}'))
292
+ assert Toll(opener=op, sign=sg).pay(PAID) == {"ok": 1}
293
+ req = sg.seen[0][0]
294
+ assert req.x402_version == 1 and req.accepts[0]["maxAmountRequired"] == "50000"
295
+ assert op.header(1, "x-payment") == "djEtcGF5bWVudA==" and op.header(1, "payment-signature") is None
296
+
297
+
298
+ def test_pay_stops_after_five_pending_tries():
299
+ s = Sleeper()
300
+ sg = Signer()
301
+ op = HttpOpener(required(), *[pending("5")] * 6)
302
+ with pytest.raises(TollError, match="same payment header") as e:
303
+ Toll(opener=op, sign=sg, sleep=s).pay(PAID)
304
+ assert e.value.status == 402
305
+ assert len(op.requests) == 6 and len(sg.seen) == 1 and s.slept == [5, 5, 5, 5]
306
+
307
+
308
+ def test_pay_stops_before_300s_waiting():
309
+ s = Sleeper()
310
+ sg = Signer()
311
+ op = HttpOpener(required(), pending("200"), pending("200"))
312
+ with pytest.raises(TollError):
313
+ Toll(opener=op, sign=sg, sleep=s).pay(PAID)
314
+ assert s.slept == [200] and len(sg.seen) == 1
315
+
316
+
317
+ def test_other_402_after_paying_is_not_accepted():
318
+ sg = Signer()
319
+ op = HttpOpener(required(), pending("3"), required())
320
+ with pytest.raises(TollError, match="payment not accepted") as e:
321
+ Toll(opener=op, sign=sg, sleep=Sleeper()).pay(PAID)
322
+ assert e.value.status == 402 and len(sg.seen) == 1 and len(op.requests) == 3
323
+
324
+
325
+ def test_no_payment_sent_without_requirements_or_when_sign_fails():
326
+ sg = Signer()
327
+ with pytest.raises(TollError, match="without x402 payment requirements"):
328
+ Toll(opener=HttpOpener((402, {}, "{}")), sign=sg).pay(PAID)
329
+ assert sg.seen == []
330
+
331
+ def bad(req, url):
332
+ raise RuntimeError("no funds")
333
+
334
+ op = HttpOpener(required())
335
+ with pytest.raises(TollError, match="nothing was sent: no funds"):
336
+ Toll(opener=op, sign=bad).pay(PAID)
337
+ assert len(op.requests) == 1
338
+
339
+ op = HttpOpener(required())
340
+ with pytest.raises(TollError, match="one non-empty line"):
341
+ Toll(opener=op, sign=lambda r, u: "a\r\nb").pay(PAID)
342
+ assert len(op.requests) == 1
343
+
344
+
345
+ def test_pay_timeout_warns_not_to_sign_again():
346
+ sg = Signer()
347
+
348
+ def hang(req, timeout):
349
+ raise TimeoutError()
350
+
351
+ op = HttpOpener(required(), hang)
352
+ with pytest.raises(TollError, match="may have settled") as e:
353
+ Toll(opener=op, sign=sg).pay(PAID, timeout=40)
354
+ assert "new signature" in str(e.value) and len(sg.seen) == 1
355
+ assert op.requests[0][1] <= 40 # each request gets what is left of pay()'s own timeout
356
+
357
+ op = HttpOpener(required(), lambda r, t: (_ for _ in ()).throw(urllib.error.URLError(socket.timeout())))
358
+ with pytest.raises(TollError, match="timed out.*may have settled"):
359
+ Toll(opener=op, sign=Signer()).pay(PAID)
360
+
361
+
362
+ def test_pay_unpaid_200_needs_no_signature():
363
+ sg = Signer()
364
+ assert Toll(opener=HttpOpener((200, {}, '{"free": true}')), sign=sg).pay(PAID) == {"free": True}
365
+ assert sg.seen == []
366
+
367
+
368
+ def test_retry_after_clamped():
369
+ assert parse_pending("try again in 0 s") == {"retry_after_s": 1}
370
+
371
+
372
+ @pytest.mark.skipif(os.environ.get("TOLL_LIVE") != "1", reason="set TOLL_LIVE=1 for the live smoke test")
373
+ def test_live_smoke():
374
+ assert "Robinhood Chain" in Toll().chain_pulse().text
toll_sdk-0.1.0/uv.lock ADDED
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.9"
4
+
5
+ [[package]]
6
+ name = "toll-sdk"
7
+ version = "0.1.0"
8
+ source = { editable = "." }