q-runtime-core 0.9.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.
- q_runtime_core-0.9.0.dist-info/METADATA +299 -0
- q_runtime_core-0.9.0.dist-info/RECORD +36 -0
- q_runtime_core-0.9.0.dist-info/WHEEL +5 -0
- q_runtime_core-0.9.0.dist-info/entry_points.txt +2 -0
- q_runtime_core-0.9.0.dist-info/licenses/LICENSE +3 -0
- q_runtime_core-0.9.0.dist-info/top_level.txt +1 -0
- qruntime/__init__.py +76 -0
- qruntime/budget.py +295 -0
- qruntime/cli.py +594 -0
- qruntime/core/__init__.py +0 -0
- qruntime/core/invariants.py +67 -0
- qruntime/core/model.py +140 -0
- qruntime/core/transition.py +92 -0
- qruntime/exceptions.py +132 -0
- qruntime/integrations/__init__.py +0 -0
- qruntime/integrations/official_x402_demo.py +94 -0
- qruntime/loader.py +39 -0
- qruntime/policies/__init__.py +0 -0
- qruntime/policies/authority.py +1 -0
- qruntime/policies/budget.py +1 -0
- qruntime/policies/evidence.py +1 -0
- qruntime/policy.py +248 -0
- qruntime/rails/__init__.py +0 -0
- qruntime/rails/simulated.py +4 -0
- qruntime/rails/x402.py +284 -0
- qruntime/rails/x402_capture.py +194 -0
- qruntime/rails/x402_external.py +575 -0
- qruntime/rails/x402_external_suite.py +65 -0
- qruntime/rails/x402_fullcycle.py +4651 -0
- qruntime/rails/x402_verify.py +202 -0
- qruntime/results.py +112 -0
- qruntime/runtime.py +243 -0
- qruntime/spend.py +584 -0
- qruntime/verify/__init__.py +0 -0
- qruntime/verify/explorer.py +59 -0
- qruntime/verify/report.py +38 -0
qruntime/cli.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from qruntime.loader import load_workflow
|
|
8
|
+
from qruntime.rails.x402 import (
|
|
9
|
+
X402DecodeError,
|
|
10
|
+
as_jsonable,
|
|
11
|
+
normalize_payment_required,
|
|
12
|
+
parse_payment_payload,
|
|
13
|
+
parse_payment_required,
|
|
14
|
+
parse_settlement_response,
|
|
15
|
+
)
|
|
16
|
+
from qruntime.rails.x402_verify import trace_result_json, trace_result_text, verify_x402_trace
|
|
17
|
+
from qruntime.rails.x402_capture import (
|
|
18
|
+
X402CaptureError,
|
|
19
|
+
capture_to_file,
|
|
20
|
+
compare_capture_quotes,
|
|
21
|
+
)
|
|
22
|
+
from qruntime.rails.x402_external import (
|
|
23
|
+
ExternalProbeError,
|
|
24
|
+
audit_x402_list_directory,
|
|
25
|
+
probe_external,
|
|
26
|
+
write_external_probe,
|
|
27
|
+
)
|
|
28
|
+
from qruntime.rails.x402_external_suite import run_external_suite
|
|
29
|
+
from qruntime.verify.explorer import verify
|
|
30
|
+
from qruntime.verify.report import to_json, to_text
|
|
31
|
+
from qruntime.rails.x402_fullcycle import (
|
|
32
|
+
create_testnet_wallet,
|
|
33
|
+
get_persistent_payment_intent,
|
|
34
|
+
list_persistent_payment_intents,
|
|
35
|
+
reconcile_testnet_transaction,
|
|
36
|
+
fullcycle_result_dict,
|
|
37
|
+
fullcycle_result_text,
|
|
38
|
+
run_local_testnet_payment,
|
|
39
|
+
verify_fullcycle,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_x402_input(path: str):
|
|
44
|
+
raw = Path(path).read_text(encoding="utf-8").strip()
|
|
45
|
+
try:
|
|
46
|
+
value = json.loads(raw)
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
value = raw
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> int:
|
|
53
|
+
parser = argparse.ArgumentParser(prog="qruntime")
|
|
54
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
verify_p = sub.add_parser("verify", help="verify economic invariants for a QFlow YAML file")
|
|
57
|
+
verify_p.add_argument("path")
|
|
58
|
+
verify_p.add_argument("--json", action="store_true", dest="as_json")
|
|
59
|
+
|
|
60
|
+
x402_p = sub.add_parser("x402", help="decode and verify x402 v2 economic traces")
|
|
61
|
+
xsub = x402_p.add_subparsers(dest="x402_command", required=True)
|
|
62
|
+
|
|
63
|
+
inspect = xsub.add_parser("inspect", help="decode one x402 v2 wire object")
|
|
64
|
+
inspect.add_argument("type", choices=["required", "signature", "response"])
|
|
65
|
+
inspect.add_argument("path")
|
|
66
|
+
|
|
67
|
+
normalize = xsub.add_parser("normalize", help="map PAYMENT-REQUIRED to a Q economic event")
|
|
68
|
+
normalize.add_argument("path")
|
|
69
|
+
normalize.add_argument("--accept-index", type=int, default=0)
|
|
70
|
+
|
|
71
|
+
trace = xsub.add_parser("verify-trace", help="verify a recorded/synthetic x402 event trace")
|
|
72
|
+
trace.add_argument("path")
|
|
73
|
+
trace.add_argument("--json", action="store_true", dest="as_json")
|
|
74
|
+
|
|
75
|
+
capture = xsub.add_parser("capture-402", help="capture a live HTTP 402/PAYMENT-REQUIRED without paying")
|
|
76
|
+
capture.add_argument("url")
|
|
77
|
+
capture.add_argument("--out", required=True)
|
|
78
|
+
capture.add_argument("--timeout", type=float, default=10.0)
|
|
79
|
+
|
|
80
|
+
compare = xsub.add_parser("compare-quotes", help="compare two captured PAYMENT-REQUIRED quotes")
|
|
81
|
+
compare.add_argument("old_capture")
|
|
82
|
+
compare.add_argument("new_capture")
|
|
83
|
+
compare.add_argument("--accept-index", type=int, default=0)
|
|
84
|
+
compare.add_argument("--json", action="store_true", dest="as_json")
|
|
85
|
+
|
|
86
|
+
external = xsub.add_parser("external-audit", help="audit an independent live x402 endpoint without paying")
|
|
87
|
+
external.add_argument("url")
|
|
88
|
+
external.add_argument("--method", choices=["GET", "POST", "HEAD"], default="GET")
|
|
89
|
+
external.add_argument("--json-body", default=None, help="inline JSON body for POST probes")
|
|
90
|
+
external.add_argument("--out", default=None)
|
|
91
|
+
external.add_argument("--timeout", type=float, default=10.0)
|
|
92
|
+
external.add_argument("--mode", choices=["strict", "compatibility"], default="strict")
|
|
93
|
+
external.add_argument("--json", action="store_true", dest="as_json")
|
|
94
|
+
|
|
95
|
+
ext_suite = xsub.add_parser("external-suite", help="cross-implementation checks against independent grug402 endpoints")
|
|
96
|
+
ext_suite.add_argument("--timeout", type=float, default=10.0)
|
|
97
|
+
ext_suite.add_argument("--out", default=None)
|
|
98
|
+
ext_suite.add_argument("--mode", choices=["strict", "compatibility"], default="strict")
|
|
99
|
+
ext_suite.add_argument("--json", action="store_true", dest="as_json")
|
|
100
|
+
|
|
101
|
+
directory = xsub.add_parser("directory-audit", help="safely audit GET endpoints discovered through x402 List")
|
|
102
|
+
directory.add_argument("--limit", type=int, default=10)
|
|
103
|
+
directory.add_argument("--timeout", type=float, default=10.0)
|
|
104
|
+
directory.add_argument("--mode", choices=["strict", "compatibility"], default="compatibility")
|
|
105
|
+
directory.add_argument("--out", required=True)
|
|
106
|
+
|
|
107
|
+
serve = xsub.add_parser("serve-official-demo", help="run a Base-Sepolia demo using the official x402 Python SDK")
|
|
108
|
+
serve.add_argument("--pay-to", required=True, help="public EVM test-wallet address; private key is never required")
|
|
109
|
+
serve.add_argument("--price-usd", default="0.01")
|
|
110
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
111
|
+
serve.add_argument("--port", type=int, default=4021)
|
|
112
|
+
serve.add_argument("--facilitator-url", default="https://x402.org/facilitator")
|
|
113
|
+
|
|
114
|
+
fullcycle = xsub.add_parser("verify-fullcycle", help="verify x402 v2 authorization-flow invariants")
|
|
115
|
+
fullcycle.add_argument("path")
|
|
116
|
+
fullcycle.add_argument("--json", action="store_true", dest="as_json")
|
|
117
|
+
|
|
118
|
+
wallet = xsub.add_parser("create-testnet-wallet", help="create a disposable Base-Sepolia EVM wallet file")
|
|
119
|
+
wallet.add_argument("--out", required=True)
|
|
120
|
+
|
|
121
|
+
live = xsub.add_parser("live-testnet", help="run one capped full-cycle payment against the local Base-Sepolia demo")
|
|
122
|
+
live.add_argument("url", nargs="?", default="http://127.0.0.1:4021/weather")
|
|
123
|
+
live.add_argument("--max-amount-atomic", required=True, type=int)
|
|
124
|
+
live.add_argument(
|
|
125
|
+
"--intent-id",
|
|
126
|
+
default=None,
|
|
127
|
+
help="stable logical operation id; reuse only when retrying the same payment intent",
|
|
128
|
+
)
|
|
129
|
+
live.add_argument("--out", required=True)
|
|
130
|
+
live.add_argument("--key-env", default="Q_RUNTIME_TESTNET_PRIVATE_KEY")
|
|
131
|
+
live.add_argument("--timeout", type=float, default=30.0)
|
|
132
|
+
|
|
133
|
+
reconcile = xsub.add_parser(
|
|
134
|
+
"reconcile-testnet",
|
|
135
|
+
help="read-only reconcile an already-broadcast Base-Sepolia transaction",
|
|
136
|
+
)
|
|
137
|
+
reconcile.add_argument("transaction")
|
|
138
|
+
reconcile.add_argument("--out", required=True)
|
|
139
|
+
reconcile.add_argument("--timeout", type=float, default=15.0)
|
|
140
|
+
reconcile.add_argument("--json", action="store_true", dest="as_json")
|
|
141
|
+
|
|
142
|
+
intents = xsub.add_parser(
|
|
143
|
+
"intents",
|
|
144
|
+
help="list persisted x402 payment intents",
|
|
145
|
+
)
|
|
146
|
+
intents.add_argument(
|
|
147
|
+
"--json",
|
|
148
|
+
action="store_true",
|
|
149
|
+
dest="as_json",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
intent_view = intents.add_mutually_exclusive_group()
|
|
153
|
+
|
|
154
|
+
intent_view.add_argument(
|
|
155
|
+
"--state",
|
|
156
|
+
choices=[
|
|
157
|
+
"pending",
|
|
158
|
+
"confirmed",
|
|
159
|
+
"failed",
|
|
160
|
+
],
|
|
161
|
+
default=None,
|
|
162
|
+
help="filter intents by economic state",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
intent_view.add_argument(
|
|
166
|
+
"--summary",
|
|
167
|
+
action="store_true",
|
|
168
|
+
help="show aggregate intent-state counts",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
intent = xsub.add_parser(
|
|
172
|
+
"intent",
|
|
173
|
+
help="inspect one persisted x402 payment intent",
|
|
174
|
+
)
|
|
175
|
+
intent.add_argument("intent_id")
|
|
176
|
+
intent.add_argument(
|
|
177
|
+
"--json",
|
|
178
|
+
action="store_true",
|
|
179
|
+
dest="as_json",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
args = parser.parse_args(argv)
|
|
183
|
+
|
|
184
|
+
if args.command == "verify":
|
|
185
|
+
spec = load_workflow(args.path)
|
|
186
|
+
result = verify(spec)
|
|
187
|
+
print(to_json(result) if args.as_json else to_text(result))
|
|
188
|
+
return 0 if result.passed else 2
|
|
189
|
+
|
|
190
|
+
if args.command == "x402":
|
|
191
|
+
if args.x402_command == "inspect":
|
|
192
|
+
value = _read_x402_input(args.path)
|
|
193
|
+
if args.type == "required":
|
|
194
|
+
parsed = parse_payment_required(value)
|
|
195
|
+
elif args.type == "signature":
|
|
196
|
+
parsed = parse_payment_payload(value)
|
|
197
|
+
else:
|
|
198
|
+
parsed = parse_settlement_response(value)
|
|
199
|
+
print(json.dumps(as_jsonable(parsed), indent=2, ensure_ascii=False))
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
if args.x402_command == "normalize":
|
|
203
|
+
value = _read_x402_input(args.path)
|
|
204
|
+
event = normalize_payment_required(value, args.accept_index)
|
|
205
|
+
print(json.dumps(as_jsonable(event), indent=2, ensure_ascii=False))
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
if args.x402_command == "verify-trace":
|
|
209
|
+
result = verify_x402_trace(args.path)
|
|
210
|
+
print(trace_result_json(result) if args.as_json else trace_result_text(result))
|
|
211
|
+
return 0 if result.passed else 2
|
|
212
|
+
|
|
213
|
+
if args.x402_command == "capture-402":
|
|
214
|
+
try:
|
|
215
|
+
capture_result = capture_to_file(args.url, args.out, timeout=args.timeout)
|
|
216
|
+
except X402CaptureError as exc:
|
|
217
|
+
print(f"CAPTURE FAIL — {exc}")
|
|
218
|
+
return 2
|
|
219
|
+
req = capture_result.payment_required.selected(0)
|
|
220
|
+
print("CAPTURED — LIVE X402 PAYMENT-REQUIRED")
|
|
221
|
+
print(f"HTTP status: {capture_result.status_code}")
|
|
222
|
+
print(f"resource: {capture_result.payment_required.resource_url}")
|
|
223
|
+
print(f"scheme/network: {req.scheme} / {req.network}")
|
|
224
|
+
print(f"amount atomic: {req.amount_atomic}")
|
|
225
|
+
print(f"quote fingerprint: {req.fingerprint()}")
|
|
226
|
+
print(f"trace: {args.out}")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
if args.x402_command == "compare-quotes":
|
|
230
|
+
try:
|
|
231
|
+
result = compare_capture_quotes(
|
|
232
|
+
args.old_capture, args.new_capture, accept_index=args.accept_index
|
|
233
|
+
)
|
|
234
|
+
except (X402CaptureError, ValueError, json.JSONDecodeError) as exc:
|
|
235
|
+
print(f"COMPARE FAIL — {exc}")
|
|
236
|
+
return 2
|
|
237
|
+
if args.as_json:
|
|
238
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
239
|
+
elif result["stable"]:
|
|
240
|
+
print("PASS — PaymentRequirementStableForIntent")
|
|
241
|
+
print("live quote fingerprint unchanged")
|
|
242
|
+
else:
|
|
243
|
+
print("FAIL — LIVE_X402_QUOTE_DRIFT")
|
|
244
|
+
print(f"invariant: {result['invariant']}")
|
|
245
|
+
if not result["same_resource"]:
|
|
246
|
+
print(f"resource: {result['old_resource']} -> {result['new_resource']}")
|
|
247
|
+
for field, change in result["changes"].items():
|
|
248
|
+
print(f"{field}: {change['before']} -> {change['after']}")
|
|
249
|
+
return 0 if result["stable"] else 2
|
|
250
|
+
|
|
251
|
+
if args.x402_command == "external-audit":
|
|
252
|
+
try:
|
|
253
|
+
body = None if args.json_body is None else json.loads(args.json_body)
|
|
254
|
+
result = probe_external(
|
|
255
|
+
args.url,
|
|
256
|
+
method=args.method,
|
|
257
|
+
json_body=body,
|
|
258
|
+
timeout=args.timeout,
|
|
259
|
+
require_public_https=True,
|
|
260
|
+
mode=args.mode,
|
|
261
|
+
)
|
|
262
|
+
except (ExternalProbeError, json.JSONDecodeError) as exc:
|
|
263
|
+
print(f"EXTERNAL AUDIT FAIL — {exc}")
|
|
264
|
+
return 2
|
|
265
|
+
if args.out:
|
|
266
|
+
write_external_probe(result, args.out)
|
|
267
|
+
data = result.as_dict()
|
|
268
|
+
if args.as_json:
|
|
269
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
270
|
+
else:
|
|
271
|
+
if result.classification == "legacy_x402_v1":
|
|
272
|
+
print("FAIL — LEGACY_X402_V1" if args.mode == "strict" else "LEGACY — X402_V1_DETECTED")
|
|
273
|
+
else:
|
|
274
|
+
print("PASS — EXTERNAL X402 AUDIT" if result.passed else "FAIL — EXTERNAL X402 AUDIT")
|
|
275
|
+
print(f"mode: {args.mode}")
|
|
276
|
+
print(f"HTTP: {result.status_code} {result.method} {result.final_url}")
|
|
277
|
+
print(f"challenge source: {result.challenge_source or 'none'}")
|
|
278
|
+
if result.payment_required is not None:
|
|
279
|
+
print(f"resource: {result.payment_required.resource_url}")
|
|
280
|
+
print(f"accepts: {len(result.payment_required.accepts)}")
|
|
281
|
+
first = result.payment_required.accepts[0]
|
|
282
|
+
print(f"first requirement: {first.scheme} / {first.network} / {first.amount_atomic}")
|
|
283
|
+
print(f"fingerprint: {first.fingerprint()}")
|
|
284
|
+
for finding in result.findings:
|
|
285
|
+
print(f"{finding.severity.upper()} {finding.invariant}: {finding.reason}")
|
|
286
|
+
if args.out:
|
|
287
|
+
print(f"report: {args.out}")
|
|
288
|
+
return 0 if result.passed else 2
|
|
289
|
+
|
|
290
|
+
if args.x402_command == "external-suite":
|
|
291
|
+
result = run_external_suite(timeout=args.timeout, mode=args.mode)
|
|
292
|
+
if args.out:
|
|
293
|
+
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
Path(args.out).write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
295
|
+
if args.as_json:
|
|
296
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
297
|
+
else:
|
|
298
|
+
print("PASS — EXTERNAL SUITE" if result["suite_passed"] else "FAIL — EXTERNAL SUITE")
|
|
299
|
+
for case in result["cases"]:
|
|
300
|
+
mark = "PASS" if case.get("matched_expectation") else "FAIL"
|
|
301
|
+
print(f"{mark} {case['name']}: {case['url']}")
|
|
302
|
+
audit = case.get("audit")
|
|
303
|
+
if audit:
|
|
304
|
+
for f in audit.get("findings", []):
|
|
305
|
+
if f.get("severity") == "error":
|
|
306
|
+
print(f" {f['invariant']}: {f['reason']}")
|
|
307
|
+
elif case.get("error"):
|
|
308
|
+
print(f" {case['error']}")
|
|
309
|
+
if args.out:
|
|
310
|
+
print(f"report: {args.out}")
|
|
311
|
+
return 0 if result["suite_passed"] else 2
|
|
312
|
+
|
|
313
|
+
if args.x402_command == "directory-audit":
|
|
314
|
+
try:
|
|
315
|
+
result = audit_x402_list_directory(limit=args.limit, timeout=args.timeout, mode=args.mode)
|
|
316
|
+
except ExternalProbeError as exc:
|
|
317
|
+
print(f"DIRECTORY AUDIT FAIL — {exc}")
|
|
318
|
+
return 2
|
|
319
|
+
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
Path(args.out).write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
321
|
+
print("DIRECTORY AUDIT COMPLETE")
|
|
322
|
+
print(f"services audited: {result['services_audited']}")
|
|
323
|
+
print(f"errors: {result['errors']}")
|
|
324
|
+
print(f"warnings: {result['warnings']}")
|
|
325
|
+
print(f"legacy x402 v1: {result.get('legacy_x402_v1', 0)}")
|
|
326
|
+
print(f"mode: {args.mode}")
|
|
327
|
+
print(f"report: {args.out}")
|
|
328
|
+
return 0 if result["errors"] == 0 and result["services_audited"] > 0 else 2
|
|
329
|
+
|
|
330
|
+
if args.x402_command == "verify-fullcycle":
|
|
331
|
+
result = verify_fullcycle(args.path)
|
|
332
|
+
print(json.dumps(fullcycle_result_dict(result), indent=2, ensure_ascii=False) if args.as_json else fullcycle_result_text(result))
|
|
333
|
+
return 0 if result.passed else 2
|
|
334
|
+
|
|
335
|
+
if args.x402_command == "create-testnet-wallet":
|
|
336
|
+
try:
|
|
337
|
+
result = create_testnet_wallet(args.out)
|
|
338
|
+
except (RuntimeError, FileExistsError, ValueError) as exc:
|
|
339
|
+
print(f"WALLET FAIL — {exc}")
|
|
340
|
+
return 2
|
|
341
|
+
print("CREATED — DISPOSABLE TESTNET WALLET")
|
|
342
|
+
print("network: eip155:84532 (Base Sepolia)")
|
|
343
|
+
print(f"address: {result['address']}")
|
|
344
|
+
print(f"secret file: {result['path']}")
|
|
345
|
+
print("WARNING: TESTNET ONLY. Never reuse this key/address for mainnet assets.")
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
if args.x402_command == "live-testnet":
|
|
349
|
+
try:
|
|
350
|
+
result = run_local_testnet_payment(
|
|
351
|
+
args.url,
|
|
352
|
+
max_amount_atomic=args.max_amount_atomic,
|
|
353
|
+
out=args.out,
|
|
354
|
+
intent_id=args.intent_id,
|
|
355
|
+
key_env=args.key_env,
|
|
356
|
+
timeout=args.timeout,
|
|
357
|
+
)
|
|
358
|
+
except (RuntimeError, ValueError, X402DecodeError) as exc:
|
|
359
|
+
print(f"LIVE TESTNET FAIL — {exc}")
|
|
360
|
+
return 2
|
|
361
|
+
print("PASS — LIVE X402 TESTNET SETTLEMENT")
|
|
362
|
+
print(f"payer: {result['payer']}")
|
|
363
|
+
print(f"resource: {result['request_url']}")
|
|
364
|
+
print(f"amount atomic: {result['preflight']['amount_atomic']}")
|
|
365
|
+
print(f"network: {result['settlement']['network']}")
|
|
366
|
+
print(f"transaction: {result['settlement']['transaction']}")
|
|
367
|
+
print(f"report: {args.out}")
|
|
368
|
+
return 0
|
|
369
|
+
|
|
370
|
+
if args.x402_command == "intents":
|
|
371
|
+
try:
|
|
372
|
+
entries = list_persistent_payment_intents()
|
|
373
|
+
except RuntimeError as exc:
|
|
374
|
+
print(f"INTENT STORE FAIL ? {exc}")
|
|
375
|
+
return 2
|
|
376
|
+
|
|
377
|
+
if args.summary:
|
|
378
|
+
states = [
|
|
379
|
+
str(entry.get("state", ""))
|
|
380
|
+
for entry in entries
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
summary = {
|
|
384
|
+
"total": len(entries),
|
|
385
|
+
"pending": states.count("pending"),
|
|
386
|
+
"confirmed": states.count(
|
|
387
|
+
"resolved_confirmed"
|
|
388
|
+
),
|
|
389
|
+
"failed": states.count(
|
|
390
|
+
"resolved_failed"
|
|
391
|
+
),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
summary["other"] = (
|
|
395
|
+
summary["total"]
|
|
396
|
+
- summary["pending"]
|
|
397
|
+
- summary["confirmed"]
|
|
398
|
+
- summary["failed"]
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if args.as_json:
|
|
402
|
+
print(
|
|
403
|
+
json.dumps(
|
|
404
|
+
summary,
|
|
405
|
+
indent=2,
|
|
406
|
+
ensure_ascii=False,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
print("X402 INTENT SUMMARY")
|
|
411
|
+
print(f"total: {summary['total']}")
|
|
412
|
+
print(
|
|
413
|
+
f"pending: "
|
|
414
|
+
f"{summary['pending']}"
|
|
415
|
+
)
|
|
416
|
+
print(
|
|
417
|
+
f"confirmed: "
|
|
418
|
+
f"{summary['confirmed']}"
|
|
419
|
+
)
|
|
420
|
+
print(
|
|
421
|
+
f"failed: "
|
|
422
|
+
f"{summary['failed']}"
|
|
423
|
+
)
|
|
424
|
+
print(
|
|
425
|
+
f"other: "
|
|
426
|
+
f"{summary['other']}"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return 0
|
|
430
|
+
|
|
431
|
+
if args.state:
|
|
432
|
+
state_map = {
|
|
433
|
+
"pending": "pending",
|
|
434
|
+
"confirmed": "resolved_confirmed",
|
|
435
|
+
"failed": "resolved_failed",
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
wanted = state_map[args.state]
|
|
439
|
+
|
|
440
|
+
entries = tuple(
|
|
441
|
+
entry
|
|
442
|
+
for entry in entries
|
|
443
|
+
if str(
|
|
444
|
+
entry.get("state", "")
|
|
445
|
+
) == wanted
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if args.as_json:
|
|
449
|
+
print(
|
|
450
|
+
json.dumps(
|
|
451
|
+
list(entries),
|
|
452
|
+
indent=2,
|
|
453
|
+
ensure_ascii=False,
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
if not entries:
|
|
459
|
+
print("NO PERSISTED X402 INTENTS")
|
|
460
|
+
return 0
|
|
461
|
+
|
|
462
|
+
print(f"PERSISTED X402 INTENTS: {len(entries)}")
|
|
463
|
+
|
|
464
|
+
for entry in entries:
|
|
465
|
+
print(
|
|
466
|
+
f"{entry.get('state', 'unknown')} "
|
|
467
|
+
f"{entry.get('intent_id', '')}"
|
|
468
|
+
)
|
|
469
|
+
print(
|
|
470
|
+
f" resource: "
|
|
471
|
+
f"{entry.get('resource', '')}"
|
|
472
|
+
)
|
|
473
|
+
print(
|
|
474
|
+
f" network: "
|
|
475
|
+
f"{entry.get('network', '')}"
|
|
476
|
+
)
|
|
477
|
+
print(
|
|
478
|
+
f" transaction: "
|
|
479
|
+
f"{entry.get('transaction', '')}"
|
|
480
|
+
)
|
|
481
|
+
print(
|
|
482
|
+
f" updated_at: "
|
|
483
|
+
f"{entry.get('updated_at', '')}"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
return 0
|
|
487
|
+
|
|
488
|
+
if args.x402_command == "intent":
|
|
489
|
+
try:
|
|
490
|
+
entry = get_persistent_payment_intent(
|
|
491
|
+
args.intent_id
|
|
492
|
+
)
|
|
493
|
+
except (RuntimeError, ValueError) as exc:
|
|
494
|
+
print(f"INTENT LOOKUP FAIL ? {exc}")
|
|
495
|
+
return 2
|
|
496
|
+
|
|
497
|
+
if entry is None:
|
|
498
|
+
print(
|
|
499
|
+
f"INTENT NOT FOUND: "
|
|
500
|
+
f"{args.intent_id}"
|
|
501
|
+
)
|
|
502
|
+
return 2
|
|
503
|
+
|
|
504
|
+
if args.as_json:
|
|
505
|
+
print(
|
|
506
|
+
json.dumps(
|
|
507
|
+
entry,
|
|
508
|
+
indent=2,
|
|
509
|
+
ensure_ascii=False,
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
return 0
|
|
513
|
+
|
|
514
|
+
print("X402 INTENT")
|
|
515
|
+
print(f"intent_id: {entry.get('intent_id', '')}")
|
|
516
|
+
print(f"state: {entry.get('state', '')}")
|
|
517
|
+
print(f"resource: {entry.get('resource', '')}")
|
|
518
|
+
print(f"network: {entry.get('network', '')}")
|
|
519
|
+
print(
|
|
520
|
+
f"transaction: "
|
|
521
|
+
f"{entry.get('transaction', '')}"
|
|
522
|
+
)
|
|
523
|
+
print(
|
|
524
|
+
f"reconciliation: "
|
|
525
|
+
f"{entry.get('reconciliation_outcome', '')}"
|
|
526
|
+
)
|
|
527
|
+
print(
|
|
528
|
+
f"first_seen_at: "
|
|
529
|
+
f"{entry.get('first_seen_at', '')}"
|
|
530
|
+
)
|
|
531
|
+
print(
|
|
532
|
+
f"updated_at: "
|
|
533
|
+
f"{entry.get('updated_at', '')}"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
return 0
|
|
537
|
+
|
|
538
|
+
if args.x402_command == "reconcile-testnet":
|
|
539
|
+
try:
|
|
540
|
+
result = reconcile_testnet_transaction(
|
|
541
|
+
args.transaction,
|
|
542
|
+
out=args.out,
|
|
543
|
+
timeout=args.timeout,
|
|
544
|
+
)
|
|
545
|
+
except (RuntimeError, ValueError) as exc:
|
|
546
|
+
print(f"RECONCILIATION FAIL ? {exc}")
|
|
547
|
+
return 2
|
|
548
|
+
|
|
549
|
+
if args.as_json:
|
|
550
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
551
|
+
else:
|
|
552
|
+
outcome = result["outcome"]
|
|
553
|
+
if outcome == "confirmed":
|
|
554
|
+
print("RECONCILIATION CONFIRMED")
|
|
555
|
+
elif outcome == "failed":
|
|
556
|
+
print("RECONCILIATION FAILED")
|
|
557
|
+
else:
|
|
558
|
+
print("RECONCILIATION PENDING")
|
|
559
|
+
|
|
560
|
+
print(f"network: {result['network']}")
|
|
561
|
+
print(f"transaction: {result['transaction']}")
|
|
562
|
+
print(f"outcome: {result['outcome']}")
|
|
563
|
+
print(f"terminal: {result['terminal']}")
|
|
564
|
+
print(f"status: {result['chain']['status']}")
|
|
565
|
+
print(f"block: {result['chain']['block_number']}")
|
|
566
|
+
print(f"confirmations: {result['chain']['confirmations']}")
|
|
567
|
+
print(f"method: {result['chain']['method']}")
|
|
568
|
+
print(f"report: {args.out}")
|
|
569
|
+
|
|
570
|
+
# confirmed/failed are terminal reconciliation outcomes.
|
|
571
|
+
# pending remains fail-closed for automation/retry decisions.
|
|
572
|
+
return 0 if result["terminal"] else 2
|
|
573
|
+
|
|
574
|
+
if args.x402_command == "serve-official-demo":
|
|
575
|
+
from qruntime.integrations.official_x402_demo import serve as serve_official_x402
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
serve_official_x402(
|
|
579
|
+
pay_to=args.pay_to,
|
|
580
|
+
price_usd=args.price_usd,
|
|
581
|
+
host=args.host,
|
|
582
|
+
port=args.port,
|
|
583
|
+
facilitator_url=args.facilitator_url,
|
|
584
|
+
)
|
|
585
|
+
except (RuntimeError, ValueError) as exc:
|
|
586
|
+
print(f"SERVER FAIL — {exc}")
|
|
587
|
+
return 2
|
|
588
|
+
return 0
|
|
589
|
+
|
|
590
|
+
return 1
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
if __name__ == "__main__":
|
|
594
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .model import RuntimeState, TaskStatus, WorkflowSpec, unresolved_required_cost
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Violation:
|
|
11
|
+
invariant: str
|
|
12
|
+
message: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_invariants(spec: WorkflowSpec, state: RuntimeState) -> list[Violation]:
|
|
16
|
+
violations: list[Violation] = []
|
|
17
|
+
|
|
18
|
+
if state.spent + state.reserved > spec.budget:
|
|
19
|
+
violations.append(Violation(
|
|
20
|
+
"NoBudgetOversubscription",
|
|
21
|
+
f"spent({state.spent}) + reserved({state.reserved}) > budget({spec.budget})",
|
|
22
|
+
))
|
|
23
|
+
|
|
24
|
+
duplicates = [intent for intent, n in Counter(state.settled_intents).items() if n > 1]
|
|
25
|
+
if duplicates:
|
|
26
|
+
violations.append(Violation(
|
|
27
|
+
"NoDuplicateEconomicEffect",
|
|
28
|
+
f"economic intent settled more than once: {', '.join(sorted(duplicates))}",
|
|
29
|
+
))
|
|
30
|
+
|
|
31
|
+
if state.ambiguous_intents:
|
|
32
|
+
violations.append(Violation(
|
|
33
|
+
"PaymentStateReconciledBeforeRetry",
|
|
34
|
+
"retry attempted while prior payment outcome remained ambiguous: "
|
|
35
|
+
+ ", ".join(sorted(state.ambiguous_intents)),
|
|
36
|
+
))
|
|
37
|
+
|
|
38
|
+
if state.evidence_bypasses:
|
|
39
|
+
violations.append(Violation(
|
|
40
|
+
"EvidenceBeforeDependentSpend",
|
|
41
|
+
"economic reservation occurred without required evidence: "
|
|
42
|
+
+ ", ".join(sorted(state.evidence_bypasses)),
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
if state.unauthorized_effects:
|
|
46
|
+
violations.append(Violation(
|
|
47
|
+
"AuthorizedTransitionsOnly",
|
|
48
|
+
"economic effect attempted by actor outside delegated authority: "
|
|
49
|
+
+ ", ".join(sorted(state.unauthorized_effects)),
|
|
50
|
+
))
|
|
51
|
+
|
|
52
|
+
required_remaining = unresolved_required_cost(spec, state)
|
|
53
|
+
if state.spent + state.reserved + required_remaining > spec.budget:
|
|
54
|
+
violations.append(Violation(
|
|
55
|
+
"CompletionReservePreserved",
|
|
56
|
+
"current commitments leave insufficient authorized budget for unresolved required steps",
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
if state.task_status == TaskStatus.COMPLETED:
|
|
60
|
+
missing = [s.id for s in spec.steps if s.required and state.status(s.id).value != "DONE"]
|
|
61
|
+
if missing:
|
|
62
|
+
violations.append(Violation(
|
|
63
|
+
"TerminalStateHonesty",
|
|
64
|
+
f"task completed with unresolved required steps: {', '.join(missing)}",
|
|
65
|
+
))
|
|
66
|
+
|
|
67
|
+
return violations
|