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.
Files changed (58) hide show
  1. arc_builder_kit/__init__.py +4 -0
  2. arc_builder_kit/__main__.py +6 -0
  3. arc_builder_kit/_paths.py +47 -0
  4. arc_builder_kit/cli.py +277 -0
  5. arc_builder_kit/config/arc_testnet.facts.json +31 -0
  6. arc_builder_kit/doctor.py +936 -0
  7. arc_builder_kit/examples/agent-commerce-components/components.js +200 -0
  8. arc_builder_kit/examples/agent-commerce-components/index.html +120 -0
  9. arc_builder_kit/examples/agent-commerce-flows/flows.js +271 -0
  10. arc_builder_kit/examples/agent-commerce-flows/index.html +114 -0
  11. arc_builder_kit/examples/agent-commerce-live/commerce-live.js +190 -0
  12. arc_builder_kit/examples/agent-commerce-live/index.html +105 -0
  13. arc_builder_kit/examples/agent-commerce-review-packet/index.html +96 -0
  14. arc_builder_kit/examples/agent-commerce-review-packet/packet.js +125 -0
  15. arc_builder_kit/examples/agent-identity-profile-preview/identity.js +126 -0
  16. arc_builder_kit/examples/agent-identity-profile-preview/index.html +104 -0
  17. arc_builder_kit/examples/arc-agent-treasury-lab/index.html +152 -0
  18. arc_builder_kit/examples/arc-agent-treasury-lab/treasury.js +532 -0
  19. arc_builder_kit/examples/arc-testnet-operator-evidence/evidence.example.json +47 -0
  20. arc_builder_kit/examples/arc-testnet-wallet-send-gate/index.html +233 -0
  21. arc_builder_kit/examples/arc-testnet-wallet-send-gate/live-infrastructure-policy.example.json +59 -0
  22. arc_builder_kit/examples/arc-testnet-wallet-send-gate/wallet-send-gate.js +472 -0
  23. arc_builder_kit/examples/circle-wallet-integration/index.html +155 -0
  24. arc_builder_kit/examples/circle-wallet-integration/wallet-lab.js +91 -0
  25. arc_builder_kit/examples/job-escrow-simulator/index.html +121 -0
  26. arc_builder_kit/examples/job-escrow-simulator/simulator.js +162 -0
  27. arc_builder_kit/examples/payment-intent-demo/index.html +132 -0
  28. arc_builder_kit/examples/payment-intent-playground/index.html +301 -0
  29. arc_builder_kit/examples/payment-intent-playground/playground.js +835 -0
  30. arc_builder_kit/examples/payment-intent-receipt-matcher/index.html +157 -0
  31. arc_builder_kit/examples/payment-intent-receipt-matcher/matcher.js +877 -0
  32. arc_builder_kit/examples/receipt-verifier-playground/index.html +120 -0
  33. arc_builder_kit/examples/receipt-verifier-playground/verifier.js +226 -0
  34. arc_builder_kit/examples/receipt-viewer/index.html +138 -0
  35. arc_builder_kit/examples/receipt-viewer/receipt-viewer.js +472 -0
  36. arc_builder_kit/examples/transaction-status-playground/index.html +135 -0
  37. arc_builder_kit/examples/transaction-status-playground/status.js +518 -0
  38. arc_builder_kit/examples/x402-local-challenge-server/.env.example +25 -0
  39. arc_builder_kit/examples/x402-local-challenge-server/README.md +111 -0
  40. arc_builder_kit/examples/x402-local-challenge-server/server.py +711 -0
  41. arc_builder_kit/mcp_server.py +463 -0
  42. arc_builder_kit/release_packet.py +469 -0
  43. arc_builder_kit/templates/README.md +25 -0
  44. arc_builder_kit/templates/job-escrow-starter/README.md +25 -0
  45. arc_builder_kit/templates/job-escrow-starter/index.html +41 -0
  46. arc_builder_kit/templates/job-escrow-starter/index.js +14 -0
  47. arc_builder_kit/templates/payment-intent-starter/README.md +25 -0
  48. arc_builder_kit/templates/payment-intent-starter/index.html +42 -0
  49. arc_builder_kit/templates/payment-intent-starter/index.js +7 -0
  50. arc_builder_kit/templates/x402-agent-starter/README.md +29 -0
  51. arc_builder_kit/templates/x402-agent-starter/server.py +201 -0
  52. arc_builder_kit/validate_repo.py +2212 -0
  53. arc_builder_kit-0.2.0.dist-info/METADATA +543 -0
  54. arc_builder_kit-0.2.0.dist-info/RECORD +58 -0
  55. arc_builder_kit-0.2.0.dist-info/WHEEL +5 -0
  56. arc_builder_kit-0.2.0.dist-info/entry_points.txt +3 -0
  57. arc_builder_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
  58. 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()