arc-builder-kit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arc_builder_kit/__init__.py +4 -0
- arc_builder_kit/__main__.py +6 -0
- arc_builder_kit/_paths.py +47 -0
- arc_builder_kit/cli.py +277 -0
- arc_builder_kit/config/arc_testnet.facts.json +31 -0
- arc_builder_kit/doctor.py +936 -0
- arc_builder_kit/examples/agent-commerce-components/components.js +200 -0
- arc_builder_kit/examples/agent-commerce-components/index.html +120 -0
- arc_builder_kit/examples/agent-commerce-flows/flows.js +271 -0
- arc_builder_kit/examples/agent-commerce-flows/index.html +114 -0
- arc_builder_kit/examples/agent-commerce-live/commerce-live.js +190 -0
- arc_builder_kit/examples/agent-commerce-live/index.html +105 -0
- arc_builder_kit/examples/agent-commerce-review-packet/index.html +96 -0
- arc_builder_kit/examples/agent-commerce-review-packet/packet.js +125 -0
- arc_builder_kit/examples/agent-identity-profile-preview/identity.js +126 -0
- arc_builder_kit/examples/agent-identity-profile-preview/index.html +104 -0
- arc_builder_kit/examples/arc-agent-treasury-lab/index.html +152 -0
- arc_builder_kit/examples/arc-agent-treasury-lab/treasury.js +532 -0
- arc_builder_kit/examples/arc-testnet-operator-evidence/evidence.example.json +47 -0
- arc_builder_kit/examples/arc-testnet-wallet-send-gate/index.html +233 -0
- arc_builder_kit/examples/arc-testnet-wallet-send-gate/live-infrastructure-policy.example.json +59 -0
- arc_builder_kit/examples/arc-testnet-wallet-send-gate/wallet-send-gate.js +472 -0
- arc_builder_kit/examples/circle-wallet-integration/index.html +155 -0
- arc_builder_kit/examples/circle-wallet-integration/wallet-lab.js +91 -0
- arc_builder_kit/examples/job-escrow-simulator/index.html +121 -0
- arc_builder_kit/examples/job-escrow-simulator/simulator.js +162 -0
- arc_builder_kit/examples/payment-intent-demo/index.html +132 -0
- arc_builder_kit/examples/payment-intent-playground/index.html +301 -0
- arc_builder_kit/examples/payment-intent-playground/playground.js +835 -0
- arc_builder_kit/examples/payment-intent-receipt-matcher/index.html +157 -0
- arc_builder_kit/examples/payment-intent-receipt-matcher/matcher.js +877 -0
- arc_builder_kit/examples/receipt-verifier-playground/index.html +120 -0
- arc_builder_kit/examples/receipt-verifier-playground/verifier.js +226 -0
- arc_builder_kit/examples/receipt-viewer/index.html +138 -0
- arc_builder_kit/examples/receipt-viewer/receipt-viewer.js +472 -0
- arc_builder_kit/examples/transaction-status-playground/index.html +135 -0
- arc_builder_kit/examples/transaction-status-playground/status.js +518 -0
- arc_builder_kit/examples/x402-local-challenge-server/.env.example +25 -0
- arc_builder_kit/examples/x402-local-challenge-server/README.md +111 -0
- arc_builder_kit/examples/x402-local-challenge-server/server.py +711 -0
- arc_builder_kit/mcp_server.py +463 -0
- arc_builder_kit/release_packet.py +469 -0
- arc_builder_kit/templates/README.md +25 -0
- arc_builder_kit/templates/job-escrow-starter/README.md +25 -0
- arc_builder_kit/templates/job-escrow-starter/index.html +41 -0
- arc_builder_kit/templates/job-escrow-starter/index.js +14 -0
- arc_builder_kit/templates/payment-intent-starter/README.md +25 -0
- arc_builder_kit/templates/payment-intent-starter/index.html +42 -0
- arc_builder_kit/templates/payment-intent-starter/index.js +7 -0
- arc_builder_kit/templates/x402-agent-starter/README.md +29 -0
- arc_builder_kit/templates/x402-agent-starter/server.py +201 -0
- arc_builder_kit/validate_repo.py +2212 -0
- arc_builder_kit-0.2.0.dist-info/METADATA +543 -0
- arc_builder_kit-0.2.0.dist-info/RECORD +58 -0
- arc_builder_kit-0.2.0.dist-info/WHEEL +5 -0
- arc_builder_kit-0.2.0.dist-info/entry_points.txt +3 -0
- arc_builder_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
- arc_builder_kit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local-only x402-style challenge server for Arc builder demos.
|
|
3
|
+
|
|
4
|
+
This example is intentionally production-shaped but not production-connected:
|
|
5
|
+
- it returns a deterministic 402 challenge for protected resources;
|
|
6
|
+
- it verifies a local demo proof through a verifier interface;
|
|
7
|
+
- it never opens a wallet, settles funds, or broadcasts transactions.
|
|
8
|
+
|
|
9
|
+
Replace ``LocalDemoVerifier`` with a real Circle/x402 verifier only after the
|
|
10
|
+
payment network, asset, recipient, and replay/expiry rules are fully scoped.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from http import HTTPStatus
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
23
|
+
from typing import Iterator, Mapping, Protocol
|
|
24
|
+
from urllib.parse import urlsplit
|
|
25
|
+
|
|
26
|
+
DEFAULT_NETWORK = "arc-testnet"
|
|
27
|
+
DEFAULT_ASSET = "USDC"
|
|
28
|
+
DEFAULT_AMOUNT = "0.01"
|
|
29
|
+
DEFAULT_PAY_TO = "0xA11CE00000000000000000000000000000000000"
|
|
30
|
+
DEFAULT_RESOURCE = "arc-mcp-builder-assistant.local-report.v1"
|
|
31
|
+
ZERO_EVM_ADDRESS = "0x0000000000000000000000000000000000000000"
|
|
32
|
+
EVM_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
|
33
|
+
TRUTHY = {"1", "true", "yes", "on"}
|
|
34
|
+
FALSY = {"", "0", "false", "no", "off"}
|
|
35
|
+
LOCAL_BIND_HOSTS = {"127.0.0.1", "localhost"}
|
|
36
|
+
MAX_MCP_LINE_BYTES = 1_000_000
|
|
37
|
+
MAX_PAYMENT_PROOF_BYTES = 4_096
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class PaymentConfig:
|
|
42
|
+
"""Safe-by-default payment request configuration for the demo boundary."""
|
|
43
|
+
|
|
44
|
+
network: str
|
|
45
|
+
asset: str
|
|
46
|
+
amount: str
|
|
47
|
+
pay_to: str
|
|
48
|
+
resource: str
|
|
49
|
+
verifier_mode: str = "local-simulation"
|
|
50
|
+
human_approval_required: bool = True
|
|
51
|
+
mainnet_enabled: bool = False
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def demo(cls) -> "PaymentConfig":
|
|
55
|
+
return cls(
|
|
56
|
+
network=DEFAULT_NETWORK,
|
|
57
|
+
asset=DEFAULT_ASSET,
|
|
58
|
+
amount=DEFAULT_AMOUNT,
|
|
59
|
+
pay_to=DEFAULT_PAY_TO,
|
|
60
|
+
resource=DEFAULT_RESOURCE,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> "PaymentConfig":
|
|
65
|
+
values = os.environ if env is None else env
|
|
66
|
+
config = cls(
|
|
67
|
+
network=values.get("X402_DEMO_NETWORK", DEFAULT_NETWORK).strip() or DEFAULT_NETWORK,
|
|
68
|
+
asset=values.get("X402_DEMO_ASSET", DEFAULT_ASSET).strip() or DEFAULT_ASSET,
|
|
69
|
+
amount=values.get("X402_DEMO_AMOUNT", DEFAULT_AMOUNT).strip() or DEFAULT_AMOUNT,
|
|
70
|
+
pay_to=values.get("X402_DEMO_PAY_TO", DEFAULT_PAY_TO).strip() or DEFAULT_PAY_TO,
|
|
71
|
+
resource=DEFAULT_RESOURCE,
|
|
72
|
+
mainnet_enabled=parse_env_bool(values.get("X402_DEMO_MAINNET_ENABLED", "false"), "X402_DEMO_MAINNET_ENABLED"),
|
|
73
|
+
)
|
|
74
|
+
validate_payment_config(config)
|
|
75
|
+
return config
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Response:
|
|
80
|
+
status: int
|
|
81
|
+
body: dict[str, object]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class VerificationResult:
|
|
86
|
+
ok: bool
|
|
87
|
+
reason: str
|
|
88
|
+
receipt: dict[str, object]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PaymentVerifier(Protocol):
|
|
92
|
+
"""Boundary for swapping local proof checks with a real verifier later."""
|
|
93
|
+
|
|
94
|
+
def verify(self, proof: str, challenge: Mapping[str, object], config: PaymentConfig) -> VerificationResult:
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LocalDemoVerifier:
|
|
99
|
+
"""Accepts only explicit local demo proofs.
|
|
100
|
+
|
|
101
|
+
Valid proof shape:
|
|
102
|
+
local-demo:<challenge-id>:<amount>
|
|
103
|
+
|
|
104
|
+
This is deliberately not a signature scheme. It is a deterministic local
|
|
105
|
+
switch that lets the example model the 402 -> proof -> 200 flow without
|
|
106
|
+
pretending that funds moved.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def verify(self, proof: str, challenge: Mapping[str, object], config: PaymentConfig) -> VerificationResult:
|
|
110
|
+
expected = f"local-demo:{challenge['id']}:{config.amount}"
|
|
111
|
+
if proof != expected:
|
|
112
|
+
return VerificationResult(
|
|
113
|
+
ok=False,
|
|
114
|
+
reason="invalid_local_demo_proof",
|
|
115
|
+
receipt={
|
|
116
|
+
"settled": False,
|
|
117
|
+
"transactionBroadcast": False,
|
|
118
|
+
"verifierMode": config.verifier_mode,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
return VerificationResult(
|
|
122
|
+
ok=True,
|
|
123
|
+
reason="local_demo_proof_accepted",
|
|
124
|
+
receipt={
|
|
125
|
+
"settled": False,
|
|
126
|
+
"transactionBroadcast": False,
|
|
127
|
+
"mainnetEnabled": config.mainnet_enabled,
|
|
128
|
+
"verifierMode": config.verifier_mode,
|
|
129
|
+
"challengeId": challenge["id"],
|
|
130
|
+
"network": config.network,
|
|
131
|
+
"asset": config.asset,
|
|
132
|
+
"amount": config.amount,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def require_exact_keys(value: Mapping[str, object], expected: set[str], label: str) -> None:
|
|
138
|
+
observed = set(value)
|
|
139
|
+
if observed != expected:
|
|
140
|
+
missing = sorted(expected - observed)
|
|
141
|
+
unknown = sorted(observed - expected)
|
|
142
|
+
raise ValueError(f"{label} keys must match exactly; missing={missing}, unknown={unknown}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def validate_payment_proof(proof: str) -> str:
|
|
146
|
+
if not isinstance(proof, str):
|
|
147
|
+
raise ValueError("X-Payment proof must be a string")
|
|
148
|
+
if not proof:
|
|
149
|
+
raise ValueError("X-Payment proof must not be empty")
|
|
150
|
+
if len(proof.encode("utf-8")) > MAX_PAYMENT_PROOF_BYTES:
|
|
151
|
+
raise ValueError("X-Payment proof exceeds the 4 KB safety limit")
|
|
152
|
+
if any(character in proof for character in "\r\n\0"):
|
|
153
|
+
raise ValueError("X-Payment proof contains forbidden control characters")
|
|
154
|
+
return proof
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def extract_payment_proof(headers: Mapping[str, str]) -> str | None:
|
|
158
|
+
get_all = getattr(headers, "get_all", None)
|
|
159
|
+
if callable(get_all):
|
|
160
|
+
values = get_all("X-Payment") or []
|
|
161
|
+
else:
|
|
162
|
+
values = [
|
|
163
|
+
value
|
|
164
|
+
for key, value in headers.items()
|
|
165
|
+
if isinstance(key, str) and key.lower() == "x-payment"
|
|
166
|
+
]
|
|
167
|
+
if not values:
|
|
168
|
+
return None
|
|
169
|
+
if len(values) != 1:
|
|
170
|
+
raise ValueError("exactly one X-Payment header is required")
|
|
171
|
+
return validate_payment_proof(values[0])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_env_bool(value: str | None, name: str) -> bool:
|
|
175
|
+
normalized = (value or "").strip().lower()
|
|
176
|
+
if normalized in TRUTHY:
|
|
177
|
+
return True
|
|
178
|
+
if normalized in FALSY:
|
|
179
|
+
return False
|
|
180
|
+
raise ValueError(f"{name} must be one of true/false/1/0/yes/no/on/off")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def amount_to_micro_usd(amount: str) -> int:
|
|
184
|
+
"""Convert a decimal USDC string to integer microUSD for agents."""
|
|
185
|
+
|
|
186
|
+
whole, dot, fractional = amount.partition(".")
|
|
187
|
+
if not whole.isdigit() or (dot and not fractional.isdigit()):
|
|
188
|
+
raise ValueError("amount must be a positive decimal string")
|
|
189
|
+
if len(fractional) > 6:
|
|
190
|
+
raise ValueError("USDC demo amounts use at most 6 decimal places")
|
|
191
|
+
return int(whole) * 1_000_000 + int(fractional.ljust(6, "0") or "0")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate_payment_config(config: PaymentConfig) -> None:
|
|
195
|
+
"""Reject config that would weaken the local Arc Testnet boundary."""
|
|
196
|
+
|
|
197
|
+
if config.network != DEFAULT_NETWORK:
|
|
198
|
+
raise ValueError("X402_DEMO_NETWORK must stay arc-testnet for this Arc-focused demo")
|
|
199
|
+
if config.asset != DEFAULT_ASSET:
|
|
200
|
+
raise ValueError("X402_DEMO_ASSET must stay USDC because this demo uses USDC 6-decimal economics")
|
|
201
|
+
try:
|
|
202
|
+
amount_micro_usd = amount_to_micro_usd(config.amount)
|
|
203
|
+
except ValueError as error:
|
|
204
|
+
raise ValueError(f"X402_DEMO_AMOUNT invalid: {error}") from error
|
|
205
|
+
if amount_micro_usd <= 0:
|
|
206
|
+
raise ValueError("X402_DEMO_AMOUNT must be greater than 0")
|
|
207
|
+
if not EVM_ADDRESS_RE.match(config.pay_to):
|
|
208
|
+
raise ValueError("X402_DEMO_PAY_TO must be a 42-character EVM address")
|
|
209
|
+
if config.pay_to.lower() == ZERO_EVM_ADDRESS:
|
|
210
|
+
raise ValueError("X402_DEMO_PAY_TO must be a non-zero EVM address")
|
|
211
|
+
if config.resource != DEFAULT_RESOURCE:
|
|
212
|
+
raise ValueError("payment resource must stay pinned to the reviewed local demo resource")
|
|
213
|
+
if config.verifier_mode != "local-simulation":
|
|
214
|
+
raise ValueError("verifier mode must stay local-simulation in this demo")
|
|
215
|
+
if config.human_approval_required is not True:
|
|
216
|
+
raise ValueError("human approval must remain required in this demo")
|
|
217
|
+
if config.mainnet_enabled is not False:
|
|
218
|
+
raise ValueError("X402_DEMO_MAINNET_ENABLED must remain false in this local demo")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def validate_bind_target(host: str, port: int) -> None:
|
|
222
|
+
"""Keep the local proof demo unavailable on external interfaces."""
|
|
223
|
+
if host.strip().lower() not in LOCAL_BIND_HOSTS:
|
|
224
|
+
raise ValueError("--host must stay 127.0.0.1 or localhost for this local-only demo")
|
|
225
|
+
if not 1 <= port <= 65535:
|
|
226
|
+
raise ValueError("--port must be between 1 and 65535")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_unit_economics(config: PaymentConfig) -> dict[str, object]:
|
|
230
|
+
"""Return integer-priced demo economics without float ambiguity."""
|
|
231
|
+
|
|
232
|
+
validate_payment_config(config)
|
|
233
|
+
return {
|
|
234
|
+
"asset": config.asset,
|
|
235
|
+
"assetDecimals": 6,
|
|
236
|
+
"priceMicroUsd": amount_to_micro_usd(config.amount),
|
|
237
|
+
"displayPrice": f"{config.amount} {config.asset}",
|
|
238
|
+
"billingModel": "one local demo proof unlocks one protected report response",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def build_payment_challenge(config: PaymentConfig) -> dict[str, object]:
|
|
243
|
+
"""Return an x402-shaped payment challenge with safe local metadata."""
|
|
244
|
+
|
|
245
|
+
challenge_id = f"{config.resource}:{config.network}:{config.asset}:{config.amount}:{config.pay_to.lower()}"
|
|
246
|
+
return {
|
|
247
|
+
"id": challenge_id,
|
|
248
|
+
"x402Version": "demo-boundary-v1",
|
|
249
|
+
"resource": config.resource,
|
|
250
|
+
"accepts": [
|
|
251
|
+
{
|
|
252
|
+
"scheme": "exact",
|
|
253
|
+
"network": config.network,
|
|
254
|
+
"asset": config.asset,
|
|
255
|
+
"amount": config.amount,
|
|
256
|
+
"payTo": config.pay_to,
|
|
257
|
+
}
|
|
258
|
+
],
|
|
259
|
+
"unitEconomics": build_unit_economics(config),
|
|
260
|
+
"verifierMode": config.verifier_mode,
|
|
261
|
+
"humanApprovalRequired": config.human_approval_required,
|
|
262
|
+
"mainnetEnabled": config.mainnet_enabled,
|
|
263
|
+
"transactionBroadcast": False,
|
|
264
|
+
"instructions": "Approve locally only, then send X-Payment: local-demo:<challenge-id>:<amount>.",
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def build_mcp_manifest(config: PaymentConfig) -> dict[str, object]:
|
|
269
|
+
"""Return a machine-readable paid-agent manifest for local MCP-style discovery."""
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"name": "arc-local-x402-paid-agent",
|
|
273
|
+
"version": "0.1.0",
|
|
274
|
+
"description": "Local-only Arc Testnet x402-style paid agent boundary for builders.",
|
|
275
|
+
"network": {
|
|
276
|
+
"name": "Arc Testnet",
|
|
277
|
+
"chainId": 5_042_002,
|
|
278
|
+
"chainIdHex": "0x4cef52",
|
|
279
|
+
"rpc": "https://rpc.testnet.arc.network",
|
|
280
|
+
"explorer": "https://testnet.arcscan.app",
|
|
281
|
+
},
|
|
282
|
+
"payment": {
|
|
283
|
+
"network": config.network,
|
|
284
|
+
"asset": config.asset,
|
|
285
|
+
"assetDecimals": 6,
|
|
286
|
+
"amount": config.amount,
|
|
287
|
+
"payTo": config.pay_to,
|
|
288
|
+
"resource": config.resource,
|
|
289
|
+
},
|
|
290
|
+
"unitEconomics": build_unit_economics(config),
|
|
291
|
+
"safety": {
|
|
292
|
+
"testnetOnly": True,
|
|
293
|
+
"humanApprovalRequired": config.human_approval_required,
|
|
294
|
+
"localDemoProofOnly": True,
|
|
295
|
+
"mainnetEnabled": config.mainnet_enabled,
|
|
296
|
+
"transactionBroadcast": False,
|
|
297
|
+
"privateKeysAccepted": False,
|
|
298
|
+
"autonomousSpending": False,
|
|
299
|
+
},
|
|
300
|
+
"productionReplacementBoundary": (
|
|
301
|
+
"Replace LocalDemoVerifier with Circle Gateway/x402 verification only after "
|
|
302
|
+
"network, asset, pay-to ownership, expiry, replay protection, logging, "
|
|
303
|
+
"and settlement finality rules are reviewed."
|
|
304
|
+
),
|
|
305
|
+
"builderContext": {
|
|
306
|
+
"verifiedFacts": [
|
|
307
|
+
"Arc Testnet chain ID is 5042002 / 0x4cef52.",
|
|
308
|
+
"ERC-20 USDC amounts use 6 decimal places.",
|
|
309
|
+
],
|
|
310
|
+
"repoChoices": [
|
|
311
|
+
"This demo uses deterministic local proof strings instead of live settlement.",
|
|
312
|
+
"The HTTP server binds to localhost by default.",
|
|
313
|
+
],
|
|
314
|
+
"assumptionsAndUnknowns": [
|
|
315
|
+
"A production verifier, nonce store, and wallet approval path are not implemented here.",
|
|
316
|
+
],
|
|
317
|
+
"nonGoals": [
|
|
318
|
+
"No wallet signing.",
|
|
319
|
+
"No transaction broadcast.",
|
|
320
|
+
"No mainnet payment processing.",
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
"tools": [
|
|
324
|
+
{
|
|
325
|
+
"name": "inspect_payment_challenge",
|
|
326
|
+
"description": "Return the current local x402-style challenge without requiring a proof.",
|
|
327
|
+
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"name": "get_paid_resource",
|
|
331
|
+
"description": "Return the protected local report when X-Payment carries the accepted demo proof.",
|
|
332
|
+
"inputSchema": {
|
|
333
|
+
"type": "object",
|
|
334
|
+
"properties": {"xPayment": {"type": "string"}},
|
|
335
|
+
"required": ["xPayment"],
|
|
336
|
+
"additionalProperties": False,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def payment_required_response(config: PaymentConfig, error: str = "payment_required") -> Response:
|
|
344
|
+
challenge = build_payment_challenge(config)
|
|
345
|
+
return Response(
|
|
346
|
+
status=HTTPStatus.PAYMENT_REQUIRED,
|
|
347
|
+
body={
|
|
348
|
+
"error": error,
|
|
349
|
+
**challenge,
|
|
350
|
+
"mcpManifest": build_mcp_manifest(config),
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def handle_protected_request(
|
|
356
|
+
headers: Mapping[str, str],
|
|
357
|
+
config: PaymentConfig | None = None,
|
|
358
|
+
verifier: PaymentVerifier | None = None,
|
|
359
|
+
) -> Response:
|
|
360
|
+
"""Return 402 until a local verifier accepts the proof header."""
|
|
361
|
+
|
|
362
|
+
config = config or PaymentConfig.demo()
|
|
363
|
+
verifier = verifier or LocalDemoVerifier()
|
|
364
|
+
try:
|
|
365
|
+
proof = extract_payment_proof(headers)
|
|
366
|
+
except ValueError as error:
|
|
367
|
+
return Response(
|
|
368
|
+
status=HTTPStatus.PAYMENT_REQUIRED,
|
|
369
|
+
body={
|
|
370
|
+
"error": "invalid_x_payment",
|
|
371
|
+
"reason": str(error),
|
|
372
|
+
"settled": False,
|
|
373
|
+
"transactionBroadcast": False,
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
if not proof:
|
|
377
|
+
return payment_required_response(config)
|
|
378
|
+
|
|
379
|
+
challenge = build_payment_challenge(config)
|
|
380
|
+
try:
|
|
381
|
+
verification = verifier.verify(proof, challenge, config)
|
|
382
|
+
except Exception:
|
|
383
|
+
return Response(
|
|
384
|
+
status=HTTPStatus.PAYMENT_REQUIRED,
|
|
385
|
+
body={
|
|
386
|
+
"error": "payment_verifier_unavailable",
|
|
387
|
+
"settled": False,
|
|
388
|
+
"transactionBroadcast": False,
|
|
389
|
+
"challenge": challenge,
|
|
390
|
+
},
|
|
391
|
+
)
|
|
392
|
+
if not isinstance(verification, VerificationResult):
|
|
393
|
+
return Response(
|
|
394
|
+
status=HTTPStatus.PAYMENT_REQUIRED,
|
|
395
|
+
body={
|
|
396
|
+
"error": "invalid_verifier_result",
|
|
397
|
+
"settled": False,
|
|
398
|
+
"transactionBroadcast": False,
|
|
399
|
+
"challenge": challenge,
|
|
400
|
+
},
|
|
401
|
+
)
|
|
402
|
+
if not verification.ok:
|
|
403
|
+
return Response(
|
|
404
|
+
status=HTTPStatus.PAYMENT_REQUIRED,
|
|
405
|
+
body={
|
|
406
|
+
"error": "payment_verification_failed",
|
|
407
|
+
"reason": "proof_not_accepted",
|
|
408
|
+
"settled": False,
|
|
409
|
+
"transactionBroadcast": False,
|
|
410
|
+
"challenge": challenge,
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
if (
|
|
414
|
+
verification.receipt.get("settled") is not False
|
|
415
|
+
or verification.receipt.get("transactionBroadcast") is not False
|
|
416
|
+
):
|
|
417
|
+
return Response(
|
|
418
|
+
status=HTTPStatus.PAYMENT_REQUIRED,
|
|
419
|
+
body={
|
|
420
|
+
"error": "unsafe_verifier_result",
|
|
421
|
+
"settled": False,
|
|
422
|
+
"transactionBroadcast": False,
|
|
423
|
+
"challenge": challenge,
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return Response(
|
|
428
|
+
status=HTTPStatus.OK,
|
|
429
|
+
body={
|
|
430
|
+
"ok": True,
|
|
431
|
+
"data": {
|
|
432
|
+
"message": "Protected Arc builder resource unlocked.",
|
|
433
|
+
"resource": config.resource,
|
|
434
|
+
},
|
|
435
|
+
"receipt": verification.receipt,
|
|
436
|
+
"unitEconomics": build_unit_economics(config),
|
|
437
|
+
"mcpManifest": build_mcp_manifest(config),
|
|
438
|
+
},
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def make_jsonrpc_result(request_id: object, result: dict[str, object]) -> dict[str, object]:
|
|
443
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def make_jsonrpc_error(request_id: object, code: int, message: str) -> dict[str, object]:
|
|
447
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
451
|
+
result: dict[str, object] = {}
|
|
452
|
+
for key, value in pairs:
|
|
453
|
+
if key in result:
|
|
454
|
+
raise ValueError(f"duplicate JSON key is not allowed: {key}")
|
|
455
|
+
result[key] = value
|
|
456
|
+
return result
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def bounded_mcp_lines() -> Iterator[bytes | None]:
|
|
460
|
+
"""Yield bounded stdin lines and use None for rejected oversized input."""
|
|
461
|
+
|
|
462
|
+
stream = sys.stdin.buffer
|
|
463
|
+
while True:
|
|
464
|
+
payload = stream.readline(MAX_MCP_LINE_BYTES + 1)
|
|
465
|
+
if not payload:
|
|
466
|
+
return
|
|
467
|
+
if len(payload) > MAX_MCP_LINE_BYTES:
|
|
468
|
+
while payload and not payload.endswith(b"\n"):
|
|
469
|
+
payload = stream.readline(MAX_MCP_LINE_BYTES + 1)
|
|
470
|
+
yield None
|
|
471
|
+
continue
|
|
472
|
+
yield payload
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def call_manifest_tool(name: str, arguments: Mapping[str, object] | None = None, config: PaymentConfig | None = None) -> dict[str, object]:
|
|
476
|
+
"""Dispatch the local manifest tools without wallet/RPC side effects."""
|
|
477
|
+
|
|
478
|
+
config = config or PaymentConfig.demo()
|
|
479
|
+
arguments = arguments or {}
|
|
480
|
+
if name == "inspect_payment_challenge":
|
|
481
|
+
require_exact_keys(arguments, set(), "inspect_payment_challenge arguments")
|
|
482
|
+
challenge = build_payment_challenge(config)
|
|
483
|
+
structured = {
|
|
484
|
+
"status": HTTPStatus.PAYMENT_REQUIRED,
|
|
485
|
+
"challenge": challenge,
|
|
486
|
+
"mcpManifest": build_mcp_manifest(config),
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
"content": [{"type": "text", "text": "Local 402 challenge for Arc Testnet USDC demo payment."}],
|
|
490
|
+
"structuredContent": structured,
|
|
491
|
+
}
|
|
492
|
+
if name == "get_paid_resource":
|
|
493
|
+
require_exact_keys(arguments, {"xPayment"}, "get_paid_resource arguments")
|
|
494
|
+
x_payment = arguments.get("xPayment")
|
|
495
|
+
if not isinstance(x_payment, str):
|
|
496
|
+
raise ValueError("get_paid_resource xPayment must be a string")
|
|
497
|
+
response = handle_protected_request({"X-Payment": x_payment}, config)
|
|
498
|
+
return {
|
|
499
|
+
"content": [
|
|
500
|
+
{
|
|
501
|
+
"type": "text",
|
|
502
|
+
"text": f"Local paid resource response status: {int(response.status)}. No funds moved.",
|
|
503
|
+
}
|
|
504
|
+
],
|
|
505
|
+
"structuredContent": {"status": int(response.status), "body": response.body},
|
|
506
|
+
}
|
|
507
|
+
raise ValueError(f"unknown tool: {name}")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def process_jsonrpc_request(message: Mapping[str, object], config: PaymentConfig | None = None) -> dict[str, object] | None:
|
|
511
|
+
"""Process one newline-delimited JSON-RPC/MCP-style request."""
|
|
512
|
+
|
|
513
|
+
config = config or PaymentConfig.demo()
|
|
514
|
+
has_id = "id" in message
|
|
515
|
+
request_id = message.get("id") if has_id else None
|
|
516
|
+
if message.get("jsonrpc") != "2.0":
|
|
517
|
+
return make_jsonrpc_error(request_id, -32600, "request jsonrpc must be exactly 2.0")
|
|
518
|
+
if has_id and (
|
|
519
|
+
isinstance(request_id, bool)
|
|
520
|
+
or not isinstance(request_id, (str, int, type(None)))
|
|
521
|
+
):
|
|
522
|
+
return make_jsonrpc_error(None, -32600, "request id must be a string, integer, or null")
|
|
523
|
+
method = message.get("method")
|
|
524
|
+
if not isinstance(method, str):
|
|
525
|
+
return make_jsonrpc_error(request_id, -32600, "request method must be a string")
|
|
526
|
+
allowed_request_keys = {"jsonrpc", "method"} | ({"id"} if has_id else set())
|
|
527
|
+
if "params" in message:
|
|
528
|
+
allowed_request_keys.add("params")
|
|
529
|
+
try:
|
|
530
|
+
require_exact_keys(message, allowed_request_keys, "JSON-RPC request")
|
|
531
|
+
except ValueError as error:
|
|
532
|
+
return make_jsonrpc_error(request_id, -32600, str(error))
|
|
533
|
+
if not has_id:
|
|
534
|
+
return None
|
|
535
|
+
manifest = build_mcp_manifest(config)
|
|
536
|
+
|
|
537
|
+
if method == "initialize":
|
|
538
|
+
return make_jsonrpc_result(
|
|
539
|
+
request_id,
|
|
540
|
+
{
|
|
541
|
+
"protocolVersion": "2024-11-05",
|
|
542
|
+
"serverInfo": {"name": manifest["name"], "version": manifest["version"]},
|
|
543
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
544
|
+
"safety": manifest["safety"],
|
|
545
|
+
},
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if method == "tools/list":
|
|
549
|
+
return make_jsonrpc_result(request_id, {"tools": manifest["tools"], "safety": manifest["safety"]})
|
|
550
|
+
|
|
551
|
+
if method == "tools/call":
|
|
552
|
+
params = message.get("params")
|
|
553
|
+
if not isinstance(params, Mapping):
|
|
554
|
+
return make_jsonrpc_error(request_id, -32602, "tools/call params must be an object")
|
|
555
|
+
try:
|
|
556
|
+
require_exact_keys(params, {"name", "arguments"}, "tools/call params")
|
|
557
|
+
except ValueError as error:
|
|
558
|
+
return make_jsonrpc_error(request_id, -32602, str(error))
|
|
559
|
+
name = params.get("name")
|
|
560
|
+
arguments = params.get("arguments")
|
|
561
|
+
if not isinstance(name, str):
|
|
562
|
+
return make_jsonrpc_error(request_id, -32602, "tools/call requires a string tool name")
|
|
563
|
+
if not isinstance(arguments, Mapping):
|
|
564
|
+
return make_jsonrpc_error(request_id, -32602, "tools/call arguments must be an object")
|
|
565
|
+
try:
|
|
566
|
+
return make_jsonrpc_result(request_id, call_manifest_tool(name, arguments, config))
|
|
567
|
+
except ValueError as error:
|
|
568
|
+
return make_jsonrpc_error(request_id, -32602, str(error))
|
|
569
|
+
|
|
570
|
+
return make_jsonrpc_error(request_id, -32601, f"unknown method: {method}")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def run_mcp_stdio(config: PaymentConfig | None = None) -> None:
|
|
574
|
+
"""Run a tiny newline-delimited JSON-RPC loop over stdin/stdout."""
|
|
575
|
+
|
|
576
|
+
config = config or PaymentConfig.demo()
|
|
577
|
+
for payload in bounded_mcp_lines():
|
|
578
|
+
if payload is None:
|
|
579
|
+
response = make_jsonrpc_error(None, -32600, "request exceeds the 1 MB safety limit")
|
|
580
|
+
print(json.dumps(response, sort_keys=True), flush=True)
|
|
581
|
+
continue
|
|
582
|
+
if not payload.strip():
|
|
583
|
+
continue
|
|
584
|
+
try:
|
|
585
|
+
line = payload.decode("utf-8")
|
|
586
|
+
message = json.loads(line, object_pairs_hook=reject_duplicate_json_keys)
|
|
587
|
+
if not isinstance(message, Mapping):
|
|
588
|
+
response = make_jsonrpc_error(None, -32600, "request must be a JSON object")
|
|
589
|
+
else:
|
|
590
|
+
response = process_jsonrpc_request(message, config)
|
|
591
|
+
except UnicodeDecodeError:
|
|
592
|
+
response = make_jsonrpc_error(None, -32700, "parse error: request must be UTF-8")
|
|
593
|
+
except json.JSONDecodeError as error:
|
|
594
|
+
response = make_jsonrpc_error(None, -32700, f"parse error: {error.msg}")
|
|
595
|
+
except ValueError as error:
|
|
596
|
+
response = make_jsonrpc_error(None, -32700, f"parse error: {error}")
|
|
597
|
+
if response is None:
|
|
598
|
+
continue
|
|
599
|
+
print(json.dumps(response, sort_keys=True), flush=True)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class DemoHandler(BaseHTTPRequestHandler):
|
|
603
|
+
config = PaymentConfig.demo()
|
|
604
|
+
|
|
605
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
|
|
606
|
+
path = urlsplit(self.path).path
|
|
607
|
+
if path in ("/", "/health"):
|
|
608
|
+
self._send(Response(status=HTTPStatus.OK, body={"ok": True, "service": "x402-local-challenge-server"}))
|
|
609
|
+
return
|
|
610
|
+
if path == "/protected":
|
|
611
|
+
self._send(handle_protected_request(self.headers, self.config))
|
|
612
|
+
return
|
|
613
|
+
self._send(Response(status=HTTPStatus.NOT_FOUND, body={"error": "not_found"}))
|
|
614
|
+
|
|
615
|
+
def _send(self, response: Response) -> None:
|
|
616
|
+
body = json.dumps(response.body, indent=2, sort_keys=True).encode("utf-8")
|
|
617
|
+
self.send_response(int(response.status))
|
|
618
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
619
|
+
self.send_header("Cache-Control", "no-store")
|
|
620
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
621
|
+
self.send_header("Content-Length", str(len(body)))
|
|
622
|
+
self.end_headers()
|
|
623
|
+
self.wfile.write(body)
|
|
624
|
+
|
|
625
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def print_json(payload: object) -> None:
|
|
630
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def build_cli_challenge_payload(config: PaymentConfig | None = None) -> dict[str, object]:
|
|
634
|
+
config = config or PaymentConfig.demo()
|
|
635
|
+
challenge = build_payment_challenge(config)
|
|
636
|
+
return {
|
|
637
|
+
"status": int(HTTPStatus.PAYMENT_REQUIRED),
|
|
638
|
+
"challenge": challenge,
|
|
639
|
+
"localDemoProof": f"local-demo:{challenge['id']}:{config.amount}",
|
|
640
|
+
"mcpManifest": build_mcp_manifest(config),
|
|
641
|
+
"safety": {
|
|
642
|
+
"localDemoProofOnly": True,
|
|
643
|
+
"transactionBroadcast": False,
|
|
644
|
+
"privateKeysAccepted": False,
|
|
645
|
+
"mainnetEnabled": config.mainnet_enabled,
|
|
646
|
+
},
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def build_cli_verification_payload(proof: str, config: PaymentConfig | None = None) -> dict[str, object]:
|
|
651
|
+
config = config or PaymentConfig.demo()
|
|
652
|
+
response = handle_protected_request({"X-Payment": proof}, config)
|
|
653
|
+
return {"status": int(response.status), "body": response.body}
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def main() -> None:
|
|
657
|
+
parser = argparse.ArgumentParser(description="Run the local-only x402 challenge server demo.")
|
|
658
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
659
|
+
parser.add_argument("--port", type=int, default=8087)
|
|
660
|
+
parser.add_argument(
|
|
661
|
+
"--mcp-stdio",
|
|
662
|
+
action="store_true",
|
|
663
|
+
help="Run a newline-delimited JSON-RPC/MCP-style stdio tool server instead of HTTP.",
|
|
664
|
+
)
|
|
665
|
+
parser.add_argument(
|
|
666
|
+
"--print-manifest",
|
|
667
|
+
action="store_true",
|
|
668
|
+
help="Print the safe MCP-style manifest JSON and exit.",
|
|
669
|
+
)
|
|
670
|
+
parser.add_argument(
|
|
671
|
+
"--print-challenge",
|
|
672
|
+
action="store_true",
|
|
673
|
+
help="Print the local 402 challenge, demo proof hint, and manifest JSON and exit.",
|
|
674
|
+
)
|
|
675
|
+
parser.add_argument(
|
|
676
|
+
"--verify-payment",
|
|
677
|
+
metavar="X_PAYMENT",
|
|
678
|
+
help="Verify one local-demo X-Payment value and print the simulated response JSON.",
|
|
679
|
+
)
|
|
680
|
+
args = parser.parse_args()
|
|
681
|
+
try:
|
|
682
|
+
config = PaymentConfig.from_env()
|
|
683
|
+
except ValueError as error:
|
|
684
|
+
raise SystemExit(f"Invalid x402 demo configuration: {error}") from error
|
|
685
|
+
|
|
686
|
+
if args.print_manifest:
|
|
687
|
+
print_json(build_mcp_manifest(config))
|
|
688
|
+
return
|
|
689
|
+
if args.print_challenge:
|
|
690
|
+
print_json(build_cli_challenge_payload(config))
|
|
691
|
+
return
|
|
692
|
+
if args.verify_payment is not None:
|
|
693
|
+
print_json(build_cli_verification_payload(args.verify_payment, config))
|
|
694
|
+
return
|
|
695
|
+
if args.mcp_stdio:
|
|
696
|
+
run_mcp_stdio(config)
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
validate_bind_target(args.host, args.port)
|
|
701
|
+
except ValueError as error:
|
|
702
|
+
raise SystemExit(f"Invalid local server bind target: {error}") from error
|
|
703
|
+
DemoHandler.config = config
|
|
704
|
+
server = ThreadingHTTPServer((args.host, args.port), DemoHandler)
|
|
705
|
+
print(f"local x402 challenge server listening on http://{args.host}:{args.port}")
|
|
706
|
+
print("GET /protected returns a 402 challenge. No funds move in this demo.")
|
|
707
|
+
server.serve_forever()
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
if __name__ == "__main__":
|
|
711
|
+
main()
|