feeless402 0.2.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,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: feeless402
3
+ Version: 0.2.0
4
+ Summary: Feeless x402 micropayments for AI agents — self-custodied XNO wallet, pay-per-call client, merchant server with railHint, faucet, and MCP server
5
+ License: MIT
6
+ Project-URL: Homepage, https://feeless402.com
7
+ Project-URL: Specification, https://railhint.com
8
+ Project-URL: Source, https://github.com/feeless402/feeless402
9
+ Keywords: x402,micropayments,ai-agents,nano,xno,http-402,mcp,railhint
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP
15
+ Classifier: Topic :: Office/Business :: Financial
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: nanopy
20
+ Requires-Dist: requests
21
+ Provides-Extra: server
22
+ Requires-Dist: fastapi; extra == "server"
23
+ Requires-Dist: uvicorn[standard]; extra == "server"
24
+ Provides-Extra: mcp
25
+ Requires-Dist: mcp>=2.0; extra == "mcp"
26
+ Provides-Extra: all
27
+ Requires-Dist: fastapi; extra == "all"
28
+ Requires-Dist: uvicorn[standard]; extra == "all"
29
+ Requires-Dist: mcp>=2.0; extra == "all"
30
+ Dynamic: license-file
31
+
32
+ # nano-pay · Feeless402
33
+
34
+ Self-custodied Nano (XNO) wallet + x402 payment client **and** merchant
35
+ server, built for AI agents. Client spends; `nano-pay serve` earns — paid
36
+ endpoints, on-ledger verification/settlement (no facilitator), a starter
37
+ faucet, and the `railHint` x402 extension that teaches visiting agents how
38
+ to onboard (see SPEC-railhint.md). Site: site/index.html + site/llms.txt.
39
+
40
+ **The pitch, in one number:** the same $5 buys 5,000 API calls paid as
41
+ per-call USDC-on-Base x402 payments (merchants floor prices at 0.001 USDC),
42
+ or ~1.8 million calls paid in Nano at the true metered price
43
+ ($0.0000027/call observed live at nano-gpt.com). Top up once, micropay
44
+ forever.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install -e . # needs Python 3.10+; pulls nanopy + requests
50
+ nano-pay init # creates ~/.nano-pay/wallet.json (chmod 600)
51
+ ```
52
+
53
+ ## Agent flow
54
+
55
+ ```bash
56
+ nano-pay topup 5 # quote: $5 USDC-BASE → ~12.3 XNO (NanSwap)
57
+ nano-pay topup 5 --execute # create order (set NANSWAP_API_KEY)
58
+ # → send USDC to the returned deposit address; XNO arrives in 30-60s
59
+ nano-pay receive # pocket incoming XNO
60
+ nano-pay quote https://nano-gpt.com/api/v1/chat/completions \
61
+ --json '{"model":"gpt-5-nano","messages":[{"role":"user","content":"hi"}]}'
62
+ nano-pay pay https://nano-gpt.com/api/v1/chat/completions \
63
+ --json '{"model":"gpt-5-nano","messages":[{"role":"user","content":"hi"}]}'
64
+ ```
65
+
66
+ `pay` handles the whole x402 handshake: request → parse the 402 quote
67
+ (both the x402nano/`PAYMENT-REQUIRED` v2 dialect and the NanoGPT/`accepts`
68
+ v1 dialect) → price-cap check → sign a send state block locally → retry
69
+ with `PAYMENT-SIGNATURE` + `X-PAYMENT` headers. The server settles the
70
+ block via its facilitator; nothing is broadcast unless the server accepts.
71
+
72
+ ## Design notes
73
+
74
+ - **Self-custody:** seed never leaves `~/.nano-pay/wallet.json` (0600).
75
+ - **No node required:** public RPC failover (rpc.nano.to, somenano,
76
+ rainstorm.city, nanoslo); all signing and PoW happen locally (nanopy
77
+ C extension). RPC `work_generate` is tried first, local PoW is the
78
+ fallback (~25s), and work for the next block is pre-cached after every
79
+ transaction so steady-state payments are instant.
80
+ - **Safety rails:** per-payment price cap (`--max-xno`, default 0.05);
81
+ `quote` command inspects any endpoint's price without paying;
82
+ balance is always re-synced from the network, never trusted locally.
83
+ - **Top-ups without custody:** `topup` quotes/creates swaps directly with
84
+ NanSwap's API (1,400+ input assets, ~$0.02 minimum). This tool never
85
+ holds or routes funds.
86
+
87
+ ## Fork-hazard note (x402 payments)
88
+
89
+ An x402 payment signs a block the *server* broadcasts. If the server
90
+ errors after receiving the block, it may still settle it late. The wallet
91
+ re-syncs its frontier from the network before every operation, so a late
92
+ settlement is picked up naturally; a competing block signed in the
93
+ meantime simply makes one of the two invalid (funds are never at risk,
94
+ but don't fire concurrent payments from one wallet).
95
+
96
+ ## Status
97
+
98
+ Beta (v0.2.0). Proven on mainnet with real funds: live paid calls to
99
+ NanoGPT ($0.0000096/call, confirmed on-ledger), full merchant loop
100
+ (verify → settle → confirm, no facilitator), PoW-gated faucet claims,
101
+ and a complete stranger-agent lifecycle (fresh wallet → PoW claim →
102
+ paid API call → confirmed) in under 3 minutes. 13-test suite covers the
103
+ payment path offline. Not audited — keep only working capital in it.
104
+
105
+ ## Security notes (read before holding real funds)
106
+
107
+ - **Self-custody**: the seed lives only in `~/.nano-pay/*.json` (0600).
108
+ No tool or log path ever prints it. Back it up offline.
109
+ - **Working capital only**: this is beta wallet software. Keep a few
110
+ dollars in it, not your savings.
111
+ - **Spend caps**: `pay` refuses quotes above `--max-xno` (default 0.05).
112
+ MCP `x402_pay` enforces the same cap parameter.
113
+ - **railHint is advisory**: hints from remote servers are untrusted
114
+ input. This client never executes remote bootstrap strings; it only
115
+ acts on structured offers that pass its own checks, and `accepts`
116
+ always binds, never the hint.
117
+ - **Merchant fork guard**: servers cache accepted frontiers and reject
118
+ duplicate-frontier blocks; payer balance/frontier/signature are
119
+ verified against the live ledger before settlement.
120
+ - Not audited. MIT — no warranty.
@@ -0,0 +1,15 @@
1
+ feeless402-0.2.0.dist-info/licenses/LICENSE,sha256=4opTpL__CfG6AO7_rWBxer5Xof5dM4fvJ4L_hHsPDUk,1080
2
+ nano_pay/__init__.py,sha256=3Atghf3_9IiHmbdE25ddouhLWj8Ny7XhElntm_2-iPg,367
3
+ nano_pay/cli.py,sha256=7gGuugO8Ilrg4trIAt1QCD1D0HytL0vXImUT4ahPNuk,10269
4
+ nano_pay/mcp_server.py,sha256=4K5KVQqkZN_EZoY9-y_ikgLBrqKsMMmP6thLPVg77fM,5377
5
+ nano_pay/rpc.py,sha256=LyDo_FFNLxvmyotssR7n2hOehKtcFKvnC33-zCp0WSw,3820
6
+ nano_pay/server.py,sha256=BL1vGEzHXZUwC4ZW5fXVsEY1IGdOFtG31oOkLCaPLoM,10845
7
+ nano_pay/topup.py,sha256=nd8X58Qgx3E7YRxy2vHY1jIDPv8EdmnpvxNyWz-uUBc,2653
8
+ nano_pay/verify.py,sha256=JY227KYAVMYPJnejIbUVl9zl9BONnpG5mYyKjQBOuNo,3564
9
+ nano_pay/wallet.py,sha256=_e8nz2_aTmg9qYZtSd3h8RI_gp9VIorENVFXy9FCZ2k,7426
10
+ nano_pay/x402.py,sha256=j4z7DSrLvP6LW9P6FkN8UnmNSihNmNPGn9ut4gdbNYo,8625
11
+ feeless402-0.2.0.dist-info/METADATA,sha256=ZnLsWY4w-8AfndEN5eK0V8obOX1Cs2txewK-HPAC2K0,5590
12
+ feeless402-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ feeless402-0.2.0.dist-info/entry_points.txt,sha256=MgndzNFIsSCptCxjFrRcIqwYnS2dixB36jYBjbESdFk,72
14
+ feeless402-0.2.0.dist-info/top_level.txt,sha256=aHS4ae6qB86LlnQ4kEbRjt59opAOgacx0OH61YjEhnM,9
15
+ feeless402-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ f402 = nano_pay.cli:main
3
+ nano-pay = nano_pay.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Feeless402 contributors
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 @@
1
+ nano_pay
nano_pay/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """nano-pay: self-custodied Nano (XNO) wallet + x402 payment client for AI agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ RAW_PER_XNO = 10**30
6
+
7
+
8
+ def xno_to_raw(xno) -> int:
9
+ from decimal import Decimal
10
+
11
+ return int(Decimal(str(xno)) * RAW_PER_XNO)
12
+
13
+
14
+ def raw_to_xno(raw: int) -> str:
15
+ from decimal import Decimal
16
+
17
+ return str((Decimal(raw) / RAW_PER_XNO).normalize())
nano_pay/cli.py ADDED
@@ -0,0 +1,330 @@
1
+ """nano-pay CLI — agent-friendly: everything prints compact JSON."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from . import raw_to_xno, xno_to_raw
8
+ from .rpc import RPC
9
+ from .topup import TopupError, create_order, estimate, limits
10
+ from .wallet import Wallet, WalletError
11
+ from .x402 import PriceCapExceeded, X402Error, request_with_payment
12
+
13
+ DEFAULT_MAX_PAY_XNO = "0.05" # per-payment safety cap unless overridden
14
+
15
+
16
+ def out(obj, code=0):
17
+ print(json.dumps(obj, indent=2, default=str))
18
+ sys.exit(code)
19
+
20
+
21
+ def cmd_init(args):
22
+ w = Wallet()
23
+ if w.exists() and not args.seed:
24
+ w.load()
25
+ out({"status": "exists", "address": w.address, "file": str(w.path)})
26
+ w.create(seed=args.seed)
27
+ out(
28
+ {
29
+ "status": "created",
30
+ "address": w.address,
31
+ "file": str(w.path),
32
+ "note": "fund this address with XNO (see `nano-pay topup`), "
33
+ "then run `nano-pay receive`",
34
+ }
35
+ )
36
+
37
+
38
+ def cmd_address(args):
39
+ out({"address": Wallet().load().address})
40
+
41
+
42
+ def cmd_status(args):
43
+ w = Wallet().load()
44
+ rpc = RPC()
45
+ acct = w.synced_account(rpc)
46
+ pending = rpc.receivable(w.address)
47
+ out(
48
+ {
49
+ "address": w.address,
50
+ "balance_xno": raw_to_xno(acct.raw_bal),
51
+ "balance_raw": str(acct.raw_bal),
52
+ "receivable_count": len(pending),
53
+ "receivable_xno": raw_to_xno(sum(pending.values())),
54
+ "opened": acct.frontier != "0" * 64,
55
+ }
56
+ )
57
+
58
+
59
+ def cmd_receive(args):
60
+ w = Wallet().load()
61
+ rpc = RPC()
62
+ got = w.receive_all(rpc)
63
+ acct = w.synced_account(rpc)
64
+ out(
65
+ {
66
+ "received": [
67
+ {"hash": h, "amount_xno": raw_to_xno(a)} for h, a in got
68
+ ],
69
+ "balance_xno": raw_to_xno(acct.raw_bal),
70
+ }
71
+ )
72
+
73
+
74
+ def cmd_send(args):
75
+ w = Wallet().load()
76
+ rpc = RPC()
77
+ h = w.send(rpc, args.to, xno_to_raw(args.amount))
78
+ out({"status": "sent", "hash": h, "to": args.to, "amount_xno": args.amount})
79
+
80
+
81
+ def cmd_prework(args):
82
+ w = Wallet().load()
83
+ rpc = RPC()
84
+ acct = w.synced_account(rpc)
85
+ root = acct.pk if acct.frontier == "0" * 64 else acct.frontier
86
+ w.prework(root, rpc)
87
+ out({"status": "work cached", "root": root})
88
+
89
+
90
+ def _do_request(args, dry_run):
91
+ w = Wallet().load() if not dry_run else (Wallet().load() if Wallet().exists() else None)
92
+ rpc = RPC()
93
+ kwargs = {}
94
+ if args.json:
95
+ kwargs["json"] = json.loads(args.json)
96
+ headers = {}
97
+ for hdr in args.header or []:
98
+ k, _, v = hdr.partition(":")
99
+ headers[k.strip()] = v.strip()
100
+ method = args.method or ("POST" if args.json else "GET")
101
+ r, info = request_with_payment(
102
+ method,
103
+ args.url,
104
+ w,
105
+ rpc,
106
+ max_raw=xno_to_raw(args.max_xno),
107
+ headers=headers,
108
+ dry_run=dry_run,
109
+ **kwargs,
110
+ )
111
+ body = r.text
112
+ try:
113
+ body = r.json()
114
+ except Exception:
115
+ pass
116
+ out(
117
+ {
118
+ "status_code": r.status_code,
119
+ "paid": (not dry_run) and info is not None,
120
+ "payment": info,
121
+ "body": body if not args.body_only else None,
122
+ }
123
+ if not args.body_only
124
+ else body
125
+ )
126
+
127
+
128
+ def cmd_quote(args):
129
+ args.max_xno = DEFAULT_MAX_PAY_XNO
130
+ args.body_only = False
131
+ _do_request(args, dry_run=True)
132
+
133
+
134
+ def cmd_pay(args):
135
+ _do_request(args, dry_run=False)
136
+
137
+
138
+ def cmd_topup(args):
139
+ w = Wallet().load()
140
+ try:
141
+ if args.execute:
142
+ order = create_order(args.asset, args.amount, w.address)
143
+ out({"status": "order created", "order": order})
144
+ est = estimate(args.asset, args.amount)
145
+ lim = limits(args.asset)
146
+ out(
147
+ {
148
+ "from": f"{args.amount} {args.asset.upper()}",
149
+ "estimated_xno": est,
150
+ "limits": lim,
151
+ "receive_address": w.address,
152
+ "note": "re-run with --execute (needs NANSWAP_API_KEY) to "
153
+ "create the order, then send the deposit",
154
+ }
155
+ )
156
+ except TopupError as e:
157
+ out({"error": str(e)}, code=1)
158
+
159
+
160
+ def cmd_compare(args):
161
+ import requests as rq
162
+
163
+ from .x402 import compare_rails, parse_quote
164
+
165
+ headers = {"x-x402": "true"}
166
+ for hdr in args.header or []:
167
+ k, _, v = hdr.partition(":")
168
+ headers[k.strip()] = v.strip()
169
+ kwargs = {"json": json.loads(args.json)} if args.json else {}
170
+ method = args.method or ("POST" if args.json else "GET")
171
+ r = rq.request(method, args.url, headers=headers, timeout=60, **kwargs)
172
+ if r.status_code != 402:
173
+ out({"note": f"endpoint returned {r.status_code}, not 402 — nothing to compare"})
174
+ rails = compare_rails(parse_quote(r))
175
+ out({
176
+ "endpoint": args.url,
177
+ "rails": rails,
178
+ "note": "prices quoted live by the server just now — verify, don't trust",
179
+ })
180
+
181
+
182
+ def cmd_serve(args):
183
+ from .server import serve
184
+
185
+ serve(port=args.port, host=args.host)
186
+
187
+
188
+ def _claim_one(base, w):
189
+ """Claim from one faucet, solving its PoW challenge if it has one."""
190
+ import requests as rq
191
+
192
+ base = base.rstrip("/")
193
+ payload = {"address": w.address}
194
+ try:
195
+ ch = rq.get(
196
+ f"{base}/faucet/challenge",
197
+ params={"address": w.address},
198
+ timeout=30,
199
+ ).json()
200
+ if ch.get("pow_required"):
201
+ import nanopy
202
+
203
+ payload["work"] = (
204
+ f"{nanopy.ext.work_generate(bytes.fromhex(ch['root']), int(ch['difficulty'], 16), __import__('os').urandom(128)):016x}"
205
+ )
206
+ except Exception:
207
+ pass # no challenge endpoint — plain claim
208
+ r = rq.post(f"{base}/faucet", json=payload, timeout=240)
209
+ try:
210
+ return r.status_code, r.json()
211
+ except Exception:
212
+ return r.status_code, {"raw": r.text[:300]}
213
+
214
+
215
+ def cmd_claim(args):
216
+ w = Wallet().load()
217
+ faucets = [args.url] if args.url else []
218
+ if args.auto:
219
+ import requests as rq
220
+
221
+ for registry in (args.url, "https://feeless402.com"):
222
+ if not registry:
223
+ continue
224
+ try:
225
+ hint = rq.get(
226
+ f"{registry.rstrip('/')}/.well-known/railhint.json",
227
+ timeout=20,
228
+ ).json()
229
+ faucets += [u for u in hint.get("faucets", []) if u not in faucets]
230
+ except Exception:
231
+ pass
232
+ attempts = []
233
+ for base in faucets:
234
+ code, res = _claim_one(base, w)
235
+ attempts.append({"faucet": base, "status": code, "result": res})
236
+ if code == 200:
237
+ rpc = RPC()
238
+ got = w.receive_all(rpc)
239
+ acct = w.synced_account(rpc)
240
+ out(
241
+ {
242
+ "claimed_from": base,
243
+ "result": res,
244
+ "received": [
245
+ {"hash": h, "amount_xno": raw_to_xno(a)}
246
+ for h, a in got
247
+ ],
248
+ "balance_xno": raw_to_xno(acct.raw_bal),
249
+ "attempts": attempts,
250
+ }
251
+ )
252
+ out({"error": "no faucet granted a claim", "attempts": attempts}, code=1)
253
+
254
+
255
+ def main():
256
+ p = argparse.ArgumentParser(
257
+ prog="nano-pay",
258
+ description="Self-custodied Nano wallet + x402 payment client for agents",
259
+ )
260
+ sub = p.add_subparsers(dest="cmd", required=True)
261
+
262
+ s = sub.add_parser("init", help="create wallet")
263
+ s.add_argument("--seed", help="import existing 64-hex seed")
264
+ s.set_defaults(fn=cmd_init)
265
+
266
+ s = sub.add_parser("address", help="show wallet address")
267
+ s.set_defaults(fn=cmd_address)
268
+
269
+ s = sub.add_parser("status", help="balance + receivable")
270
+ s.set_defaults(fn=cmd_status)
271
+
272
+ s = sub.add_parser("receive", help="pocket all incoming XNO")
273
+ s.set_defaults(fn=cmd_receive)
274
+
275
+ s = sub.add_parser("send", help="send XNO directly")
276
+ s.add_argument("to")
277
+ s.add_argument("amount", help="amount in XNO")
278
+ s.set_defaults(fn=cmd_send)
279
+
280
+ s = sub.add_parser("prework", help="pre-compute PoW for next block")
281
+ s.set_defaults(fn=cmd_prework)
282
+
283
+ for name, fn, hlp in (
284
+ ("quote", cmd_quote, "show an endpoint's x402 quote WITHOUT paying"),
285
+ ("pay", cmd_pay, "request URL, auto-paying its Nano x402 quote"),
286
+ ("compare", cmd_compare, "price the same call on every rail the server offers"),
287
+ ):
288
+ s = sub.add_parser(name, help=hlp)
289
+ s.add_argument("url")
290
+ s.add_argument("--method")
291
+ s.add_argument("--json", help="JSON request body")
292
+ s.add_argument("--header", action="append", help="extra 'K: V' header")
293
+ s.add_argument("--body-only", action="store_true")
294
+ if name == "pay":
295
+ s.add_argument(
296
+ "--max-xno",
297
+ default=DEFAULT_MAX_PAY_XNO,
298
+ help=f"refuse quotes above this (default {DEFAULT_MAX_PAY_XNO})",
299
+ )
300
+ s.set_defaults(fn=fn)
301
+
302
+ s = sub.add_parser("topup", help="quote/execute small swap into XNO")
303
+ s.add_argument("amount", type=float, help="amount of source asset")
304
+ s.add_argument("--asset", default="USDC-BASE", help="source asset w/ network suffix, e.g. USDC-BASE, USDT-SOL (default USDC-BASE)")
305
+ s.add_argument("--execute", action="store_true")
306
+ s.set_defaults(fn=cmd_topup)
307
+
308
+ s = sub.add_parser("serve", help="run the Feeless402 paid-API + faucet server")
309
+ s.add_argument("--port", type=int, default=8402)
310
+ s.add_argument("--host", default="127.0.0.1")
311
+ s.set_defaults(fn=cmd_serve)
312
+
313
+ s = sub.add_parser("mcp", help="run as an MCP server (stdio) for agent frameworks")
314
+ s.set_defaults(fn=lambda a: __import__("nano_pay.mcp_server", fromlist=["main"]).main())
315
+
316
+ s = sub.add_parser("claim", help="claim free starter XNO from a Feeless402 faucet")
317
+ s.add_argument("url", nargs="?", help="faucet base URL (optional with --auto)")
318
+ s.add_argument("--auto", action="store_true",
319
+ help="cycle the faucet federation list until one grants")
320
+ s.set_defaults(fn=cmd_claim)
321
+
322
+ args = p.parse_args()
323
+ try:
324
+ args.fn(args)
325
+ except (WalletError, X402Error, PriceCapExceeded) as e:
326
+ out({"error": str(e)}, code=1)
327
+
328
+
329
+ if __name__ == "__main__":
330
+ main()
nano_pay/mcp_server.py ADDED
@@ -0,0 +1,167 @@
1
+ """Feeless402 MCP server — feeless x402 micropayments as agent tools.
2
+
3
+ Any MCP-speaking agent (Claude, Cursor, OpenClaw bridges, ...) can load
4
+ this and gain the ability to price, pay for, and earn from x402 APIs
5
+ over the feeless rail.
6
+
7
+ Run: nano-pay mcp (stdio transport)
8
+ Config example (Claude Code):
9
+ claude mcp add feeless402 -- nano-pay mcp
10
+ """
11
+
12
+ import json
13
+
14
+ from mcp.server.mcpserver import MCPServer
15
+
16
+ from . import raw_to_xno, xno_to_raw
17
+ from .rpc import RPC
18
+ from .wallet import Wallet
19
+
20
+ INSTRUCTIONS = """Pay for x402-priced HTTP APIs (HTTP 402 Payment Required)
21
+ with feeless, sub-second Nano (XNO) micropayments from a self-custodied
22
+ local wallet.
23
+
24
+ The economics, verifiable via x402_compare: gas rails floor micro-prices
25
+ (e.g. 0.001 USDC minimum ~ 370x a sub-millicent call); the feeless rail
26
+ quotes the true metered price. Strategy: top up once (or claim free
27
+ starter XNO from a faucet), then micropay per call at marginal cost zero.
28
+
29
+ Safety: payments are refused above max_xno (default 0.05). The wallet
30
+ seed never leaves the local machine and is never returned by any tool.
31
+ Keep only working capital in this wallet."""
32
+
33
+ mcp = MCPServer(
34
+ name="feeless402",
35
+ instructions=INSTRUCTIONS,
36
+ website_url="https://feeless402.com",
37
+ )
38
+
39
+
40
+ def _rpc():
41
+ return RPC()
42
+
43
+
44
+ @mcp.tool(description="Wallet address, XNO balance, and pending incoming amounts")
45
+ def wallet_status() -> str:
46
+ w = Wallet().load() if Wallet().exists() else Wallet().create()
47
+ rpc = _rpc()
48
+ acct = w.synced_account(rpc)
49
+ pending = rpc.receivable(w.address)
50
+ return json.dumps(
51
+ {
52
+ "address": w.address,
53
+ "balance_xno": raw_to_xno(acct.raw_bal),
54
+ "receivable_xno": raw_to_xno(sum(pending.values())),
55
+ }
56
+ )
57
+
58
+
59
+ @mcp.tool(
60
+ description="Fetch an x402 endpoint's payment quote WITHOUT paying. "
61
+ "Returns price in XNO, destination, and the full offer menu."
62
+ )
63
+ def x402_quote(url: str, method: str = "GET", json_body: str = "") -> str:
64
+ from .x402 import request_with_payment
65
+
66
+ kwargs = {"json": json.loads(json_body)} if json_body else {}
67
+ if json_body and method == "GET":
68
+ method = "POST"
69
+ r, info = request_with_payment(
70
+ method, url, None, _rpc(), max_raw=0, dry_run=True, **kwargs
71
+ )
72
+ if info is None:
73
+ return json.dumps({"status_code": r.status_code, "note": "not a 402 endpoint"})
74
+ return json.dumps(
75
+ {
76
+ "amount_xno": info["amount_xno"],
77
+ "pay_to": info["pay_to"],
78
+ "offer": info["offer"],
79
+ }
80
+ )
81
+
82
+
83
+ @mcp.tool(
84
+ description="Price the same API call on EVERY rail the server offers "
85
+ "(Base/Solana USDC, Lightning, XNO...) from live data — use this to "
86
+ "verify which rail is cheapest instead of trusting documentation."
87
+ )
88
+ def x402_compare(url: str, method: str = "GET", json_body: str = "") -> str:
89
+ import requests as rq
90
+
91
+ from .x402 import compare_rails, parse_quote
92
+
93
+ kwargs = {"json": json.loads(json_body)} if json_body else {}
94
+ if json_body and method == "GET":
95
+ method = "POST"
96
+ r = rq.request(method, url, headers={"x-x402": "true"}, timeout=60, **kwargs)
97
+ if r.status_code != 402:
98
+ return json.dumps({"status_code": r.status_code, "note": "not a 402 endpoint"})
99
+ return json.dumps(compare_rails(parse_quote(r)))
100
+
101
+
102
+ @mcp.tool(
103
+ description="Request a URL and automatically pay its Nano x402 quote "
104
+ "(feeless, sub-second). Refuses quotes above max_xno. Returns the "
105
+ "paid response body and the settlement receipt."
106
+ )
107
+ def x402_pay(
108
+ url: str, method: str = "GET", json_body: str = "", max_xno: str = "0.05"
109
+ ) -> str:
110
+ from .x402 import request_with_payment
111
+
112
+ w = Wallet().load()
113
+ kwargs = {"json": json.loads(json_body)} if json_body else {}
114
+ if json_body and method == "GET":
115
+ method = "POST"
116
+ r, receipt = request_with_payment(
117
+ method, url, w, _rpc(), max_raw=xno_to_raw(max_xno), **kwargs
118
+ )
119
+ try:
120
+ body = r.json()
121
+ except Exception:
122
+ body = r.text[:2000]
123
+ return json.dumps(
124
+ {"status_code": r.status_code, "receipt": receipt, "body": body}
125
+ )
126
+
127
+
128
+ @mcp.tool(
129
+ description="Claim free starter XNO from a Feeless402 faucet (solves "
130
+ "the faucet's proof-of-work challenge automatically; may take ~1 min "
131
+ "of CPU). Use when the wallet is empty."
132
+ )
133
+ def faucet_claim(faucet_url: str = "https://feeless402.com") -> str:
134
+ from .cli import _claim_one
135
+
136
+ w = Wallet().load() if Wallet().exists() else Wallet().create()
137
+ code, res = _claim_one(faucet_url, w)
138
+ if code == 200:
139
+ got = w.receive_all(_rpc())
140
+ res["received_xno"] = raw_to_xno(sum(a for _, a in got))
141
+ return json.dumps({"status": code, "result": res})
142
+
143
+
144
+ @mcp.tool(
145
+ description="Quote a small swap from another asset (e.g. USDC-BASE, "
146
+ "USDT-SOL) into XNO for topping up the wallet. Execution requires "
147
+ "NANSWAP_API_KEY; this tool only quotes."
148
+ )
149
+ def topup_quote(amount: float = 5.0, asset: str = "USDC-BASE") -> str:
150
+ from .topup import estimate, limits
151
+
152
+ w = Wallet().load() if Wallet().exists() else Wallet().create()
153
+ return json.dumps(
154
+ {
155
+ "estimate": estimate(asset, amount),
156
+ "limits": limits(asset),
157
+ "receive_address": w.address,
158
+ }
159
+ )
160
+
161
+
162
+ def main():
163
+ mcp.run("stdio")
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()