fizzl 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.
- fizzl-0.1.0/.gitignore +5 -0
- fizzl-0.1.0/LICENSE +21 -0
- fizzl-0.1.0/PKG-INFO +90 -0
- fizzl-0.1.0/README.md +69 -0
- fizzl-0.1.0/pyproject.toml +26 -0
- fizzl-0.1.0/src/fizzl/__init__.py +122 -0
- fizzl-0.1.0/src/fizzl/langchain.py +62 -0
- fizzl-0.1.0/tests/test_fizzl.py +74 -0
fizzl-0.1.0/.gitignore
ADDED
fizzl-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frits Zwager
|
|
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.
|
fizzl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fizzl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Safety checks for AI agents that pay: check a transaction or signature before signing, a token before buying, a wallet's approvals, and an x402 endpoint before paying it (presign-guard, x402 Doctor).
|
|
5
|
+
Project-URL: Homepage, https://github.com/Fizzl13/x402-examples/tree/main/fizzl-py
|
|
6
|
+
Project-URL: Repository, https://github.com/Fizzl13/x402-examples
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,ai-agents,approvals,base,honeypot,langchain,mcp,presign,security,solana,token-check,usdc,wallet,x402
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Requires-Dist: requests>=2.28
|
|
15
|
+
Provides-Extra: langchain
|
|
16
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
17
|
+
Requires-Dist: pydantic>=2; extra == 'langchain'
|
|
18
|
+
Provides-Extra: x402
|
|
19
|
+
Requires-Dist: x402[requests]>=2.0; extra == 'x402'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# fizzl
|
|
23
|
+
|
|
24
|
+
Safety checks for Python AI agents that pay: LangChain, LangGraph, CrewAI or your own loop.
|
|
25
|
+
|
|
26
|
+
| Check | What the agent checks | Service | Price |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| `check_before_signing` | a transaction, token approval or signature **before signing it**: green / orange / red with reason codes (drainers, unlimited approvals to unknown spenders, look-alike tokens, Permit/Permit2/Seaport signatures that hand over tokens) | [presign-guard](https://presign-guard.fizzl.eu) | $0.01 |
|
|
29
|
+
| `check_token` | a token **before buying or accepting it**: honeypot, rug-pull signs, look-alikes (Solana and EVM) | presign-guard | $0.01 |
|
|
30
|
+
| `check_wallet_approvals` | every open approval of a wallet and which to revoke | presign-guard | $0.02 |
|
|
31
|
+
| `check_endpoint_before_paying` | an x402 or MPP paid API **before paying it**: go / caution / no_go, the cheapest option that settles, budget, track record, bait signs (fake brands, airdrop lures, output that doesn't match) | [x402 Doctor](https://x402-doctor.fizzl.eu) | $0.001 |
|
|
32
|
+
|
|
33
|
+
The checks only check. They never sign, pay or move anything themselves. A failed check comes back as
|
|
34
|
+
`{"error": ..., "message": ...}` instead of raising, so the agent can tell its user.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install "fizzl[x402]" # with the x402 client, to pay per check
|
|
40
|
+
pip install "fizzl[langchain,x402]" # plus LangChain tools
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Use
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import requests
|
|
47
|
+
from eth_account import Account
|
|
48
|
+
from x402 import x402ClientSync
|
|
49
|
+
from x402.http.clients import wrapRequestsWithPayment
|
|
50
|
+
from x402.mechanisms.evm.exact import ExactEvmScheme
|
|
51
|
+
from fizzl import Fizzl
|
|
52
|
+
|
|
53
|
+
payer = x402ClientSync().register("eip155:8453", ExactEvmScheme(Account.from_key(AGENT_KEY))) # USDC on Base
|
|
54
|
+
session = wrapRequestsWithPayment(requests.Session(), payer)
|
|
55
|
+
|
|
56
|
+
fizzl = Fizzl(session=session)
|
|
57
|
+
fizzl.check_endpoint_before_paying("https://api.example.com/funding", max_usd=0.05)
|
|
58
|
+
# {'verdict': 'go', 'summary': 'OK to pay: $0.02 on Base.', 'options': [...], 'reasons': [], ...}
|
|
59
|
+
fizzl.check_token("base", "0x...")
|
|
60
|
+
fizzl.check_before_signing(type="approval", chainId=8453, token="0x...", spender="0x...", amount="115792089237316195423570985008687907853269984665640564039457584007913129639935")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Prepaid credits instead of a payment per check:** buy a pack once ([presign-guard](https://presign-guard.fizzl.eu/v1/credits): 100 checks for $0.80; [x402 Doctor](https://x402-doctor.fizzl.eu/api/v1/credits): 1000 preflights for $0.80), then use a plain session:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
fizzl = Fizzl(credit_keys={"presign": PRESIGN_CREDIT_KEY, "doctor": DOCTOR_CREDIT_KEY})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## LangChain / LangGraph
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from langgraph.prebuilt import create_react_agent
|
|
73
|
+
from fizzl.langchain import fizzl_tools
|
|
74
|
+
|
|
75
|
+
tools = fizzl_tools(session=session) # or credit_keys={...}; only=[...] for some of them
|
|
76
|
+
agent = create_react_agent(model, tools + your_tools,
|
|
77
|
+
prompt="Before you pay any API, call check_endpoint_before_paying. Before you sign anything, call check_before_signing. Never continue on red or no_go.")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The tools are plain `StructuredTool`s with typed arguments, so CrewAI and other frameworks that take LangChain tools can use them too.
|
|
81
|
+
|
|
82
|
+
## Want the wallet to enforce it, not just inform the model?
|
|
83
|
+
|
|
84
|
+
Tools inform the model; a model can still ignore them. To make red and over-budget payments impossible, guard the
|
|
85
|
+
wallet itself: [presign-guard-wallet](https://www.npmjs.com/package/presign-guard-wallet) (Node) checks every
|
|
86
|
+
signature with presign-guard and your spending limits, with Telegram approval above them.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
fizzl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# fizzl
|
|
2
|
+
|
|
3
|
+
Safety checks for Python AI agents that pay: LangChain, LangGraph, CrewAI or your own loop.
|
|
4
|
+
|
|
5
|
+
| Check | What the agent checks | Service | Price |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| `check_before_signing` | a transaction, token approval or signature **before signing it**: green / orange / red with reason codes (drainers, unlimited approvals to unknown spenders, look-alike tokens, Permit/Permit2/Seaport signatures that hand over tokens) | [presign-guard](https://presign-guard.fizzl.eu) | $0.01 |
|
|
8
|
+
| `check_token` | a token **before buying or accepting it**: honeypot, rug-pull signs, look-alikes (Solana and EVM) | presign-guard | $0.01 |
|
|
9
|
+
| `check_wallet_approvals` | every open approval of a wallet and which to revoke | presign-guard | $0.02 |
|
|
10
|
+
| `check_endpoint_before_paying` | an x402 or MPP paid API **before paying it**: go / caution / no_go, the cheapest option that settles, budget, track record, bait signs (fake brands, airdrop lures, output that doesn't match) | [x402 Doctor](https://x402-doctor.fizzl.eu) | $0.001 |
|
|
11
|
+
|
|
12
|
+
The checks only check. They never sign, pay or move anything themselves. A failed check comes back as
|
|
13
|
+
`{"error": ..., "message": ...}` instead of raising, so the agent can tell its user.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pip install "fizzl[x402]" # with the x402 client, to pay per check
|
|
19
|
+
pip install "fizzl[langchain,x402]" # plus LangChain tools
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import requests
|
|
26
|
+
from eth_account import Account
|
|
27
|
+
from x402 import x402ClientSync
|
|
28
|
+
from x402.http.clients import wrapRequestsWithPayment
|
|
29
|
+
from x402.mechanisms.evm.exact import ExactEvmScheme
|
|
30
|
+
from fizzl import Fizzl
|
|
31
|
+
|
|
32
|
+
payer = x402ClientSync().register("eip155:8453", ExactEvmScheme(Account.from_key(AGENT_KEY))) # USDC on Base
|
|
33
|
+
session = wrapRequestsWithPayment(requests.Session(), payer)
|
|
34
|
+
|
|
35
|
+
fizzl = Fizzl(session=session)
|
|
36
|
+
fizzl.check_endpoint_before_paying("https://api.example.com/funding", max_usd=0.05)
|
|
37
|
+
# {'verdict': 'go', 'summary': 'OK to pay: $0.02 on Base.', 'options': [...], 'reasons': [], ...}
|
|
38
|
+
fizzl.check_token("base", "0x...")
|
|
39
|
+
fizzl.check_before_signing(type="approval", chainId=8453, token="0x...", spender="0x...", amount="115792089237316195423570985008687907853269984665640564039457584007913129639935")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Prepaid credits instead of a payment per check:** buy a pack once ([presign-guard](https://presign-guard.fizzl.eu/v1/credits): 100 checks for $0.80; [x402 Doctor](https://x402-doctor.fizzl.eu/api/v1/credits): 1000 preflights for $0.80), then use a plain session:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
fizzl = Fizzl(credit_keys={"presign": PRESIGN_CREDIT_KEY, "doctor": DOCTOR_CREDIT_KEY})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## LangChain / LangGraph
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from langgraph.prebuilt import create_react_agent
|
|
52
|
+
from fizzl.langchain import fizzl_tools
|
|
53
|
+
|
|
54
|
+
tools = fizzl_tools(session=session) # or credit_keys={...}; only=[...] for some of them
|
|
55
|
+
agent = create_react_agent(model, tools + your_tools,
|
|
56
|
+
prompt="Before you pay any API, call check_endpoint_before_paying. Before you sign anything, call check_before_signing. Never continue on red or no_go.")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The tools are plain `StructuredTool`s with typed arguments, so CrewAI and other frameworks that take LangChain tools can use them too.
|
|
60
|
+
|
|
61
|
+
## Want the wallet to enforce it, not just inform the model?
|
|
62
|
+
|
|
63
|
+
Tools inform the model; a model can still ignore them. To make red and over-budget payments impossible, guard the
|
|
64
|
+
wallet itself: [presign-guard-wallet](https://www.npmjs.com/package/presign-guard-wallet) (Node) checks every
|
|
65
|
+
signature with presign-guard and your spending limits, with Telegram approval above them.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fizzl"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Safety checks for AI agents that pay: check a transaction or signature before signing, a token before buying, a wallet's approvals, and an x402 endpoint before paying it (presign-guard, x402 Doctor)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = ["requests>=2.28"]
|
|
14
|
+
keywords = ["x402", "ai-agents", "agents", "langchain", "wallet", "security", "presign", "token-check", "honeypot", "approvals", "usdc", "base", "solana", "mcp"]
|
|
15
|
+
classifiers = ["Programming Language :: Python :: 3", "Intended Audience :: Developers", "Topic :: Security"]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
langchain = ["langchain-core>=0.3", "pydantic>=2"]
|
|
19
|
+
x402 = ["x402[requests]>=2.0"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/Fizzl13/x402-examples/tree/main/fizzl-py"
|
|
23
|
+
Repository = "https://github.com/Fizzl13/x402-examples"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/fizzl"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Fizzl safety checks for AI agents that pay.
|
|
2
|
+
|
|
3
|
+
Check a transaction or signature before signing, a token before buying, a wallet's open approvals, and an
|
|
4
|
+
x402 endpoint before paying it (presign-guard and x402 Doctor, paid per call over x402 or with prepaid
|
|
5
|
+
credits). The checks never sign, pay or move anything themselves, and a failed check comes back as
|
|
6
|
+
``{"error": ..., "message": ...}`` instead of an exception, so an agent can tell its user.
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from x402 import x402ClientSync
|
|
10
|
+
from x402.http.clients import wrapRequestsWithPayment
|
|
11
|
+
from fizzl import Fizzl
|
|
12
|
+
|
|
13
|
+
payer = x402ClientSync() # register an EVM scheme with your agent's key
|
|
14
|
+
session = wrapRequestsWithPayment(requests.Session(), payer)
|
|
15
|
+
fizzl = Fizzl(session=session)
|
|
16
|
+
fizzl.check_endpoint_before_paying("https://api.example.com/data", max_usd=0.05)
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Mapping, Optional
|
|
21
|
+
from urllib.parse import urlencode
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__all__ = ["Fizzl", "PRICES", "PRESIGN_URL", "DOCTOR_URL", "__version__"]
|
|
25
|
+
|
|
26
|
+
PRESIGN_URL = "https://presign-guard.fizzl.eu"
|
|
27
|
+
DOCTOR_URL = "https://x402-doctor.fizzl.eu"
|
|
28
|
+
PRICES = {
|
|
29
|
+
"check_before_signing": "$0.01",
|
|
30
|
+
"check_token": "$0.01",
|
|
31
|
+
"check_wallet_approvals": "$0.02",
|
|
32
|
+
"check_endpoint_before_paying": "$0.001",
|
|
33
|
+
}
|
|
34
|
+
EVM_CHAIN_IDS = (1, 10, 56, 137, 8453, 42161)
|
|
35
|
+
TOKEN_CHAINS = ("solana", "base", "ethereum", "arbitrum", "optimism", "polygon", "bsc")
|
|
36
|
+
APPROVAL_CHAINS = ("base", "ethereum", "arbitrum", "optimism", "polygon", "bsc")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _error(code: str, message: str) -> dict:
|
|
40
|
+
return {"error": code, "message": message}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Fizzl:
|
|
44
|
+
"""The four checks. ``session`` is anything with ``request(method, url, **kwargs)`` like a
|
|
45
|
+
``requests.Session``; wrap it with x402's ``wrapRequestsWithPayment`` to pay per check, or pass
|
|
46
|
+
``credit_keys={"presign": ..., "doctor": ...}`` to pay from prepaid credits with a plain session."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, session: Any = None, credit_keys: Optional[Mapping[str, str]] = None, timeout: float = 30,
|
|
49
|
+
presign_url: str = PRESIGN_URL, doctor_url: str = DOCTOR_URL):
|
|
50
|
+
if session is None:
|
|
51
|
+
import requests # only needed when no session is given
|
|
52
|
+
session = requests.Session()
|
|
53
|
+
self.session = session
|
|
54
|
+
self.credit_keys = dict(credit_keys or {})
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.presign_url = presign_url.rstrip("/")
|
|
57
|
+
self.doctor_url = doctor_url.rstrip("/")
|
|
58
|
+
|
|
59
|
+
def _call(self, service: str, method: str, url: str, body: Optional[dict] = None) -> dict:
|
|
60
|
+
headers = {"accept": "application/json", "user-agent": f"fizzl-py/{__version__}"}
|
|
61
|
+
key = self.credit_keys.get(service)
|
|
62
|
+
if key:
|
|
63
|
+
headers["x-credit-key"] = key
|
|
64
|
+
try:
|
|
65
|
+
res = self.session.request(method, url, headers=headers, timeout=self.timeout, **({"json": body} if body is not None else {}))
|
|
66
|
+
except Exception as err: # network, timeout, payment client errors
|
|
67
|
+
return _error("check_failed", f"{type(err).__name__}: {err}")
|
|
68
|
+
try:
|
|
69
|
+
data = res.json()
|
|
70
|
+
except Exception:
|
|
71
|
+
data = None
|
|
72
|
+
if res.status_code == 402:
|
|
73
|
+
why = data.get("error") if isinstance(data, dict) and isinstance(data.get("error"), str) else None
|
|
74
|
+
return _error("payment_required", "This check is paid (x402) and no payment went through"
|
|
75
|
+
+ (f" ({why})" if why else "")
|
|
76
|
+
+ ". Pass a session wrapped with x402's wrapRequestsWithPayment whose wallet holds USDC on Base, or a prepaid credit key.")
|
|
77
|
+
if res.status_code >= 400:
|
|
78
|
+
msg = (data or {}).get("error") or (data or {}).get("message") if isinstance(data, dict) else None
|
|
79
|
+
return _error(f"http_{res.status_code}", str(msg or getattr(res, "text", ""))[:300])
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
return _error("bad_response", "The check did not answer JSON.")
|
|
82
|
+
data.pop("receipt", None) # the signed receipt is for audits, not for the model
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
def check_before_signing(self, type: str, chainId: int, token: Optional[str] = None, spender: Optional[str] = None,
|
|
86
|
+
amount: Optional[str] = None, to: Optional[str] = None, data: Optional[str] = None,
|
|
87
|
+
value: Optional[str] = None, typedData: Any = None, origin: Optional[str] = None) -> dict:
|
|
88
|
+
"""Check a transaction, token approval or signature BEFORE signing it (presign-guard, $0.01).
|
|
89
|
+
Returns verdict green / orange / red with reason codes. Never sign on red; ask the user on orange."""
|
|
90
|
+
if type not in ("approval", "transaction", "signature"):
|
|
91
|
+
return _error("bad_input", 'type must be "approval", "transaction" or "signature"')
|
|
92
|
+
if chainId not in EVM_CHAIN_IDS:
|
|
93
|
+
return _error("bad_input", f"chainId must be one of {EVM_CHAIN_IDS}")
|
|
94
|
+
body = {k: v for k, v in dict(type=type, chainId=chainId, token=token, spender=spender, amount=amount, to=to,
|
|
95
|
+
data=data, value=value, typedData=typedData, origin=origin).items() if v is not None}
|
|
96
|
+
return self._call("presign", "POST", f"{self.presign_url}/v1/check", body)
|
|
97
|
+
|
|
98
|
+
def check_token(self, chain: str, address: str) -> dict:
|
|
99
|
+
"""Check a token BEFORE buying, holding or accepting it (presign-guard, $0.01): honeypot, rug-pull
|
|
100
|
+
signs, look-alikes of known tokens. Solana and EVM chains."""
|
|
101
|
+
if chain not in TOKEN_CHAINS:
|
|
102
|
+
return _error("bad_input", f"chain must be one of {TOKEN_CHAINS}")
|
|
103
|
+
return self._call("presign", "GET", f"{self.presign_url}/v1/token?{urlencode({'chain': chain, 'address': address})}")
|
|
104
|
+
|
|
105
|
+
def check_wallet_approvals(self, chain: str, address: str) -> dict:
|
|
106
|
+
"""List every open token approval of an EVM wallet and which ones to revoke (presign-guard, $0.02)."""
|
|
107
|
+
if chain not in APPROVAL_CHAINS:
|
|
108
|
+
return _error("bad_input", f"chain must be one of {APPROVAL_CHAINS}")
|
|
109
|
+
return self._call("presign", "GET", f"{self.presign_url}/v1/approvals?{urlencode({'chain': chain, 'address': address})}")
|
|
110
|
+
|
|
111
|
+
def check_endpoint_before_paying(self, url: str, max_usd: Optional[float] = None, network: Optional[str] = None,
|
|
112
|
+
method: Optional[str] = None) -> dict:
|
|
113
|
+
"""Check an x402 or MPP paid API BEFORE paying it (x402 Doctor, $0.001): go / caution / no_go, the
|
|
114
|
+
cheapest option that settles, budget, track record and bait signs. Don't pay on no_go."""
|
|
115
|
+
q = {"url": url}
|
|
116
|
+
if max_usd is not None:
|
|
117
|
+
q["max_usd"] = str(max_usd)
|
|
118
|
+
if network:
|
|
119
|
+
q["network"] = network
|
|
120
|
+
if method:
|
|
121
|
+
q["method"] = method
|
|
122
|
+
return self._call("doctor", "GET", f"{self.doctor_url}/api/v1/preflight?{urlencode(q)}")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The Fizzl checks as LangChain tools (``langchain-core``), for LangChain and LangGraph agents.
|
|
2
|
+
|
|
3
|
+
from fizzl.langchain import fizzl_tools
|
|
4
|
+
tools = fizzl_tools(session=paying_session) # or credit_keys={...}
|
|
5
|
+
agent = create_react_agent(model, tools)
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, List, Literal, Optional, Union
|
|
10
|
+
|
|
11
|
+
from . import Fizzl
|
|
12
|
+
|
|
13
|
+
TOOL_NAMES = ("check_before_signing", "check_token", "check_wallet_approvals", "check_endpoint_before_paying")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fizzl_tools(session: Any = None, credit_keys: Optional[dict] = None, only: Optional[List[str]] = None, **kwargs) -> list:
|
|
17
|
+
"""LangChain StructuredTools for the four checks (all of them, or the names in ``only``)."""
|
|
18
|
+
try:
|
|
19
|
+
from langchain_core.tools import StructuredTool
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
except ImportError as err: # pragma: no cover
|
|
22
|
+
raise ImportError("fizzl.langchain needs langchain-core: pip install 'fizzl[langchain]'") from err
|
|
23
|
+
|
|
24
|
+
f = Fizzl(session=session, credit_keys=credit_keys, **kwargs)
|
|
25
|
+
|
|
26
|
+
class SignArgs(BaseModel):
|
|
27
|
+
type: Literal["approval", "transaction", "signature"] = Field(description="What is about to be signed")
|
|
28
|
+
chainId: int = Field(description="EVM chain id: 1, 10, 56, 137, 8453 (Base) or 42161")
|
|
29
|
+
token: Optional[str] = Field(None, description="approval: token contract")
|
|
30
|
+
spender: Optional[str] = Field(None, description="approval: who gets the allowance")
|
|
31
|
+
amount: Optional[str] = Field(None, description="approval: amount in base units (0 = revoke)")
|
|
32
|
+
to: Optional[str] = Field(None, description="transaction: target contract or recipient")
|
|
33
|
+
data: Optional[str] = Field(None, description="transaction: 0x-prefixed calldata")
|
|
34
|
+
value: Optional[str] = Field(None, description="transaction: native value in wei")
|
|
35
|
+
typedData: Optional[Union[dict, str]] = Field(None, description="signature: the eth_signTypedData_v4 payload")
|
|
36
|
+
origin: Optional[str] = Field(None, description="the site asking for the signature or transaction, if any")
|
|
37
|
+
|
|
38
|
+
class TokenArgs(BaseModel):
|
|
39
|
+
chain: Literal["solana", "base", "ethereum", "arbitrum", "optimism", "polygon", "bsc"]
|
|
40
|
+
address: str = Field(description="Solana mint (base58) or EVM token contract (0x…)")
|
|
41
|
+
|
|
42
|
+
class ApprovalArgs(BaseModel):
|
|
43
|
+
chain: Literal["base", "ethereum", "arbitrum", "optimism", "polygon", "bsc"]
|
|
44
|
+
address: str = Field(description="Wallet address (0x…)")
|
|
45
|
+
|
|
46
|
+
class EndpointArgs(BaseModel):
|
|
47
|
+
url: str = Field(description="The paid endpoint you are about to pay")
|
|
48
|
+
max_usd: Optional[float] = Field(None, description="Your budget for this call in USD")
|
|
49
|
+
network: Optional[str] = Field(None, description="CAIP-2 network to pay on, e.g. eip155:8453")
|
|
50
|
+
method: Optional[Literal["GET", "POST"]] = None
|
|
51
|
+
|
|
52
|
+
specs = {
|
|
53
|
+
"check_before_signing": (f.check_before_signing, SignArgs),
|
|
54
|
+
"check_token": (f.check_token, TokenArgs),
|
|
55
|
+
"check_wallet_approvals": (f.check_wallet_approvals, ApprovalArgs),
|
|
56
|
+
"check_endpoint_before_paying": (f.check_endpoint_before_paying, EndpointArgs),
|
|
57
|
+
}
|
|
58
|
+
names = list(only) if only else list(TOOL_NAMES)
|
|
59
|
+
unknown = [n for n in names if n not in specs]
|
|
60
|
+
if unknown:
|
|
61
|
+
raise ValueError(f"unknown Fizzl tool(s): {', '.join(unknown)}")
|
|
62
|
+
return [StructuredTool.from_function(func=specs[n][0], name=n, description=(specs[n][0].__doc__ or "").strip(), args_schema=specs[n][1]) for n in names]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The checks against a stand-in session (no network, no money), and as LangChain tools."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from fizzl import PRICES, Fizzl
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Resp:
|
|
10
|
+
def __init__(self, status, body):
|
|
11
|
+
self.status_code = status
|
|
12
|
+
self.text = body if isinstance(body, str) else json.dumps(body)
|
|
13
|
+
|
|
14
|
+
def json(self):
|
|
15
|
+
return json.loads(self.text)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StandIn:
|
|
19
|
+
def __init__(self, answers):
|
|
20
|
+
self.answers, self.calls = answers, []
|
|
21
|
+
|
|
22
|
+
def request(self, method, url, **kw):
|
|
23
|
+
self.calls.append({"method": method, "url": url, **kw})
|
|
24
|
+
if isinstance(self.answers, Exception):
|
|
25
|
+
raise self.answers
|
|
26
|
+
path = url.split("://", 1)[1].split("/", 1)[1].split("?")[0]
|
|
27
|
+
status, body = self.answers.get("/" + path, (404, {"error": "not found"}))
|
|
28
|
+
return Resp(status, body)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_calls_the_right_api_sends_credit_keys_and_leaves_the_receipt_out():
|
|
32
|
+
s = StandIn({"/v1/check": (200, {"verdict": "red", "reasons": [{"code": "known_drainer"}], "receipt": {"sig": "0x"}}),
|
|
33
|
+
"/v1/token": (200, {"verdict": "green"}), "/v1/approvals": (200, {"summary": {"toRevoke": 1}}),
|
|
34
|
+
"/api/v1/preflight": (200, {"verdict": "caution"})})
|
|
35
|
+
f = Fizzl(session=s, credit_keys={"presign": "pk", "doctor": "dk"})
|
|
36
|
+
assert f.check_before_signing(type="approval", chainId=8453, token="0xa", spender="0xb", amount="1") == {"verdict": "red", "reasons": [{"code": "known_drainer"}]}
|
|
37
|
+
assert s.calls[0]["method"] == "POST" and s.calls[0]["url"] == "https://presign-guard.fizzl.eu/v1/check"
|
|
38
|
+
assert s.calls[0]["headers"]["x-credit-key"] == "pk" and s.calls[0]["json"]["spender"] == "0xb" and "data" not in s.calls[0]["json"]
|
|
39
|
+
f.check_token("solana", "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263")
|
|
40
|
+
assert "/v1/token?chain=solana&address=Dez" in s.calls[1]["url"]
|
|
41
|
+
f.check_wallet_approvals("base", "0x6B0F4651eD42893ab58139938175E4a69f175F25")
|
|
42
|
+
assert "/v1/approvals?chain=base&address=0x6B0F" in s.calls[2]["url"]
|
|
43
|
+
assert f.check_endpoint_before_paying("https://api.example.com/x", max_usd=0.05, network="eip155:8453")["verdict"] == "caution"
|
|
44
|
+
assert s.calls[3]["url"] == "https://x402-doctor.fizzl.eu/api/v1/preflight?url=https%3A%2F%2Fapi.example.com%2Fx&max_usd=0.05&network=eip155%3A8453"
|
|
45
|
+
assert s.calls[3]["headers"]["x-credit-key"] == "dk"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_failures_come_back_as_error_dicts():
|
|
49
|
+
f = Fizzl(session=StandIn({"/v1/token": (402, {}), "/v1/check": (500, {"error": "boom"}), "/v1/approvals": (200, "not json")}))
|
|
50
|
+
assert f.check_token("base", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")["error"] == "payment_required"
|
|
51
|
+
assert f.check_before_signing(type="transaction", chainId=1, to="0x1") == {"error": "http_500", "message": "boom"}
|
|
52
|
+
assert f.check_wallet_approvals("base", "0x6B0F4651eD42893ab58139938175E4a69f175F25")["error"] == "bad_response"
|
|
53
|
+
assert Fizzl(session=StandIn(ConnectionError("down"))).check_endpoint_before_paying("https://a.example") == {"error": "check_failed", "message": "ConnectionError: down"}
|
|
54
|
+
assert f.check_before_signing(type="approval", chainId=999)["error"] == "bad_input"
|
|
55
|
+
assert f.check_token("dogechain", "x")["error"] == "bad_input"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_prices_cover_every_check():
|
|
59
|
+
assert sorted(PRICES) == ["check_before_signing", "check_endpoint_before_paying", "check_token", "check_wallet_approvals"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_langchain_tools_call_the_checks():
|
|
63
|
+
pytest.importorskip("langchain_core")
|
|
64
|
+
from fizzl.langchain import fizzl_tools
|
|
65
|
+
s = StandIn({"/api/v1/preflight": (200, {"verdict": "no_go", "summary": "Do not pay: over budget."})})
|
|
66
|
+
tools = fizzl_tools(session=s)
|
|
67
|
+
assert [t.name for t in tools] == ["check_before_signing", "check_token", "check_wallet_approvals", "check_endpoint_before_paying"]
|
|
68
|
+
pre = next(t for t in tools if t.name == "check_endpoint_before_paying")
|
|
69
|
+
assert "BEFORE paying" in pre.description
|
|
70
|
+
assert pre.invoke({"url": "https://api.example.com/x", "max_usd": 0.01})["verdict"] == "no_go"
|
|
71
|
+
assert "max_usd=0.01" in s.calls[0]["url"]
|
|
72
|
+
assert [t.name for t in fizzl_tools(session=s, only=["check_token"])] == ["check_token"]
|
|
73
|
+
with pytest.raises(ValueError):
|
|
74
|
+
fizzl_tools(session=s, only=["nope"])
|