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.
- {feeless402-0.2.2 → feeless402-0.2.3}/PKG-INFO +1 -1
- {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/PKG-INFO +1 -1
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/__init__.py +1 -1
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/cli.py +69 -14
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/mcp_remote.py +2 -2
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/rpc.py +29 -12
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/server.py +118 -2
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/wallet.py +23 -4
- {feeless402-0.2.2 → feeless402-0.2.3}/pyproject.toml +1 -1
- {feeless402-0.2.2 → feeless402-0.2.3}/LICENSE +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/README.md +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/SOURCES.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/dependency_links.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/entry_points.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/requires.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/feeless402.egg-info/top_level.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/mcp_server.py +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/topup.py +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/verify.py +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/nano_pay/x402.py +0 -0
- {feeless402-0.2.2 → feeless402-0.2.3}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
"
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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.
|
|
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.
|
|
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
|
|
95
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
69
|
-
tmp
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|