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.
- {feeless402-0.2.2 → feeless402-0.2.4}/PKG-INFO +12 -2
- {feeless402-0.2.2 → feeless402-0.2.4}/README.md +11 -1
- {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/PKG-INFO +12 -2
- {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/SOURCES.txt +1 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/__init__.py +1 -1
- feeless402-0.2.4/nano_pay/a2a_remote.py +230 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/cli.py +84 -17
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/mcp_remote.py +2 -2
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/rpc.py +29 -12
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/server.py +256 -22
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/wallet.py +23 -4
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/x402.py +9 -1
- {feeless402-0.2.2 → feeless402-0.2.4}/pyproject.toml +1 -1
- {feeless402-0.2.2 → feeless402-0.2.4}/LICENSE +0 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/dependency_links.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/entry_points.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/requires.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/feeless402.egg-info/top_level.txt +0 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/mcp_server.py +0 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/topup.py +0 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/nano_pay/verify.py +0 -0
- {feeless402-0.2.2 → feeless402-0.2.4}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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).
|
|
@@ -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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
"
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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.
|
|
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 = [
|
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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, "
|
|
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
|
|
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": {
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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(
|
|
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">–</div><div class="lbl">paid calls by strangers</div></div>
|
|
504
657
|
<div class="tile"><div class="num" id="p_demo">–</div><div class="lbl">homepage demo unlocks (we pay those)</div></div>
|
|
658
|
+
<div class="tile"><div class="num" id="p_farm">–</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">–</div><div class="lbl">treasury balance (XNO, incl. our own demo)</div></div>
|
|
506
660
|
<div class="tile"><div class="num" id="t_price">–</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> — <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
|
-
|
|
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://
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|