feeless402 0.2.2__tar.gz → 0.2.4__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.
Files changed (23) hide show
  1. {feeless402-0.2.2 → feeless402-0.2.4}/PKG-INFO +12 -2
  2. {feeless402-0.2.2 → feeless402-0.2.4}/README.md +11 -1
  3. {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/PKG-INFO +12 -2
  4. {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/SOURCES.txt +1 -0
  5. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/__init__.py +1 -1
  6. feeless402-0.2.4/nano_pay/a2a_remote.py +230 -0
  7. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/cli.py +84 -17
  8. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/mcp_remote.py +2 -2
  9. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/rpc.py +29 -12
  10. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/server.py +256 -22
  11. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/wallet.py +23 -4
  12. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/x402.py +9 -1
  13. {feeless402-0.2.2 → feeless402-0.2.4}/pyproject.toml +1 -1
  14. {feeless402-0.2.2 → feeless402-0.2.4}/LICENSE +0 -0
  15. {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/dependency_links.txt +0 -0
  16. {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/entry_points.txt +0 -0
  17. {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/requires.txt +0 -0
  18. {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/top_level.txt +0 -0
  19. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/mcp_server.py +0 -0
  20. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/topup.py +0 -0
  21. {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/verify.py +0 -0
  22. {feeless402-0.2.2 → feeless402-0.2.4}/setup.cfg +0 -0
  23. {feeless402-0.2.2 → feeless402-0.2.4}/tests/test_payment_path.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feeless402
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
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
5
  License: MIT
6
6
  Project-URL: Homepage, https://feeless402.com
@@ -103,13 +103,23 @@ but don't fire concurrent payments from one wallet).
103
103
 
104
104
  ## Status
105
105
 
106
- Beta (v0.2.2). Proven on mainnet with real funds: live paid calls to
106
+ Beta (v0.2.4). Proven on mainnet with real funds: live paid calls to
107
107
  NanoGPT ($0.0000096/call, confirmed on-ledger), full merchant loop
108
108
  (verify → settle → confirm, no facilitator), PoW-gated faucet claims,
109
109
  and a complete stranger-agent lifecycle (fresh wallet → PoW claim →
110
110
  paid API call → confirmed) in under 3 minutes. 13-test suite covers the
111
111
  payment path offline. Not audited — keep only working capital in it.
112
112
 
113
+ ## Listings
114
+
115
+ - [gold-402](https://github.com/Haustorium12/gold-402) — curated x402
116
+ directory; the `/premium` reference endpoint is shelved under APIs and
117
+ this Python client under SDKs.
118
+ - [Official MCP registry](https://registry.modelcontextprotocol.io) —
119
+ `com.feeless402/nano-pay`.
120
+ - [agent-tools.cloud](https://agent-tools.cloud) — x402, MCP, and A2A
121
+ entries.
122
+
113
123
  ## Security notes (read before holding real funds)
114
124
 
115
125
  - **Self-custody**: the seed lives only in `~/.nano-pay/*.json` (0600).
@@ -70,13 +70,23 @@ but don't fire concurrent payments from one wallet).
70
70
 
71
71
  ## Status
72
72
 
73
- Beta (v0.2.2). Proven on mainnet with real funds: live paid calls to
73
+ Beta (v0.2.4). Proven on mainnet with real funds: live paid calls to
74
74
  NanoGPT ($0.0000096/call, confirmed on-ledger), full merchant loop
75
75
  (verify → settle → confirm, no facilitator), PoW-gated faucet claims,
76
76
  and a complete stranger-agent lifecycle (fresh wallet → PoW claim →
77
77
  paid API call → confirmed) in under 3 minutes. 13-test suite covers the
78
78
  payment path offline. Not audited — keep only working capital in it.
79
79
 
80
+ ## Listings
81
+
82
+ - [gold-402](https://github.com/Haustorium12/gold-402) — curated x402
83
+ directory; the `/premium` reference endpoint is shelved under APIs and
84
+ this Python client under SDKs.
85
+ - [Official MCP registry](https://registry.modelcontextprotocol.io) —
86
+ `com.feeless402/nano-pay`.
87
+ - [agent-tools.cloud](https://agent-tools.cloud) — x402, MCP, and A2A
88
+ entries.
89
+
80
90
  ## Security notes (read before holding real funds)
81
91
 
82
92
  - **Self-custody**: the seed lives only in `~/.nano-pay/*.json` (0600).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feeless402
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
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
5
  License: MIT
6
6
  Project-URL: Homepage, https://feeless402.com
@@ -103,13 +103,23 @@ but don't fire concurrent payments from one wallet).
103
103
 
104
104
  ## Status
105
105
 
106
- Beta (v0.2.2). Proven on mainnet with real funds: live paid calls to
106
+ Beta (v0.2.4). Proven on mainnet with real funds: live paid calls to
107
107
  NanoGPT ($0.0000096/call, confirmed on-ledger), full merchant loop
108
108
  (verify → settle → confirm, no facilitator), PoW-gated faucet claims,
109
109
  and a complete stranger-agent lifecycle (fresh wallet → PoW claim →
110
110
  paid API call → confirmed) in under 3 minutes. 13-test suite covers the
111
111
  payment path offline. Not audited — keep only working capital in it.
112
112
 
113
+ ## Listings
114
+
115
+ - [gold-402](https://github.com/Haustorium12/gold-402) — curated x402
116
+ directory; the `/premium` reference endpoint is shelved under APIs and
117
+ this Python client under SDKs.
118
+ - [Official MCP registry](https://registry.modelcontextprotocol.io) —
119
+ `com.feeless402/nano-pay`.
120
+ - [agent-tools.cloud](https://agent-tools.cloud) — x402, MCP, and A2A
121
+ entries.
122
+
113
123
  ## Security notes (read before holding real funds)
114
124
 
115
125
  - **Self-custody**: the seed lives only in `~/.nano-pay/*.json` (0600).
@@ -8,6 +8,7 @@ feeless402.egg-info/entry_points.txt
8
8
  feeless402.egg-info/requires.txt
9
9
  feeless402.egg-info/top_level.txt
10
10
  nano_pay/__init__.py
11
+ nano_pay/a2a_remote.py
11
12
  nano_pay/cli.py
12
13
  nano_pay/mcp_remote.py
13
14
  nano_pay/mcp_server.py
@@ -1,6 +1,6 @@
1
1
  """nano-pay: self-custodied Nano (XNO) wallet + x402 payment client for AI agents."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.4"
4
4
 
5
5
  RAW_PER_XNO = 10**30
6
6
 
@@ -0,0 +1,230 @@
1
+ """Feeless402 A2A agent — READ-ONLY, no wallet.
2
+
3
+ Public Agent2Agent (A2A protocol 0.3.0) endpoint at https://feeless402.com/a2a
4
+ so any A2A client can ask, in one delegated message, what an x402 call costs
5
+ on every rail its server offers. Same read-only tools as the remote MCP
6
+ endpoint (mcp_remote.py); it holds no keys and can spend nothing. The agent
7
+ replies instantly with a Message — it never creates long-running tasks.
8
+
9
+ Agent card: https://feeless402.com/.well-known/agent-card.json (static copy
10
+ in the site root; also served here at GET /.well-known/agent-card.json).
11
+
12
+ Run: python -m nano_pay.a2a_remote (127.0.0.1:8404, JSON-RPC at POST /a2a)
13
+ """
14
+
15
+ import json
16
+ import re
17
+ import uuid
18
+
19
+ from fastapi import FastAPI, Request
20
+ from fastapi.responses import JSONResponse
21
+
22
+ from . import __version__, raw_to_xno
23
+ from .mcp_remote import FAUCET_ADDR, FAUCET_CLAIM_XNO, _fetch_402
24
+ from .rpc import RPC
25
+ from .x402 import collect_offers, compare_rails, offer_amount_raw, parse_quote, pick_nano_offer
26
+
27
+ AGENT_URL = "https://feeless402.com/a2a"
28
+ URL_RE = re.compile(r"https?://[^\s\"'<>]+")
29
+
30
+ CARD = {
31
+ "protocolVersion": "0.3.0",
32
+ "name": "Feeless402 Rail Quote",
33
+ "description": (
34
+ "Read-only x402 pricing agent: send it any x402 (HTTP 402 Payment "
35
+ "Required) API URL and it fetches the live payment quote without "
36
+ "paying, then prices the same call on every rail the server offers — "
37
+ "gas rails floor micro-prices; the feeless Nano (XNO) rail quotes the "
38
+ "true metered amount. Holds no wallet and cannot pay; the "
39
+ "self-custodied client is pip install feeless402."
40
+ ),
41
+ "url": AGENT_URL,
42
+ "preferredTransport": "JSONRPC",
43
+ "version": __version__,
44
+ "provider": {"organization": "Feeless402", "url": "https://feeless402.com"},
45
+ "documentationUrl": "https://feeless402.com/docs.html",
46
+ "capabilities": {
47
+ "streaming": False,
48
+ "pushNotifications": False,
49
+ "stateTransitionHistory": False,
50
+ },
51
+ "defaultInputModes": ["text/plain"],
52
+ "defaultOutputModes": ["application/json", "text/plain"],
53
+ "securitySchemes": {},
54
+ "security": [],
55
+ "skills": [
56
+ {
57
+ "id": "x402-quote-and-compare",
58
+ "name": "Quote an x402 endpoint across rails",
59
+ "description": (
60
+ "Give it an x402 API URL (optionally with a JSON body). It "
61
+ "returns the endpoint's live 402 quote — full offer menu, "
62
+ "XNO price, destination — plus a per-rail payer-cost "
63
+ "comparison. Read-only: nothing is paid."
64
+ ),
65
+ "tags": ["x402", "pricing", "402", "nano", "rails"],
66
+ "examples": [
67
+ "quote https://feeless402.com/premium",
68
+ "what would https://nano-gpt.com/api/v1/chat/completions cost per call?",
69
+ ],
70
+ "inputModes": ["text/plain"],
71
+ "outputModes": ["application/json", "text/plain"],
72
+ },
73
+ {
74
+ "id": "faucet-info",
75
+ "name": "Starter-funds faucet status",
76
+ "description": (
77
+ "Live status of the Feeless402 onboarding faucet: address, "
78
+ "per-claim amount, remaining claims, and how an agent claims."
79
+ ),
80
+ "tags": ["faucet", "nano", "onboarding"],
81
+ "examples": ["faucet status"],
82
+ "inputModes": ["text/plain"],
83
+ "outputModes": ["application/json", "text/plain"],
84
+ },
85
+ ],
86
+ }
87
+
88
+ app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
89
+
90
+
91
+ @app.get("/.well-known/agent-card.json")
92
+ @app.get("/a2a/.well-known/agent-card.json")
93
+ def agent_card():
94
+ return CARD
95
+
96
+
97
+ @app.get("/a2a")
98
+ def a2a_get():
99
+ return {
100
+ "protocol": "a2a",
101
+ "endpoint": AGENT_URL,
102
+ "agent_card": "https://feeless402.com/.well-known/agent-card.json",
103
+ "usage": "POST JSON-RPC 2.0, method message/send",
104
+ }
105
+
106
+
107
+ def _texts(message: dict) -> str:
108
+ parts = message.get("parts") or []
109
+ return " ".join(p.get("text", "") for p in parts if p.get("kind") == "text")
110
+
111
+
112
+ def _quote_and_compare(url: str, method: str = "GET", json_body: str = "") -> dict:
113
+ r = _fetch_402(url, method, json_body)
114
+ if r.status_code != 402:
115
+ return {"url": url, "status_code": r.status_code, "note": "not a 402 endpoint"}
116
+ quote = parse_quote(r)
117
+ out = {"url": url, "offers": collect_offers(quote), "compare": compare_rails(quote)}
118
+ try:
119
+ offer = pick_nano_offer(quote)
120
+ out["nano"] = {
121
+ "amount_xno": raw_to_xno(offer_amount_raw(offer)),
122
+ "pay_to": offer.get("payTo") or offer.get("pay_to"),
123
+ }
124
+ except Exception as e:
125
+ out["nano"] = {"note": str(e)}
126
+ return out
127
+
128
+
129
+ def _faucet_info() -> dict:
130
+ info = RPC().account_info(FAUCET_ADDR) or {}
131
+ bal = int(info.get("balance", 0))
132
+ per_claim = int(float(FAUCET_CLAIM_XNO) * 10**30)
133
+ return {
134
+ "faucet_address": FAUCET_ADDR,
135
+ "claim_xno": FAUCET_CLAIM_XNO,
136
+ "balance_xno": raw_to_xno(bal),
137
+ "claims_remaining": bal // per_claim if per_claim else 0,
138
+ "rules": "one claim per address ever; 3 per IP per day; proof-of-work required",
139
+ "how_to_claim": "pip install feeless402; nano-pay claim https://feeless402.com",
140
+ }
141
+
142
+
143
+ HELP = (
144
+ "Send me an x402 API URL and I return its live 402 quote plus a per-rail "
145
+ "cost comparison, without paying. Say 'faucet' for starter-funds info. "
146
+ "I am read-only and hold no wallet — the self-custodied client is "
147
+ "pip install feeless402."
148
+ )
149
+
150
+
151
+ def _handle_message(message: dict) -> list:
152
+ """Return A2A parts answering one inbound message."""
153
+ text = _texts(message)
154
+ data_parts = [p.get("data") or {} for p in (message.get("parts") or []) if p.get("kind") == "data"]
155
+ url = next((d["url"] for d in data_parts if isinstance(d, dict) and d.get("url")), None)
156
+ if not url:
157
+ m = URL_RE.search(text)
158
+ url = m.group(0).rstrip(".,;)]") if m else None
159
+ if url:
160
+ body = next((json.dumps(d["json_body"]) for d in data_parts
161
+ if isinstance(d, dict) and d.get("json_body")), "")
162
+ method = next((d["method"] for d in data_parts
163
+ if isinstance(d, dict) and d.get("method")), "GET")
164
+ try:
165
+ result = _quote_and_compare(url, method, body)
166
+ except ValueError as e: # SSRF guard / bad URL — refusal is the answer
167
+ result = {"url": url, "error": str(e)}
168
+ summary = (
169
+ f"{result['nano']['amount_xno']} XNO per call on nano:mainnet"
170
+ if isinstance(result.get("nano"), dict) and result["nano"].get("amount_xno")
171
+ else result.get("error") or result.get("note") or "see data"
172
+ )
173
+ return [
174
+ {"kind": "data", "data": result},
175
+ {"kind": "text", "text": f"Quote for {url}: {summary}."},
176
+ ]
177
+ if "faucet" in text.lower():
178
+ info = _faucet_info()
179
+ return [
180
+ {"kind": "data", "data": info},
181
+ {"kind": "text", "text": f"Faucet holds {info['balance_xno']} XNO "
182
+ f"({info['claims_remaining']} claims left)."},
183
+ ]
184
+ return [{"kind": "text", "text": HELP}]
185
+
186
+
187
+ def _rpc_error(rid, code: int, msg: str, status: int = 200) -> JSONResponse:
188
+ return JSONResponse({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": msg}},
189
+ status_code=status)
190
+
191
+
192
+ @app.post("/a2a")
193
+ async def a2a_rpc(request: Request):
194
+ try:
195
+ req = await request.json()
196
+ except Exception:
197
+ return _rpc_error(None, -32700, "parse error", 400)
198
+ rid = req.get("id")
199
+ method = req.get("method")
200
+ if method != "message/send":
201
+ if method in ("tasks/get", "tasks/cancel", "tasks/resubscribe"):
202
+ return _rpc_error(rid, -32001, "task not found — this agent replies "
203
+ "instantly with messages and creates no tasks")
204
+ return _rpc_error(rid, -32601, f"method not supported: {method}")
205
+ message = (req.get("params") or {}).get("message") or {}
206
+ try:
207
+ parts = _handle_message(message)
208
+ except Exception as e: # never leak a traceback to the wire
209
+ return _rpc_error(rid, -32603, f"internal error: {type(e).__name__}")
210
+ return {
211
+ "jsonrpc": "2.0",
212
+ "id": rid,
213
+ "result": {
214
+ "kind": "message",
215
+ "role": "agent",
216
+ "messageId": str(uuid.uuid4()),
217
+ "contextId": message.get("contextId") or str(uuid.uuid4()),
218
+ "parts": parts,
219
+ },
220
+ }
221
+
222
+
223
+ def main():
224
+ import uvicorn
225
+
226
+ uvicorn.run(app, host="127.0.0.1", port=8404, log_level="info")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
@@ -214,23 +214,47 @@ def _claim_one(base, w):
214
214
  """Claim from one faucet, solving its PoW challenge if it has one."""
215
215
  import requests as rq
216
216
 
217
- base = base.rstrip("/")
217
+ # Accept the site root, the /faucet endpoint itself, or either with a
218
+ # query string (e.g. ``https://feeless402.com/faucet?ref=skill`` as the
219
+ # skill docs show) — normalize to the root and carry the query along so
220
+ # attribution tags survive.
221
+ from urllib.parse import parse_qsl, urlsplit
222
+
223
+ u = urlsplit(base)
224
+ path = u.path.rstrip("/")
225
+ if path.endswith("/faucet"):
226
+ path = path[: -len("/faucet")]
227
+ base = f"{u.scheme}://{u.netloc}{path}"
228
+ extra = dict(parse_qsl(u.query))
229
+ extra.pop("address", None)
218
230
  payload = {"address": w.address}
219
231
  try:
220
232
  ch = rq.get(
221
233
  f"{base}/faucet/challenge",
222
- params={"address": w.address},
234
+ params={**extra, "address": w.address},
223
235
  timeout=30,
224
236
  ).json()
225
237
  if ch.get("pow_required"):
226
238
  import nanopy
227
239
 
240
+ # Deliberately local: the faucet's challenge is meant to cost the
241
+ # claimant real CPU, which is what stops a farm claiming in bulk.
242
+ # It cannot be handed to a work service without giving that away.
243
+ # It can, however, say so — running silent for minutes on the
244
+ # first command anyone tries reads as a hang.
245
+ print("solving the faucet's proof-of-work on this machine — "
246
+ "usually 30s to a few minutes. This is what keeps the "
247
+ "faucet from being drained; it only happens once.",
248
+ file=sys.stderr, flush=True)
249
+ t0 = time.time()
228
250
  payload["work"] = (
229
251
  f"{nanopy.ext.work_generate(bytes.fromhex(ch['root']), int(ch['difficulty'], 16), __import__('os').urandom(128)):016x}"
230
252
  )
253
+ print(f"proof-of-work solved in {time.time() - t0:.0f}s — claiming…",
254
+ file=sys.stderr, flush=True)
231
255
  except Exception:
232
256
  pass # no challenge endpoint — plain claim
233
- r = rq.post(f"{base}/faucet", json=payload, timeout=240)
257
+ r = rq.post(f"{base}/faucet", params=extra or None, json=payload, timeout=240)
234
258
  try:
235
259
  return r.status_code, r.json()
236
260
  except Exception:
@@ -270,21 +294,64 @@ def cmd_claim(args):
270
294
  if got:
271
295
  break
272
296
  time.sleep(1.5)
273
- if got:
274
- w.prework(got[-1][0], rpc) # warm the next block off the hash
275
297
  acct = w.synced_account(rpc)
276
- out(
277
- {
278
- "claimed_from": base,
279
- "result": res,
280
- "received": [
281
- {"hash": h, "amount_xno": raw_to_xno(a)}
282
- for h, a in got
283
- ],
284
- "balance_xno": raw_to_xno(acct.raw_bal),
285
- "attempts": attempts,
286
- }
287
- )
298
+ payload = {
299
+ "claimed_from": base,
300
+ "result": res,
301
+ "received": [
302
+ {"hash": h, "amount_xno": raw_to_xno(a)}
303
+ for h, a in got
304
+ ],
305
+ "balance_xno": raw_to_xno(acct.raw_bal),
306
+ "attempts": attempts,
307
+ }
308
+ # Report the grant BEFORE warming the next block. Pre-computing
309
+ # takes minutes on a modest machine, and doing it first left the
310
+ # user staring at a blank terminal wondering whether the claim
311
+ # worked — on the very first command they ever run. Same order
312
+ # and same guard as send()/pay().
313
+ if got:
314
+ print(json.dumps(payload, indent=2, default=str), flush=True)
315
+ print("claimed — that was the slow part, and it is over. "
316
+ "Pre-computing the proof-of-work for your next block "
317
+ "now, and again after every transaction, so payments "
318
+ "from here are near-instant. Safe to Ctrl-C.",
319
+ file=sys.stderr, flush=True)
320
+ try:
321
+ w.prework(got[-1][0], rpc)
322
+ except Exception:
323
+ pass
324
+ sys.exit(0)
325
+ out(payload)
326
+ # An already-claimed address is not a broken faucet: the money was
327
+ # granted earlier (possibly to a client that gave up before the reply).
328
+ # Pocket anything still pending and report the balance instead of a
329
+ # hard error — exit 0 when the wallet is funded, since the agent can
330
+ # proceed straight to paying.
331
+ already = [
332
+ a for a in attempts
333
+ if a["status"] == 429
334
+ and "already claimed" in str(a["result"].get("error", ""))
335
+ ]
336
+ if already:
337
+ rpc = RPC()
338
+ got = w.receive_all(rpc, prework=False)
339
+ acct = w.synced_account(rpc)
340
+ bal = raw_to_xno(acct.raw_bal)
341
+ out(
342
+ {
343
+ "status": "already_claimed",
344
+ "message": f"this address already received its starter XNO — "
345
+ f"balance {bal} XNO, nothing more to claim here; "
346
+ f"generate a fresh wallet if you need a new grant",
347
+ "balance_xno": bal,
348
+ "received_now": [
349
+ {"hash": h, "amount_xno": raw_to_xno(a)} for h, a in got
350
+ ],
351
+ "attempts": attempts,
352
+ },
353
+ code=0 if acct.raw_bal else 1,
354
+ )
288
355
  out({"error": "no faucet granted a claim", "attempts": attempts}, code=1)
289
356
 
290
357
 
@@ -24,7 +24,7 @@ from .rpc import RPC
24
24
  from .x402 import collect_offers, compare_rails, offer_amount_raw, parse_quote, pick_nano_offer
25
25
 
26
26
  FAUCET_ADDR = "nano_1hk1cu3773u5r39e75mtqrauzro75j3hwdzyewz8izokzur66semy739w14h"
27
- FAUCET_CLAIM_XNO = "0.005"
27
+ FAUCET_CLAIM_XNO = "0.0005"
28
28
 
29
29
  INSTRUCTIONS = """Read-only demo of the Feeless402 rail: price any x402
30
30
  (HTTP 402 Payment Required) API across payment rails and see what the
@@ -156,7 +156,7 @@ def faucet_info() -> str:
156
156
  "rules": "one claim per address ever; 3 per IP per day; proof-of-work required",
157
157
  "how_to_claim": "pip install feeless402; nano-pay claim https://feeless402.com "
158
158
  "(or the faucet_claim tool of the LOCAL MCP server: nano-pay mcp)",
159
- "donations": f"send XNO to {FAUCET_ADDR} — every 0.005 onboards another agent",
159
+ "donations": f"send XNO to {FAUCET_ADDR} — every 0.0005 onboards another agent",
160
160
  }
161
161
  )
162
162
 
@@ -1,7 +1,18 @@
1
1
  """Nano node RPC client with public-node failover."""
2
2
 
3
+ import os
4
+
3
5
  import requests
4
6
 
7
+ # Optional dedicated proof-of-work service, tried before the public nodes.
8
+ # Public nodes serve work in ~5s and many refuse outright, which caps how
9
+ # fast a faucet or merchant can sign blocks. Set:
10
+ # F402_WORK_URLS comma-separated work endpoints
11
+ # F402_WORK_KEY sent as {"key": ...} and as a Bearer header (services differ)
12
+ WORK_URLS = [u.strip() for u in os.environ.get("F402_WORK_URLS", "").split(",")
13
+ if u.strip()]
14
+ WORK_KEY = os.environ.get("F402_WORK_KEY", "")
15
+
5
16
  DEFAULT_RPCS = [
6
17
  "https://rpc.nano.to",
7
18
  "https://node.somenano.com/proxy",
@@ -19,9 +30,11 @@ class RPCError(Exception):
19
30
 
20
31
 
21
32
  class RPC:
22
- def __init__(self, urls=None, timeout=20):
33
+ def __init__(self, urls=None, timeout=20, work_urls=None, work_key=None):
23
34
  self.urls = urls or list(DEFAULT_RPCS)
24
35
  self.timeout = timeout
36
+ self.work_urls = work_urls if work_urls is not None else list(WORK_URLS)
37
+ self.work_key = work_key if work_key is not None else WORK_KEY
25
38
 
26
39
  def call(self, payload: dict) -> dict:
27
40
  last_err = None
@@ -91,18 +104,22 @@ class RPC:
91
104
  return res["hash"]
92
105
 
93
106
  def work_generate(self, root: str, difficulty: str):
94
- """Ask nodes for work; many public nodes refuse — return None then."""
95
- for url in self.urls:
107
+ """Ask a work service first, then nodes; many public nodes refuse.
108
+
109
+ Returns None if nobody will do it, and the caller falls back to
110
+ local PoW.
111
+ """
112
+ payload = {"action": "work_generate", "hash": root,
113
+ "difficulty": difficulty}
114
+ for url in list(self.work_urls) + list(self.urls):
115
+ body = dict(payload)
116
+ headers = {}
117
+ if url in self.work_urls and self.work_key:
118
+ body["key"] = self.work_key # BoomPoW-style
119
+ headers["Authorization"] = f"Bearer {self.work_key}"
96
120
  try:
97
- r = requests.post(
98
- url,
99
- json={
100
- "action": "work_generate",
101
- "hash": root,
102
- "difficulty": difficulty,
103
- },
104
- timeout=self.timeout,
105
- )
121
+ r = requests.post(url, json=body, headers=headers,
122
+ timeout=self.timeout)
106
123
  data = r.json()
107
124
  if isinstance(data, dict) and data.get("work"):
108
125
  return data["work"]
@@ -32,8 +32,15 @@ DOCS_URL = os.environ.get(
32
32
  "F402_DOCS_URL", "https://github.com/feeless402/feeless402"
33
33
  )
34
34
  PRICE_XNO = os.environ.get("F402_PRICE_XNO", "0.0001")
35
- FAUCET_XNO = os.environ.get("F402_FAUCET_XNO", "0.005")
35
+ FAUCET_XNO = os.environ.get("F402_FAUCET_XNO", "0.0005")
36
+ FAUCET_TOPUP_XNO = os.environ.get("F402_FAUCET_TOPUP_XNO", "0.0045")
37
+ # Kept as a string for xno_to_raw's Decimal path; parsed separately so the
38
+ # "is it switched on" test can't trip over a str/int comparison.
39
+ FAUCET_TOPUP_ON = float(FAUCET_TOPUP_XNO or 0) > 0
40
+ # When the grant size last changed, so dispensed-history math stays honest.
41
+ FAUCET_RATE_CHANGES = [(0.0, 0.005), (1786021000.0, float(FAUCET_XNO))]
36
42
  FAUCET_PER_IP_PER_DAY = int(os.environ.get("F402_FAUCET_PER_IP_PER_DAY", "3"))
43
+ FAUCET_GLOBAL_PER_HOUR = int(os.environ.get("F402_FAUCET_GLOBAL_PER_HOUR", "12"))
37
44
  FAUCET_POW = os.environ.get("F402_FAUCET_POW", "0") == "1"
38
45
  # Sibling faucets advertised in railHint — the federation list. Comma-sep.
39
46
  FAUCET_FEDERATION = [
@@ -90,6 +97,61 @@ def _self_addresses() -> set:
90
97
  return set()
91
98
 
92
99
 
100
+ # Addresses a faucet farm sweeps its grants into. A farm has to collect what it
101
+ # harvests somewhere, and that destination is the one thing it can't fake away
102
+ # without spending more than it earns — so it is the honest way to tell a bot's
103
+ # payment from a person's, after the fact. Add to this list if a new one appears.
104
+ FARM_COLLECTORS = {
105
+ "nano_3amg1cayw1ynb4fhyzhyc4hpwrehsy1ubtcdgi57bbz7ycjjkzjhpxtdcm6y",
106
+ }
107
+
108
+
109
+ def _looks_like_farm(payer: str) -> bool:
110
+ """Has this payer swept its balance into a known farm collector?
111
+
112
+ Runs off the request path — it costs an RPC call and a payment must never
113
+ wait on bookkeeping.
114
+ """
115
+ try:
116
+ hist = rpc.call({"action": "account_history", "account": payer,
117
+ "count": "25"}).get("history", [])
118
+ return any(b.get("type") == "send"
119
+ and b.get("account") in FARM_COLLECTORS for b in hist)
120
+ except Exception:
121
+ return False
122
+
123
+
124
+ def _reclassify_if_farm(payer: str) -> None:
125
+ """Move a payment from external to farm once the sweep gives it away.
126
+
127
+ The farm pays our 0.0001 fee like anyone else, so at the moment of payment
128
+ it is indistinguishable from a real user. It gives itself away minutes or
129
+ hours later, when it sweeps. This re-checks then, so the public numbers
130
+ settle on the truth instead of flattering us.
131
+ """
132
+ def _check():
133
+ time.sleep(300) # give it time to sweep
134
+ if not _looks_like_farm(payer):
135
+ return
136
+ try:
137
+ d = json.loads(PAID_CALLS.read_text())
138
+ if payer in (d.get("payers", {}).get("farm") or []):
139
+ return # already counted as farm
140
+ d["external"] = max(0, int(d.get("external", 0)) - 1)
141
+ d["farm"] = int(d.get("farm", 0)) + 1
142
+ d.setdefault("payers", {}).setdefault("farm", []).append(payer)
143
+ ext = d.get("payers", {}).get("external") or []
144
+ if payer in ext:
145
+ ext.remove(payer)
146
+ tmp = PAID_CALLS.with_name(PAID_CALLS.name + ".tmp")
147
+ tmp.write_text(json.dumps(d, indent=2))
148
+ tmp.replace(PAID_CALLS)
149
+ print(f"reclassified farm payment: {payer[:20]}…", flush=True)
150
+ except Exception as e:
151
+ print("reclassify failed:", e, flush=True)
152
+ threading.Thread(target=_check, daemon=True).start()
153
+
154
+
93
155
  def _count_paid_call(payer: str, demo_address: str) -> None:
94
156
  """Tally a settled payment by who paid. Never raises — a bookkeeping
95
157
  failure must not affect a payment that already settled on-chain."""
@@ -115,19 +177,62 @@ def _count_paid_call(payer: str, demo_address: str) -> None:
115
177
  pass
116
178
 
117
179
 
180
+ def _maybe_graduate(payer: str) -> None:
181
+ """First real x402 payment from a faucet claimant quietly tops their
182
+ starter grant up to a full allowance. Never raises, never blocks the
183
+ payment response; the send runs in its own thread under FAUCET_LOCK.
184
+
185
+ Deliberately unadvertised on every user-facing surface: the paid call
186
+ itself is the proof of a real integration.
187
+
188
+ ⚠️ Aug 7 2026: SET TO ZERO (disabled). The premise — that paying proves
189
+ a real integration — held for about sixteen hours. A farm started paying
190
+ the 0.0001 premium fee to unlock the 0.0045 top-up, a 45x return, and
191
+ took 11 of 12 grants in a day. Any unlock that costs far less than the
192
+ prize is just a price, not a proof. Re-enable by setting
193
+ F402_FAUCET_TOPUP_XNO above zero if the economics ever change.
194
+ """
195
+ try:
196
+ if not FAUCET_TOPUP_ON:
197
+ return
198
+ if payer == demo_wallet.address or payer in _self_addresses():
199
+ return
200
+ led = _ledger()
201
+ if payer not in led["addresses"] or payer in led.get("graduated", {}):
202
+ return
203
+
204
+ def _send():
205
+ try:
206
+ with FAUCET_LOCK:
207
+ led2 = _ledger()
208
+ if payer in led2.setdefault("graduated", {}):
209
+ return
210
+ faucet_wallet.load()
211
+ faucet_wallet.receive_all(rpc, prework=False)
212
+ faucet_wallet.send(
213
+ rpc, payer, xno_to_raw(FAUCET_TOPUP_XNO),
214
+ prework=False)
215
+ led2["graduated"][payer] = time.time()
216
+ _save_ledger(led2)
217
+ except Exception:
218
+ pass
219
+
220
+ threading.Thread(target=_send, daemon=True).start()
221
+ except Exception:
222
+ pass
223
+
224
+
118
225
  def _faucet_funding() -> dict:
119
226
  """Who has put XNO INTO the faucet, read straight off the chain.
120
227
 
121
228
  Three kinds of deposit: our own top-ups, grants an agent didn't spend
122
229
  and sent back, and donations from people who owed us nothing.
123
230
  """
124
- try:
125
- hist = (rpc.call({"action": "account_history",
126
- "account": faucet_wallet.address,
127
- "count": "500"}).get("history") or [])
128
- except Exception:
129
- return {"donations": None, "donated_xno": None,
130
- "returned_grants": None, "recent": [], "address": None}
231
+ # No try/except: an RPC failure must RAISE so _cached() serves the last
232
+ # good funding block instead of caching this None-shaped placeholder.
233
+ hist = (rpc.call({"action": "account_history",
234
+ "account": faucet_wallet.address,
235
+ "count": "500"}).get("history") or [])
131
236
  ours = _self_addresses()
132
237
  claimants = set(_ledger().get("addresses", {}))
133
238
  donations, returned, donated_raw = [], 0, 0
@@ -159,13 +264,17 @@ def _faucet_funding() -> dict:
159
264
  def _paid_calls() -> dict:
160
265
  try:
161
266
  d = json.loads(PAID_CALLS.read_text())
267
+ # "external" must mean a person. Farm payments are broken out rather
268
+ # than folded in — a faucet farm paying our own fee to unlock a grant
269
+ # is not usage, and counting it as such flatters the numbers 5x.
162
270
  return {"external": int(d.get("external", 0)),
271
+ "farm": int(d.get("farm", 0)),
163
272
  "demo": int(d.get("demo", 0)),
164
273
  "self_test": int(d.get("self", 0)),
165
274
  "last_external_unix": d.get("last_external_unix")}
166
275
  except Exception:
167
- return {"external": None, "demo": None, "self_test": None,
168
- "last_external_unix": None}
276
+ return {"external": None, "farm": None, "demo": None,
277
+ "self_test": None, "last_external_unix": None}
169
278
 
170
279
 
171
280
  def _self_ips() -> set:
@@ -206,6 +315,36 @@ def _client_ip(request) -> str:
206
315
  or (request.client.host if request.client else "unknown"))
207
316
 
208
317
 
318
+ # JSON Schema (Draft 2020-12) validating the rail-hint `info` object. Shipped
319
+ # inline with every declaration per the x402 SDK extension convention
320
+ # ({"info": ..., "schema": ...}), so clients can validate without a fetch.
321
+ # Canonical copy: https://railhint.com/railhint.schema.json
322
+ RAILHINT_SCHEMA = {
323
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
324
+ "$id": "https://railhint.com/railhint.schema.json",
325
+ "title": "rail-hint",
326
+ "description": (
327
+ "Advisory rail-negotiation extension for x402 v2 402 responses. "
328
+ "Carried at extensions.\"rail-hint\" as {info, schema}. Non-binding: "
329
+ "the accepts array remains the only binding payment terms."
330
+ ),
331
+ "type": "object",
332
+ "required": ["cheapest", "why"],
333
+ "properties": {
334
+ "cheapest": {"type": "string"},
335
+ "why": {"type": "string"},
336
+ "docs": {"type": "string", "format": "uri"},
337
+ "site": {"type": "string", "format": "uri"},
338
+ "agentReadme": {"type": "string", "format": "uri"},
339
+ "bootstrap": {"type": "string"},
340
+ "topup": {"type": "string"},
341
+ "faucets": {"type": "array", "items": {"type": "string", "format": "uri"}},
342
+ "spec": {"type": "string"},
343
+ },
344
+ "additionalProperties": True,
345
+ }
346
+
347
+
209
348
  def rail_hint(price_raw: int) -> dict:
210
349
  return {
211
350
  "cheapest": "nano:mainnet",
@@ -235,7 +374,7 @@ def rail_hint(price_raw: int) -> dict:
235
374
  def payment_required_body(price_raw: int, pay_to: str, resource: str) -> dict:
236
375
  return {
237
376
  "x402Version": 2,
238
- "error": "Payment required — feeless XNO option available (see extensions.railHint)",
377
+ "error": "Payment required — feeless XNO option available (see extensions[\"rail-hint\"])",
239
378
  "resource": {"url": resource, "mimeType": "application/json"},
240
379
  "accepts": [
241
380
  {
@@ -247,7 +386,17 @@ def payment_required_body(price_raw: int, pay_to: str, resource: str) -> dict:
247
386
  "maxTimeoutSeconds": 60,
248
387
  }
249
388
  ],
250
- "extensions": {"railHint": rail_hint(price_raw)},
389
+ "extensions": {
390
+ # Conformant with the x402 SDK extension convention:
391
+ # kebab-case key, {info, schema} shape.
392
+ "rail-hint": {
393
+ "info": rail_hint(price_raw),
394
+ "schema": RAILHINT_SCHEMA,
395
+ },
396
+ # DEPRECATED legacy key (flat shape, draft-railhint-00 as first
397
+ # published). Kept while integrators migrate; will be removed.
398
+ "railHint": rail_hint(price_raw),
399
+ },
251
400
  }
252
401
 
253
402
 
@@ -344,10 +493,11 @@ def _stats_data():
344
493
  day_ago = time.time() - 86400
345
494
 
346
495
  def _bal(w):
347
- try:
348
- return round(float(raw_to_xno(w.synced_account(rpc).raw_bal)), 6)
349
- except Exception:
350
- return None
496
+ # Raises on RPC trouble — deliberately. _cached() turns a raise into
497
+ # "keep the last good value, retry in fail_ttl"; returning None here
498
+ # instead would CACHE the None as if it were data, and the page shows
499
+ # a wall of dashes for the whole TTL (the Aug 15 blank-out).
500
+ return round(float(raw_to_xno(w.synced_account(rpc).raw_bal)), 6)
351
501
 
352
502
  faucet_bal = _cached("faucet_bal", 300, lambda: _bal(faucet_wallet))
353
503
  per_claim = float(FAUCET_XNO)
@@ -358,7 +508,10 @@ def _stats_data():
358
508
  "claims_total": len(ext_times),
359
509
  "claims_24h": sum(1 for t in ext_times if t > day_ago),
360
510
  "self_test_claims": len(claims) - len(ext_times),
361
- "xno_dispensed": round(len(claims) * per_claim, 6),
511
+ "xno_dispensed": round(sum(
512
+ next(rate for start, rate in reversed(FAUCET_RATE_CHANGES)
513
+ if t >= start)
514
+ for t in ts_list), 6),
362
515
  "xno_per_claim": per_claim,
363
516
  "balance_xno": faucet_bal,
364
517
  "claims_remaining": (
@@ -448,7 +601,7 @@ STATS_HTML = """<title>Stats — Feeless402</title>
448
601
  <main>
449
602
  <header>
450
603
  <div class="brand"><a href="/"><b>feeless402</b></a> / stats</div>
451
- <nav><a href="/">Home</a><a href="/docs.html">Docs</a><a href="/faucet.html">Faucet</a><a href="/stats">Stats</a><a href="https://railhint.com" target="_blank" rel="noopener">Spec</a><a href="/llms.txt">llms.txt</a></nav>
604
+ <nav><a href="/">Home</a><a href="/docs.html">Docs</a><a href="/faucet.html">Faucet</a><a href="/stats">Stats</a><a href="/briefing/">Briefing</a><a href="/audits/">Audits</a><a href="https://railhint.com" target="_blank" rel="noopener">Spec</a><a href="/llms.txt">llms.txt</a><a href="/.well-known/agent-card.json">A2A</a></nav>
452
605
  </header>
453
606
 
454
607
  <h1>Live usage</h1>
@@ -502,6 +655,7 @@ STATS_HTML = """<title>Stats — Feeless402</title>
502
655
  <div class="grid">
503
656
  <div class="tile"><div class="num" id="p_ext">&ndash;</div><div class="lbl">paid calls by strangers</div></div>
504
657
  <div class="tile"><div class="num" id="p_demo">&ndash;</div><div class="lbl">homepage demo unlocks (we pay those)</div></div>
658
+ <div class="tile"><div class="num" id="p_farm">&ndash;</div><div class="lbl">faucet-farm payments (excluded from the count above)</div></div>
505
659
  <div class="tile"><div class="num" id="t_bal">&ndash;</div><div class="lbl">treasury balance (XNO, incl. our own demo)</div></div>
506
660
  <div class="tile"><div class="num" id="t_price">&ndash;</div><div class="lbl">price per call (XNO)</div></div>
507
661
  </div>
@@ -534,7 +688,7 @@ async function load(){
534
688
  '" target="_blank" rel="noopener noreferrer">'+
535
689
  d.from.slice(0,16)+'…'+d.from.slice(-6)+'</a> &mdash; <strong>'+
536
690
  d.xno+' XNO</strong> '+when+'</div>';}).join('');
537
- var pc=t.paid_calls||{};n2('p_ext',pc.external);n2('p_demo',pc.demo);
691
+ var pc=t.paid_calls||{};n2('p_ext',pc.external);n2('p_demo',pc.demo);n2('p_farm',pc.farm);
538
692
  document.getElementById('t_bal').textContent=(t.balance_xno==null&&t.receivable_xno==null)?'–':xno((t.balance_xno||0)+(t.receivable_xno||0));
539
693
  document.getElementById('t_price').textContent=xno(t.price_per_call_xno);
540
694
  const gen=new Date((s.generated_unix||Date.now()/1000)*1000);
@@ -580,7 +734,20 @@ def create_app() -> FastAPI:
580
734
 
581
735
  @app.get("/stats.json")
582
736
  def stats_json():
583
- return _stats_data()
737
+ # No caching: the page fetches this client-side, and a browser-cached
738
+ # copy is how a corrected figure keeps displaying the wrong number.
739
+ return JSONResponse(_stats_data(), headers={
740
+ "Cache-Control": "no-store, max-age=0"})
741
+
742
+ # Liveness for uptime monitors and integrators (real users probed for
743
+ # both spellings) — cheap on purpose: no RPC, no wallet, no state writes.
744
+ start_unix = time.time()
745
+
746
+ @app.get("/health")
747
+ @app.get("/status")
748
+ def health():
749
+ return {"ok": True, "service": "feeless402", "version": __version__,
750
+ "uptime_s": int(time.time() - start_unix)}
584
751
 
585
752
  @app.get("/stats", response_class=HTMLResponse)
586
753
  def stats_page():
@@ -609,6 +776,12 @@ def create_app() -> FastAPI:
609
776
  "## Top up for real work\n"
610
777
  "nano-pay topup 5 --asset USDC-BASE --execute\n"
611
778
  "# $5 of USDC ≈ 100,000+ micro-calls at this endpoint's price\n\n"
779
+ "## Agent interfaces (read-only, no wallet)\n"
780
+ f"Daily agent-written x402 briefing + feed: {SITE_URL}/briefing/ "
781
+ f"(machine-readable: {SITE_URL}/briefing/feed.json)\n"
782
+ f"MCP (streamable HTTP): {SITE_URL}/mcp\n"
783
+ f"A2A agent (JSON-RPC): {SITE_URL}/a2a — card at "
784
+ f"{SITE_URL}/.well-known/agent-card.json\n\n"
612
785
  f"Docs: {DOCS_URL}\nSpec: x402 exact scheme, nano:mainnet\n"
613
786
  "x402 is stewarded by the Linux Foundation's x402 Foundation "
614
787
  "(premier members incl. Coinbase, Cloudflare, Stripe, Google, "
@@ -643,6 +816,8 @@ def create_app() -> FastAPI:
643
816
  )
644
817
  receipt = settle_block(block, rpc)
645
818
  _count_paid_call(payer, demo_wallet.address)
819
+ _reclassify_if_farm(payer)
820
+ _maybe_graduate(payer)
646
821
  except PaymentInvalid as e:
647
822
  return JSONResponse(
648
823
  {"error": f"payment invalid: {e}"}, status_code=402
@@ -875,7 +1050,7 @@ def create_app() -> FastAPI:
875
1050
  "payer": demo_wallet.address,
876
1051
  "hash": receipt["hash"],
877
1052
  "confirmed": bool(receipt.get("confirmed")),
878
- "explorer": f"https://nanexplorer.com/nano/block/{receipt['hash']}",
1053
+ "explorer": f"https://blocklattice.io/block/{receipt['hash']}",
879
1054
  "elapsed_s": elapsed,
880
1055
  "article": r2.json().get("article"),
881
1056
  "ledger": ledger,
@@ -994,8 +1169,17 @@ def create_app() -> FastAPI:
994
1169
  }
995
1170
 
996
1171
  @app.get("/faucet")
997
- def faucet_info():
1172
+ def faucet_info(request: Request):
998
1173
  """A human clicking a pasted /faucet link gets a signpost, not a 405."""
1174
+ # Rescue for nano-pay <= 0.2.3 clients given a faucet URL that carries
1175
+ # a query string (SKILL.md's ``claim https://feeless402.com/faucet?ref=skill``):
1176
+ # they naively append "/faucet/challenge" and arrive here as
1177
+ # ``/faucet?ref=skill/faucet/challenge&address=nano_…``. Serve them the
1178
+ # challenge they meant to ask for instead of the signpost.
1179
+ qs = str(request.url.query)
1180
+ addr = request.query_params.get("address", "")
1181
+ if "/faucet/challenge" in qs and addr.startswith("nano_"):
1182
+ return faucet_challenge(addr)
999
1183
  return {
1000
1184
  "how": "POST JSON {\"address\": \"nano_...\", \"work\": \"...\"} "
1001
1185
  "— GET /faucet/challenge first for the PoW root",
@@ -1081,6 +1265,35 @@ def create_app() -> FastAPI:
1081
1265
  headers={"Retry-After": str(max(1, int(frees_at - time.time())))},
1082
1266
  )
1083
1267
 
1268
+ # Global budget: the faucet as a whole serves at most N claims per
1269
+ # rolling hour, regardless of who is asking. Per-IP and per-address
1270
+ # rules rotate away for free against a proxy pool (Aug 6 farm:
1271
+ # 100+ claims/hour from unique IPs, identical UA); this is the one
1272
+ # limit that can't. Counted straight from ledger timestamps, so it
1273
+ # needs no extra state and survives restarts.
1274
+ hour_ago = time.time() - 3600
1275
+ hour_claims = sorted(
1276
+ t for t in led["addresses"].values() if t > hour_ago)
1277
+ if len(hour_claims) >= FAUCET_GLOBAL_PER_HOUR:
1278
+ # Honest Retry-After: the moment the in-window count drops BELOW
1279
+ # the cap — not when the single oldest claim ages out, which
1280
+ # after a burst frees no capacity at all.
1281
+ frees_at = hour_claims[len(hour_claims) - FAUCET_GLOBAL_PER_HOUR] + 3600
1282
+ wait = max(1, int(frees_at - time.time()))
1283
+ return JSONResponse(
1284
+ {"error": "faucet hourly budget used up — try again shortly",
1285
+ "limit_per_hour_global": FAUCET_GLOBAL_PER_HOUR,
1286
+ "retry_after_seconds": wait,
1287
+ "retry_at_utc": time.strftime(
1288
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(frees_at)),
1289
+ "note": "shared across all users; separately, each address "
1290
+ "may claim once ever",
1291
+ "building_something": "if the budget keeps blocking a real "
1292
+ f"integration, ask: {DOCS_URL}/issues"},
1293
+ status_code=429,
1294
+ headers={"Retry-After": str(wait)},
1295
+ )
1296
+
1084
1297
  amount = xno_to_raw(FAUCET_XNO)
1085
1298
 
1086
1299
  def _locked_claim():
@@ -1092,6 +1305,16 @@ def create_app() -> FastAPI:
1092
1305
  led2 = _ledger()
1093
1306
  if address in led2["addresses"]:
1094
1307
  return None
1308
+ # Re-check the global budget now that we hold the lock: a
1309
+ # burst can push many requests past the pre-lock check while
1310
+ # earlier sends are still queued here (Aug 6: 5 claims slipped
1311
+ # through a 3/hour cap exactly this way).
1312
+ cutoff = time.time() - 3600
1313
+ inwin = sorted(t for t in led2["addresses"].values()
1314
+ if t > cutoff)
1315
+ if len(inwin) >= FAUCET_GLOBAL_PER_HOUR:
1316
+ return ("budget",
1317
+ inwin[len(inwin) - FAUCET_GLOBAL_PER_HOUR] + 3600)
1095
1318
  faucet_wallet.load()
1096
1319
  # pocket any pending refills first
1097
1320
  faucet_wallet.receive_all(rpc, prework=False)
@@ -1117,6 +1340,17 @@ def create_app() -> FastAPI:
1117
1340
  {"error": "address already claimed its starter XNO"},
1118
1341
  status_code=429,
1119
1342
  )
1343
+ if isinstance(h, tuple) and h[0] == "budget":
1344
+ wait = max(1, int(h[1] - time.time()))
1345
+ return JSONResponse(
1346
+ {"error": "faucet hourly budget used up — try again shortly",
1347
+ "limit_per_hour_global": FAUCET_GLOBAL_PER_HOUR,
1348
+ "retry_after_seconds": wait,
1349
+ "note": "shared across all users; separately, each address "
1350
+ "may claim once ever"},
1351
+ status_code=429,
1352
+ headers={"Retry-After": str(wait)},
1353
+ )
1120
1354
  # Wait briefly for the send to confirm before answering. Clients
1121
1355
  # typically ask for their pending block the moment we reply, and if
1122
1356
  # it hasn't propagated they see "received nothing" and a zero
@@ -13,6 +13,8 @@ Design notes:
13
13
 
14
14
  import json
15
15
  import os
16
+ import tempfile
17
+ import threading
16
18
  from pathlib import Path
17
19
 
18
20
  import nanopy
@@ -34,6 +36,7 @@ class Wallet:
34
36
  def __init__(self, path: Path = None):
35
37
  self.path = Path(path) if path else DEFAULT_DIR / "wallet.json"
36
38
  self.data = None
39
+ self._save_lock = threading.Lock()
37
40
 
38
41
  # ---------- persistence ----------
39
42
 
@@ -65,10 +68,26 @@ class Wallet:
65
68
  return self
66
69
 
67
70
  def _save(self):
68
- tmp = self.path.with_suffix(".tmp")
69
- tmp.write_text(json.dumps(self.data, indent=2))
70
- os.chmod(tmp, 0o600)
71
- tmp.replace(self.path)
71
+ # Concurrent saves (send path + work-precompute thread) once interleaved
72
+ # on a shared .tmp name and promoted a mangled file — lock the whole
73
+ # write and give each save its own temp file.
74
+ with self._save_lock:
75
+ fd, tmp = tempfile.mkstemp(
76
+ dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp"
77
+ )
78
+ try:
79
+ with os.fdopen(fd, "w") as f:
80
+ f.write(json.dumps(self.data, indent=2))
81
+ f.flush()
82
+ os.fsync(f.fileno())
83
+ os.chmod(tmp, 0o600)
84
+ os.replace(tmp, self.path)
85
+ except BaseException:
86
+ try:
87
+ os.unlink(tmp)
88
+ except OSError:
89
+ pass
90
+ raise
72
91
 
73
92
  # ---------- account / state ----------
74
93
 
@@ -263,8 +263,16 @@ def request_with_payment(
263
263
  wallet.payment_succeeded(rpc, new_frontier, work_root, prework=prework)
264
264
  else:
265
265
  wallet.payment_failed(work_root)
266
- return r2, receipt or {
266
+ # What we paid is ours to know regardless of what the merchant echoes
267
+ # back — merge our ground truth under their receipt fields.
268
+ base = {
267
269
  "amount_xno": raw_to_xno(amount),
268
270
  "pay_to": pay_to,
271
+ "block": new_frontier,
269
272
  "settled": 200 <= r2.status_code < 300,
270
273
  }
274
+ if receipt:
275
+ base.update(receipt)
276
+ base["amount_xno"] = raw_to_xno(amount)
277
+ base["block"] = new_frontier
278
+ return r2, base
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "feeless402"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Feeless x402 micropayments for AI agents — self-custodied XNO wallet, pay-per-call client, merchant server with railHint, faucet, and MCP server"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
File without changes
File without changes
File without changes