settled-x402 0.1.0__py3-none-any.whl

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,113 @@
1
+ """settled-x402: check an x402 quote against Settled's independent evidence before your agent pays.
2
+
3
+ from settled_x402 import settled_guard
4
+ settled_guard(x402_client, max_usd_per_call=0.25, daily_budget_usd=5)
5
+
6
+ Docs: https://settled.tools/for-agents · the JavaScript twin: npm `settled-x402`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from .core import (
14
+ DEFAULT_BASE_URL,
15
+ VERSION,
16
+ Answer,
17
+ Checker,
18
+ EndpointInfo,
19
+ OptionResult,
20
+ ParsedAccept,
21
+ ParsedQuote,
22
+ QuoteSummary,
23
+ SettledBlockedError,
24
+ SettledResult,
25
+ SettledSummary,
26
+ acheck,
27
+ ainspect,
28
+ assess,
29
+ block_message,
30
+ check,
31
+ decode_payment_required_header,
32
+ inspect,
33
+ normalize_network,
34
+ parse_quote,
35
+ quote_from_response,
36
+ )
37
+ from .guard import guard_all, settled_guard
38
+ from .spend import (
39
+ RARITIES,
40
+ RARITY_LEGENDARY_OVER_USD,
41
+ FileLedger,
42
+ LedgerInfo,
43
+ MemoryLedger,
44
+ SpendInfo,
45
+ SpendLedger,
46
+ SpendLimits,
47
+ SpendQuestion,
48
+ SpendToday,
49
+ default_budget_file,
50
+ rarity_of,
51
+ spent_today,
52
+ utc_day,
53
+ )
54
+
55
+ __version__ = VERSION
56
+
57
+ __all__ = [
58
+ "VERSION",
59
+ "__version__",
60
+ "DEFAULT_BASE_URL",
61
+ # entry points
62
+ "check",
63
+ "acheck",
64
+ "inspect",
65
+ "ainspect",
66
+ "settled_guard",
67
+ "guard_all",
68
+ "SettledAsyncTransport",
69
+ "SettledTransport",
70
+ # results and errors
71
+ "SettledResult",
72
+ "SettledBlockedError",
73
+ "QuoteSummary",
74
+ "OptionResult",
75
+ "SettledSummary",
76
+ "EndpointInfo",
77
+ "Answer",
78
+ # quotes
79
+ "parse_quote",
80
+ "quote_from_response",
81
+ "decode_payment_required_header",
82
+ "ParsedQuote",
83
+ "ParsedAccept",
84
+ "normalize_network",
85
+ # the pieces
86
+ "assess",
87
+ "block_message",
88
+ "Checker",
89
+ # spending limits and rarity
90
+ "rarity_of",
91
+ "RARITIES",
92
+ "RARITY_LEGENDARY_OVER_USD",
93
+ "spent_today",
94
+ "SpendToday",
95
+ "SpendInfo",
96
+ "SpendLimits",
97
+ "SpendQuestion",
98
+ "SpendLedger",
99
+ "FileLedger",
100
+ "MemoryLedger",
101
+ "LedgerInfo",
102
+ "default_budget_file",
103
+ "utc_day",
104
+ ]
105
+
106
+
107
+ def __getattr__(name: str) -> Any:
108
+ # The httpx transports import httpx, so they load on first use.
109
+ if name in ("SettledAsyncTransport", "SettledTransport"):
110
+ from . import guard
111
+
112
+ return getattr(guard, name)
113
+ raise AttributeError(f"module 'settled_x402' has no attribute {name!r}")
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
@@ -0,0 +1,138 @@
1
+ # Network and asset tables, copied from settled-x402 (JavaScript) so both guards read a quote the same way.
2
+ # x402 v1 network names as CAIP-2 ids (@x402/evm's v1 table, plus the names Settled also accepts).
3
+
4
+ V1_NETWORKS: dict[str, str] = {
5
+ "ethereum": "eip155:1",
6
+ "sepolia": "eip155:11155111",
7
+ "abstract": "eip155:2741",
8
+ "abstract-testnet": "eip155:11124",
9
+ "base": "eip155:8453",
10
+ "base-sepolia": "eip155:84532",
11
+ "avalanche": "eip155:43114",
12
+ "avalanche-fuji": "eip155:43113",
13
+ "iotex": "eip155:4689",
14
+ "sei": "eip155:1329",
15
+ "sei-testnet": "eip155:1328",
16
+ "polygon": "eip155:137",
17
+ "polygon-amoy": "eip155:80002",
18
+ "peaq": "eip155:3338",
19
+ "story": "eip155:1514",
20
+ "educhain": "eip155:41923",
21
+ "skale-base-sepolia": "eip155:324705682",
22
+ "megaeth": "eip155:4326",
23
+ "monad": "eip155:143",
24
+ "monad-testnet": "eip155:10143",
25
+ "stable": "eip155:988",
26
+ "stable-testnet": "eip155:2201",
27
+ "celo": "eip155:42220",
28
+ "flare": "eip155:14",
29
+ "arbitrum": "eip155:42161",
30
+ "optimism": "eip155:10",
31
+ "solana": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
32
+ "solana-devnet": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
33
+ }
34
+ NETWORK_NAMES: dict[str, str] = {v: k for k, v in V1_NETWORKS.items()}
35
+
36
+ # USD stablecoins and their decimals by network: @x402/evm 2.28's default assets, plus USDbC on Base, Optimism USDC and
37
+ # Solana USDC. EVM addresses are lowercased; Solana mints keep their case.
38
+ USD_ASSETS: dict[str, dict[str, int]] = {
39
+ "eip155:8453": {
40
+ "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": 6,
41
+ "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca": 6,
42
+ },
43
+ "eip155:84532": {
44
+ "0x036cbd53842c5426634e7929541ec2318f3dcf7e": 6,
45
+ },
46
+ "eip155:1": {
47
+ "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": 6,
48
+ },
49
+ "eip155:43114": {
50
+ "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e": 6,
51
+ },
52
+ "eip155:4326": {
53
+ "0xfafddbb3fc7688494971a79cc65dca3ef82079e7": 18,
54
+ },
55
+ "eip155:143": {
56
+ "0x754704bc059f8c67012fed69bc8a327a5aafb603": 6,
57
+ },
58
+ "eip155:10143": {
59
+ "0x534b2f3a21130d7a60830c2df862319e593943a3": 6,
60
+ },
61
+ "eip155:988": {
62
+ "0x779ded0c9e1022225f8e0630b35a9b54be713736": 6,
63
+ },
64
+ "eip155:2201": {
65
+ "0x78cf24370174180738c5b8e352b6d14c83a6c9a9": 6,
66
+ },
67
+ "eip155:137": {
68
+ "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359": 6,
69
+ },
70
+ "eip155:42161": {
71
+ "0xaf88d065e77c8cc2239327c5edb3a432268e5831": 6,
72
+ },
73
+ "eip155:421614": {
74
+ "0x75faf114eafb1bdbe2f0316df893fd58ce46aa4d": 6,
75
+ },
76
+ "eip155:31612": {
77
+ "0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186": 18,
78
+ },
79
+ "eip155:31611": {
80
+ "0x118917a40faf1cd7a13db0ef56c86de7973ac503": 18,
81
+ },
82
+ "eip155:723487": {
83
+ "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb": 6,
84
+ },
85
+ "eip155:72344": {
86
+ "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb": 6,
87
+ },
88
+ "eip155:36900": {
89
+ "0x9cb8142aebbcdc60af7c97af897a67a8f3ca71c2": 6,
90
+ },
91
+ "eip155:190415": {
92
+ "0x401ecb1d350407f13ba348573e5630b83638e30d": 6,
93
+ },
94
+ "eip155:181228": {
95
+ "0x401ecb1d350407f13ba348573e5630b83638e30d": 6,
96
+ },
97
+ "eip155:50": {
98
+ "0xfa2958cb79b0491cc627c1557f441ef849ca8eb1": 6,
99
+ },
100
+ "eip155:51": {
101
+ "0xb5ab69f7bbada22b28e79c8ffaece55ef1c771d4": 6,
102
+ },
103
+ "eip155:38833": {
104
+ "0xa5b8bf902b2844da17d4506cc827f7f1681735e7": 6,
105
+ },
106
+ "eip155:14": {
107
+ "0xe7cd86e13ac4309349f30b3435a9d337750fc82d": 6,
108
+ },
109
+ "eip155:42220": {
110
+ "0xceba9300f2b948710d2653dd7b07f33a8b32118c": 6,
111
+ "0x48065fbbe25f71c9282ddf5e1cd6d6a887483d5e": 6,
112
+ "0xd2ab3c9a02dbbab236bfec45d1d755df4267f771": 6,
113
+ },
114
+ "eip155:11142220": {
115
+ "0x01c5c0122039549ad1493b8220cabedd739bc44e": 6,
116
+ },
117
+ "eip155:1329": {
118
+ "0xe15fc38f6d8c56af07bbcbe3baf5708a2bf42392": 6,
119
+ },
120
+ "eip155:1328": {
121
+ "0x4fcf1784b31630811181f670aea7a7bef803eaed": 6,
122
+ },
123
+ "eip155:5042": {
124
+ "0x3600000000000000000000000000000000000000": 6,
125
+ },
126
+ "eip155:5042002": {
127
+ "0x3600000000000000000000000000000000000000": 6,
128
+ },
129
+ "eip155:10": {
130
+ "0x0b2c639c533813f4aa9d7837caf62653d097ff85": 6,
131
+ },
132
+ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": {
133
+ "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": 6,
134
+ },
135
+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": {
136
+ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": 6,
137
+ },
138
+ }
settled_x402/cli.py ADDED
@@ -0,0 +1,184 @@
1
+ """settled-x402 on the command line: `settled-x402 check <url>` and `settled-x402 spend`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from typing import Any
11
+
12
+ from .core import VERSION, inspect, normalize_network
13
+ from .spend import RARITIES, spent_today
14
+
15
+ HELP = f"""settled-x402 {VERSION}: check x402 endpoints against Settled before paying
16
+
17
+ Usage:
18
+ settled-x402 check <url> [--network <name>] [--post] [--json] [--pass <token>]
19
+ Read the endpoint's 402 quote (nothing is paid) and ask Settled about it.
20
+ --network picks the option you would pay on (default: the network Settled has on record).
21
+ Exit code: 0 pay, 2 caution, 3 avoid, 4 unknown, 1 usage or internal error.
22
+ settled-x402 spend [--budget-file <path>] [--json]
23
+ Today's tally from the spend ledger a guard with daily_budget_usd keeps (default: SETTLED_BUDGET_FILE,
24
+ else ~/.settled-x402/spend.json): what the guard let through, per call, with each call's loot rarity.
25
+ settled-x402 --version
26
+
27
+ Environment: SETTLED_PASS (a Settled day pass), SETTLED_PAYER_TOKEN (an x4t_ payer token), SETTLED_BASE_URL,
28
+ SETTLED_ALLOW_PRIVATE_HOSTS=1.
29
+ Docs: https://settled.tools/for-agents"""
30
+
31
+ EXIT = {"pay": 0, "skipped": 0, "caution": 2, "avoid": 3, "unknown": 4}
32
+
33
+ NETS = {
34
+ "eip155:8453": "base",
35
+ "eip155:84532": "base-sepolia",
36
+ "eip155:137": "polygon",
37
+ "eip155:42161": "arbitrum",
38
+ "eip155:10": "optimism",
39
+ "eip155:1": "ethereum",
40
+ "eip155:43114": "avalanche",
41
+ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "solana",
42
+ }
43
+ MATCH = {
44
+ "match": "payTo matches Settled",
45
+ "evm_match": "same address as on Settled's network",
46
+ "mismatch": "payTo DIFFERS from Settled",
47
+ "asset_mismatch": "different asset and payTo",
48
+ "evm_mismatch": "different address",
49
+ "not_compared": "not on record at Settled",
50
+ }
51
+
52
+
53
+ def _short(a: Any) -> str:
54
+ return f"{a[:6]}…{a[-4:]}" if isinstance(a, str) and len(a) > 14 else str(a if a is not None else "?")
55
+
56
+
57
+ def _money(n: Any) -> str:
58
+ if isinstance(n, bool) or not isinstance(n, (int, float)):
59
+ return "an unknown amount"
60
+ if n >= 1:
61
+ return f"${n:.2f}"
62
+ v = float(f"{n:.4g}")
63
+ return f"${int(v)}" if v == int(v) else f"${v!r}"
64
+
65
+
66
+ def _net(n: Any) -> str:
67
+ return NETS.get(n, n or "?") if isinstance(n, str) else "?"
68
+
69
+
70
+ def run_check(target: str, args: argparse.Namespace) -> int:
71
+ from urllib.parse import urlsplit
72
+
73
+ try:
74
+ parsed = urlsplit(target)
75
+ except ValueError:
76
+ parsed = None
77
+ if parsed is None or parsed.scheme not in ("http", "https") or not parsed.hostname:
78
+ sys.stderr.write(f"Not an http(s) URL: {target}\n")
79
+ return 1
80
+ result = inspect(
81
+ target,
82
+ method="POST" if args.post else "GET",
83
+ network=normalize_network(args.network) if args.network else "auto",
84
+ pass_token=args.pass_token or os.environ.get("SETTLED_PASS"),
85
+ payer_token=os.environ.get("SETTLED_PAYER_TOKEN"),
86
+ base_url=os.environ.get("SETTLED_BASE_URL"),
87
+ allow_private_hosts=os.environ.get("SETTLED_ALLOW_PRIVATE_HOSTS") == "1",
88
+ )
89
+ live = result.endpoint
90
+ assert live is not None
91
+ if args.json:
92
+ d = result.to_dict()
93
+ d.pop("endpoint", None)
94
+ d["endpoint_http_status"] = live.http_status
95
+ d["endpoint_error"] = live.error
96
+ sys.stdout.write(json.dumps(d, indent=2) + "\n")
97
+ return EXIT[result.verdict]
98
+ loot = f" · {RARITIES[result.rarity]['label'].lower()} loot ({RARITIES[result.rarity]['rule']})" if result.rarity else ""
99
+ lines = [f"{result.verdict.upper()} · {result.url or live.url}{loot}"]
100
+ q = result.quote
101
+ if q is not None:
102
+ lines.append(f" quote {_money(q.price_usd)} on {_net(q.network)} to {_short(q.pay_to)}{f' ({q.accepts} options)' if q.accepts > 1 else ''}")
103
+ elif live.error:
104
+ lines.append(f" quote none (could not reach the endpoint: {live.error})")
105
+ elif live.http_status == 402:
106
+ lines.append(" quote unreadable (the endpoint answered 402, but its quote could not be parsed)")
107
+ else:
108
+ lines.append(f" quote none (the endpoint answered HTTP {live.http_status} without asking for payment{'' if args.post else '; try --post'})")
109
+ if len(result.options) > 1:
110
+ for i, o in enumerate(result.options):
111
+ head = " options " if i == 0 else " "
112
+ text = f"{_net(o.network)}: {_money(o.price_usd)} to {_short(o.pay_to)} · {MATCH.get(o.match, o.match)}"
113
+ lines.append(f"{head}{text if o.considered else f'({text})'}")
114
+ judged: list[str] = []
115
+ for o in result.options:
116
+ if o.considered and _net(o.network) not in judged:
117
+ judged.append(_net(o.network))
118
+ if judged and any(not o.considered for o in result.options):
119
+ lines.append(f" the verdict covers the options on {', '.join(judged)}; pick another with --network")
120
+ s = result.settled
121
+ if s is not None:
122
+ bits = ", ".join(x for x in (s.status, s.payability) if x)
123
+ scout = f" · scout's last purchase {str(s.last_delivery_at)[:10]}{f' (tx {_short(s.receipt_tx)})' if s.receipt_tx else ''}" if s.last_delivery_at else ""
124
+ lines.append(f" Settled {bits or 'no record'}{f' · {_money(s.price_usd)} on {_net(s.network)} to {_short(s.pay_to)}' if s.pay_to else ''}{scout}")
125
+ if s.recommendation:
126
+ lines.append(f" preflight {s.recommendation}")
127
+ for i, r in enumerate(result.reasons):
128
+ lines.append(f"{' why ' if i == 0 else ' '}{r}")
129
+ if result.details:
130
+ lines.append(f" details {result.details}")
131
+ sys.stdout.write("\n".join(lines) + "\n")
132
+ return EXIT[result.verdict]
133
+
134
+
135
+ def run_spend(args: argparse.Namespace) -> int:
136
+ today = spent_today(budget_file=args.budget_file or os.environ.get("SETTLED_BUDGET_FILE") or None)
137
+ if args.json:
138
+ sys.stdout.write(json.dumps(today.to_dict(), indent=2) + "\n")
139
+ return 0
140
+ lines = [f"{today.day} (UTC) · {_money(today.spent_usd)} let through in {today.calls} paid call{'' if today.calls == 1 else 's'} · ledger {today.ledger.where or today.ledger.kind}"]
141
+ for e in today.entries:
142
+ at = str(e.get("at", ""))[11:19]
143
+ lines.append(f" {at} {_money(e.get('priceUsd')):>8} {str(e.get('rarity') or '?'):<9} {e.get('url') or '?'}")
144
+ if not today.entries:
145
+ lines.append(" nothing yet today")
146
+ sys.stdout.write("\n".join(lines) + "\n")
147
+ return 0
148
+
149
+
150
+ def main(argv: Sequence[str] | None = None) -> int:
151
+ parser = argparse.ArgumentParser(prog="settled-x402", add_help=False)
152
+ parser.add_argument("command", nargs="?")
153
+ parser.add_argument("target", nargs="?")
154
+ parser.add_argument("--post", action="store_true")
155
+ parser.add_argument("--json", action="store_true")
156
+ parser.add_argument("--pass", dest="pass_token")
157
+ parser.add_argument("--network")
158
+ parser.add_argument("--budget-file", dest="budget_file")
159
+ parser.add_argument("-v", "--version", action="store_true")
160
+ parser.add_argument("-h", "--help", action="store_true")
161
+ try:
162
+ args = parser.parse_args(list(argv) if argv is not None else None)
163
+ except SystemExit:
164
+ sys.stderr.write(HELP + "\n")
165
+ return 1
166
+ if args.version:
167
+ sys.stdout.write(VERSION + "\n")
168
+ return 0
169
+ if args.help or not args.command:
170
+ sys.stdout.write(HELP + "\n")
171
+ return 0 if args.help else 1
172
+ if args.command == "check":
173
+ if not args.target:
174
+ sys.stderr.write("Usage: settled-x402 check <url>\n")
175
+ return 1
176
+ return run_check(args.target, args)
177
+ if args.command == "spend":
178
+ return run_spend(args)
179
+ sys.stderr.write(f"Unknown command: {args.command}\n\n{HELP}\n")
180
+ return 1
181
+
182
+
183
+ if __name__ == "__main__": # pragma: no cover
184
+ sys.exit(main())