feeless402 0.2.2__tar.gz → 0.2.3__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 (22) hide show
  1. {feeless402-0.2.2 → feeless402-0.2.3}/PKG-INFO +1 -1
  2. {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/PKG-INFO +1 -1
  3. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/__init__.py +1 -1
  4. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/cli.py +69 -14
  5. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/mcp_remote.py +2 -2
  6. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/rpc.py +29 -12
  7. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/server.py +118 -2
  8. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/wallet.py +23 -4
  9. {feeless402-0.2.2 → feeless402-0.2.3}/pyproject.toml +1 -1
  10. {feeless402-0.2.2 → feeless402-0.2.3}/LICENSE +0 -0
  11. {feeless402-0.2.2 → feeless402-0.2.3}/README.md +0 -0
  12. {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/SOURCES.txt +0 -0
  13. {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/dependency_links.txt +0 -0
  14. {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/entry_points.txt +0 -0
  15. {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/requires.txt +0 -0
  16. {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/top_level.txt +0 -0
  17. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/mcp_server.py +0 -0
  18. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/topup.py +0 -0
  19. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/verify.py +0 -0
  20. {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/x402.py +0 -0
  21. {feeless402-0.2.2 → feeless402-0.2.3}/setup.cfg +0 -0
  22. {feeless402-0.2.2 → feeless402-0.2.3}/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.3
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feeless402
3
- Version: 0.2.2
3
+ Version: 0.2.3
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
@@ -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.3"
4
4
 
5
5
  RAW_PER_XNO = 10**30
6
6
 
@@ -225,9 +225,21 @@ def _claim_one(base, w):
225
225
  if ch.get("pow_required"):
226
226
  import nanopy
227
227
 
228
+ # Deliberately local: the faucet's challenge is meant to cost the
229
+ # claimant real CPU, which is what stops a farm claiming in bulk.
230
+ # It cannot be handed to a work service without giving that away.
231
+ # It can, however, say so — running silent for minutes on the
232
+ # first command anyone tries reads as a hang.
233
+ print("solving the faucet's proof-of-work on this machine — "
234
+ "usually 30s to a few minutes. This is what keeps the "
235
+ "faucet from being drained; it only happens once.",
236
+ file=sys.stderr, flush=True)
237
+ t0 = time.time()
228
238
  payload["work"] = (
229
239
  f"{nanopy.ext.work_generate(bytes.fromhex(ch['root']), int(ch['difficulty'], 16), __import__('os').urandom(128)):016x}"
230
240
  )
241
+ print(f"proof-of-work solved in {time.time() - t0:.0f}s — claiming…",
242
+ file=sys.stderr, flush=True)
231
243
  except Exception:
232
244
  pass # no challenge endpoint — plain claim
233
245
  r = rq.post(f"{base}/faucet", json=payload, timeout=240)
@@ -270,21 +282,64 @@ def cmd_claim(args):
270
282
  if got:
271
283
  break
272
284
  time.sleep(1.5)
273
- if got:
274
- w.prework(got[-1][0], rpc) # warm the next block off the hash
275
285
  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
- )
286
+ payload = {
287
+ "claimed_from": base,
288
+ "result": res,
289
+ "received": [
290
+ {"hash": h, "amount_xno": raw_to_xno(a)}
291
+ for h, a in got
292
+ ],
293
+ "balance_xno": raw_to_xno(acct.raw_bal),
294
+ "attempts": attempts,
295
+ }
296
+ # Report the grant BEFORE warming the next block. Pre-computing
297
+ # takes minutes on a modest machine, and doing it first left the
298
+ # user staring at a blank terminal wondering whether the claim
299
+ # worked — on the very first command they ever run. Same order
300
+ # and same guard as send()/pay().
301
+ if got:
302
+ print(json.dumps(payload, indent=2, default=str), flush=True)
303
+ print("claimed — that was the slow part, and it is over. "
304
+ "Pre-computing the proof-of-work for your next block "
305
+ "now, and again after every transaction, so payments "
306
+ "from here are near-instant. Safe to Ctrl-C.",
307
+ file=sys.stderr, flush=True)
308
+ try:
309
+ w.prework(got[-1][0], rpc)
310
+ except Exception:
311
+ pass
312
+ sys.exit(0)
313
+ out(payload)
314
+ # An already-claimed address is not a broken faucet: the money was
315
+ # granted earlier (possibly to a client that gave up before the reply).
316
+ # Pocket anything still pending and report the balance instead of a
317
+ # hard error — exit 0 when the wallet is funded, since the agent can
318
+ # proceed straight to paying.
319
+ already = [
320
+ a for a in attempts
321
+ if a["status"] == 429
322
+ and "already claimed" in str(a["result"].get("error", ""))
323
+ ]
324
+ if already:
325
+ rpc = RPC()
326
+ got = w.receive_all(rpc, prework=False)
327
+ acct = w.synced_account(rpc)
328
+ bal = raw_to_xno(acct.raw_bal)
329
+ out(
330
+ {
331
+ "status": "already_claimed",
332
+ "message": f"this address already received its starter XNO — "
333
+ f"balance {bal} XNO, nothing more to claim here; "
334
+ f"generate a fresh wallet if you need a new grant",
335
+ "balance_xno": bal,
336
+ "received_now": [
337
+ {"hash": h, "amount_xno": raw_to_xno(a)} for h, a in got
338
+ ],
339
+ "attempts": attempts,
340
+ },
341
+ code=0 if acct.raw_bal else 1,
342
+ )
288
343
  out({"error": "no faucet granted a claim", "attempts": attempts}, code=1)
289
344
 
290
345
 
@@ -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 = [
@@ -115,6 +122,51 @@ def _count_paid_call(payer: str, demo_address: str) -> None:
115
122
  pass
116
123
 
117
124
 
125
+ def _maybe_graduate(payer: str) -> None:
126
+ """First real x402 payment from a faucet claimant quietly tops their
127
+ starter grant up to a full allowance. Never raises, never blocks the
128
+ payment response; the send runs in its own thread under FAUCET_LOCK.
129
+
130
+ Deliberately unadvertised on every user-facing surface: the paid call
131
+ itself is the proof of a real integration.
132
+
133
+ ⚠️ Aug 7 2026: SET TO ZERO (disabled). The premise — that paying proves
134
+ a real integration — held for about sixteen hours. A farm started paying
135
+ the 0.0001 premium fee to unlock the 0.0045 top-up, a 45x return, and
136
+ took 11 of 12 grants in a day. Any unlock that costs far less than the
137
+ prize is just a price, not a proof. Re-enable by setting
138
+ F402_FAUCET_TOPUP_XNO above zero if the economics ever change.
139
+ """
140
+ try:
141
+ if not FAUCET_TOPUP_ON:
142
+ return
143
+ if payer == demo_wallet.address or payer in _self_addresses():
144
+ return
145
+ led = _ledger()
146
+ if payer not in led["addresses"] or payer in led.get("graduated", {}):
147
+ return
148
+
149
+ def _send():
150
+ try:
151
+ with FAUCET_LOCK:
152
+ led2 = _ledger()
153
+ if payer in led2.setdefault("graduated", {}):
154
+ return
155
+ faucet_wallet.load()
156
+ faucet_wallet.receive_all(rpc, prework=False)
157
+ faucet_wallet.send(
158
+ rpc, payer, xno_to_raw(FAUCET_TOPUP_XNO),
159
+ prework=False)
160
+ led2["graduated"][payer] = time.time()
161
+ _save_ledger(led2)
162
+ except Exception:
163
+ pass
164
+
165
+ threading.Thread(target=_send, daemon=True).start()
166
+ except Exception:
167
+ pass
168
+
169
+
118
170
  def _faucet_funding() -> dict:
119
171
  """Who has put XNO INTO the faucet, read straight off the chain.
120
172
 
@@ -358,7 +410,10 @@ def _stats_data():
358
410
  "claims_total": len(ext_times),
359
411
  "claims_24h": sum(1 for t in ext_times if t > day_ago),
360
412
  "self_test_claims": len(claims) - len(ext_times),
361
- "xno_dispensed": round(len(claims) * per_claim, 6),
413
+ "xno_dispensed": round(sum(
414
+ next(rate for start, rate in reversed(FAUCET_RATE_CHANGES)
415
+ if t >= start)
416
+ for t in ts_list), 6),
362
417
  "xno_per_claim": per_claim,
363
418
  "balance_xno": faucet_bal,
364
419
  "claims_remaining": (
@@ -582,6 +637,16 @@ def create_app() -> FastAPI:
582
637
  def stats_json():
583
638
  return _stats_data()
584
639
 
640
+ # Liveness for uptime monitors and integrators (real users probed for
641
+ # both spellings) — cheap on purpose: no RPC, no wallet, no state writes.
642
+ start_unix = time.time()
643
+
644
+ @app.get("/health")
645
+ @app.get("/status")
646
+ def health():
647
+ return {"ok": True, "service": "feeless402", "version": __version__,
648
+ "uptime_s": int(time.time() - start_unix)}
649
+
585
650
  @app.get("/stats", response_class=HTMLResponse)
586
651
  def stats_page():
587
652
  return STATS_HTML
@@ -643,6 +708,7 @@ def create_app() -> FastAPI:
643
708
  )
644
709
  receipt = settle_block(block, rpc)
645
710
  _count_paid_call(payer, demo_wallet.address)
711
+ _maybe_graduate(payer)
646
712
  except PaymentInvalid as e:
647
713
  return JSONResponse(
648
714
  {"error": f"payment invalid: {e}"}, status_code=402
@@ -1081,6 +1147,35 @@ def create_app() -> FastAPI:
1081
1147
  headers={"Retry-After": str(max(1, int(frees_at - time.time())))},
1082
1148
  )
1083
1149
 
1150
+ # Global budget: the faucet as a whole serves at most N claims per
1151
+ # rolling hour, regardless of who is asking. Per-IP and per-address
1152
+ # rules rotate away for free against a proxy pool (Aug 6 farm:
1153
+ # 100+ claims/hour from unique IPs, identical UA); this is the one
1154
+ # limit that can't. Counted straight from ledger timestamps, so it
1155
+ # needs no extra state and survives restarts.
1156
+ hour_ago = time.time() - 3600
1157
+ hour_claims = sorted(
1158
+ t for t in led["addresses"].values() if t > hour_ago)
1159
+ if len(hour_claims) >= FAUCET_GLOBAL_PER_HOUR:
1160
+ # Honest Retry-After: the moment the in-window count drops BELOW
1161
+ # the cap — not when the single oldest claim ages out, which
1162
+ # after a burst frees no capacity at all.
1163
+ frees_at = hour_claims[len(hour_claims) - FAUCET_GLOBAL_PER_HOUR] + 3600
1164
+ wait = max(1, int(frees_at - time.time()))
1165
+ return JSONResponse(
1166
+ {"error": "faucet hourly budget used up — try again shortly",
1167
+ "limit_per_hour_global": FAUCET_GLOBAL_PER_HOUR,
1168
+ "retry_after_seconds": wait,
1169
+ "retry_at_utc": time.strftime(
1170
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(frees_at)),
1171
+ "note": "shared across all users; separately, each address "
1172
+ "may claim once ever",
1173
+ "building_something": "if the budget keeps blocking a real "
1174
+ f"integration, ask: {DOCS_URL}/issues"},
1175
+ status_code=429,
1176
+ headers={"Retry-After": str(wait)},
1177
+ )
1178
+
1084
1179
  amount = xno_to_raw(FAUCET_XNO)
1085
1180
 
1086
1181
  def _locked_claim():
@@ -1092,6 +1187,16 @@ def create_app() -> FastAPI:
1092
1187
  led2 = _ledger()
1093
1188
  if address in led2["addresses"]:
1094
1189
  return None
1190
+ # Re-check the global budget now that we hold the lock: a
1191
+ # burst can push many requests past the pre-lock check while
1192
+ # earlier sends are still queued here (Aug 6: 5 claims slipped
1193
+ # through a 3/hour cap exactly this way).
1194
+ cutoff = time.time() - 3600
1195
+ inwin = sorted(t for t in led2["addresses"].values()
1196
+ if t > cutoff)
1197
+ if len(inwin) >= FAUCET_GLOBAL_PER_HOUR:
1198
+ return ("budget",
1199
+ inwin[len(inwin) - FAUCET_GLOBAL_PER_HOUR] + 3600)
1095
1200
  faucet_wallet.load()
1096
1201
  # pocket any pending refills first
1097
1202
  faucet_wallet.receive_all(rpc, prework=False)
@@ -1117,6 +1222,17 @@ def create_app() -> FastAPI:
1117
1222
  {"error": "address already claimed its starter XNO"},
1118
1223
  status_code=429,
1119
1224
  )
1225
+ if isinstance(h, tuple) and h[0] == "budget":
1226
+ wait = max(1, int(h[1] - time.time()))
1227
+ return JSONResponse(
1228
+ {"error": "faucet hourly budget used up — try again shortly",
1229
+ "limit_per_hour_global": FAUCET_GLOBAL_PER_HOUR,
1230
+ "retry_after_seconds": wait,
1231
+ "note": "shared across all users; separately, each address "
1232
+ "may claim once ever"},
1233
+ status_code=429,
1234
+ headers={"Retry-After": str(wait)},
1235
+ )
1120
1236
  # Wait briefly for the send to confirm before answering. Clients
1121
1237
  # typically ask for their pending block the moment we reply, and if
1122
1238
  # 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
 
@@ -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.3"
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
File without changes
File without changes