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
|
@@ -0,0 +1,4651 @@
|
|
|
1
|
+
"""Q Runtime v0.6 full-cycle verifier and Base-Sepolia test harness.
|
|
2
|
+
|
|
3
|
+
The deterministic verifier models the x402 v2 ``authorization`` payment flow:
|
|
4
|
+
PAYMENT-REQUIRED -> PAYMENT-SIGNATURE -> verify -> resource -> settle -> response.
|
|
5
|
+
|
|
6
|
+
The live harness is deliberately testnet-only and localhost-only. It uses the
|
|
7
|
+
installed official x402 Python SDK to exercise a locally controlled resource
|
|
8
|
+
server against the public Base-Sepolia facilitator. It never accepts a mainnet
|
|
9
|
+
network and never writes private keys to reports.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Mapping
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
from qruntime.core.invariants import Violation
|
|
22
|
+
from qruntime.rails.x402 import (
|
|
23
|
+
X402DecodeError,
|
|
24
|
+
parse_payment_payload,
|
|
25
|
+
parse_payment_required,
|
|
26
|
+
parse_settlement_response,
|
|
27
|
+
requirement_equal,
|
|
28
|
+
)
|
|
29
|
+
from qruntime.rails.x402_capture import X402CaptureError, capture_payment_required
|
|
30
|
+
from qruntime.exceptions import (
|
|
31
|
+
DuplicateIntentError,
|
|
32
|
+
IntentInFlightError,
|
|
33
|
+
IntentResourceMismatchError,
|
|
34
|
+
SettlementPendingError,
|
|
35
|
+
PaidResourceError,
|
|
36
|
+
PaymentRequirementMismatchError,
|
|
37
|
+
PaymentRequirementRejectedError,
|
|
38
|
+
PolicyDeniedError,
|
|
39
|
+
SettlementFailedError,
|
|
40
|
+
SettlementReceiptError,
|
|
41
|
+
ReconciliationError,
|
|
42
|
+
SettlementStoreError,
|
|
43
|
+
)
|
|
44
|
+
from qruntime.policy import Policy, evaluate_policy
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
BASE_SEPOLIA = "eip155:84532"
|
|
48
|
+
DEFAULT_KEY_ENV = "Q_RUNTIME_TESTNET_PRIVATE_KEY"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class FullCycleResult:
|
|
53
|
+
passed: bool
|
|
54
|
+
processed_events: int
|
|
55
|
+
violation: Violation | None = None
|
|
56
|
+
trace: tuple[str, ...] = ()
|
|
57
|
+
pending_transactions: tuple[str, ...] = ()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _wire(event: Mapping[str, Any]) -> Any:
|
|
61
|
+
if "header" in event:
|
|
62
|
+
return event["header"]
|
|
63
|
+
if "data" in event:
|
|
64
|
+
return event["data"]
|
|
65
|
+
raise X402DecodeError("full-cycle event requires data or header")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def verify_fullcycle_obj(raw: Mapping[str, Any]) -> FullCycleResult:
|
|
69
|
+
events = raw.get("events")
|
|
70
|
+
if not isinstance(events, list):
|
|
71
|
+
return FullCycleResult(False, 0, Violation("InvalidTrace", "events must be an array"))
|
|
72
|
+
|
|
73
|
+
flow = str(raw.get("payment_flow", "authorization"))
|
|
74
|
+
if flow != "authorization":
|
|
75
|
+
return FullCycleResult(
|
|
76
|
+
False,
|
|
77
|
+
0,
|
|
78
|
+
Violation("UnsupportedPaymentFlow", "v0.6 gate models authorization flow only"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
states: dict[str, dict[str, Any]] = {}
|
|
82
|
+
trace: list[str] = []
|
|
83
|
+
pending: dict[str, str] = {}
|
|
84
|
+
|
|
85
|
+
def state(url: str) -> dict[str, Any]:
|
|
86
|
+
return states.setdefault(
|
|
87
|
+
url,
|
|
88
|
+
{
|
|
89
|
+
"required": None,
|
|
90
|
+
"accepted": None,
|
|
91
|
+
"authorized": False,
|
|
92
|
+
"verified": False,
|
|
93
|
+
"resource_executed": False,
|
|
94
|
+
"resource_failed": False,
|
|
95
|
+
"settled": False,
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def fail(i: int, invariant: str, message: str) -> FullCycleResult:
|
|
100
|
+
return FullCycleResult(
|
|
101
|
+
False,
|
|
102
|
+
i,
|
|
103
|
+
Violation(invariant, message),
|
|
104
|
+
tuple(trace),
|
|
105
|
+
tuple(sorted(pending.values())),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
for i, event in enumerate(events, start=1):
|
|
109
|
+
if not isinstance(event, Mapping):
|
|
110
|
+
trace.append(f"{i}:invalid")
|
|
111
|
+
return fail(i, "InvalidTrace", "each event must be an object")
|
|
112
|
+
kind = str(event.get("kind", ""))
|
|
113
|
+
url = str(event.get("request_url", ""))
|
|
114
|
+
trace.append(f"{i}:{kind}" + (f" {url}" if url else ""))
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
if kind == "payment_required":
|
|
118
|
+
required = parse_payment_required(_wire(event))
|
|
119
|
+
target = url or required.resource_url
|
|
120
|
+
if not target:
|
|
121
|
+
return fail(i, "InvalidTrace", "payment_required requires request_url or resource.url")
|
|
122
|
+
st = state(target)
|
|
123
|
+
st["required"] = required
|
|
124
|
+
st["authorized"] = False
|
|
125
|
+
st["verified"] = False
|
|
126
|
+
st["resource_executed"] = False
|
|
127
|
+
st["resource_failed"] = False
|
|
128
|
+
st["settled"] = False
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if not url:
|
|
132
|
+
return fail(i, "InvalidTrace", f"{kind} requires request_url")
|
|
133
|
+
st = state(url)
|
|
134
|
+
|
|
135
|
+
if kind == "payment_signature":
|
|
136
|
+
if url in pending:
|
|
137
|
+
return fail(
|
|
138
|
+
i,
|
|
139
|
+
"PaymentStateReconciledBeforeRetry",
|
|
140
|
+
f"new authorization for {url} while prior settlement is unresolved",
|
|
141
|
+
)
|
|
142
|
+
if st["settled"]:
|
|
143
|
+
return fail(i, "NoDuplicateEconomicEffect", f"new payment attempted after {url} already settled")
|
|
144
|
+
offered = st["required"]
|
|
145
|
+
if offered is None:
|
|
146
|
+
return fail(
|
|
147
|
+
i,
|
|
148
|
+
"PaymentRequirementKnownBeforeAuthorization",
|
|
149
|
+
f"no current PAYMENT-REQUIRED observed for {url}",
|
|
150
|
+
)
|
|
151
|
+
payload = parse_payment_payload(_wire(event))
|
|
152
|
+
if payload.resource_url and payload.resource_url != url:
|
|
153
|
+
return fail(
|
|
154
|
+
i,
|
|
155
|
+
"PaymentPayloadBoundToResource",
|
|
156
|
+
f"payload resource {payload.resource_url} != actual request {url}",
|
|
157
|
+
)
|
|
158
|
+
if not any(requirement_equal(payload.accepted, req) for req in offered.accepts):
|
|
159
|
+
return fail(
|
|
160
|
+
i,
|
|
161
|
+
"PaymentRequirementStableForIntent",
|
|
162
|
+
"signed payment does not match any currently advertised payment requirement",
|
|
163
|
+
)
|
|
164
|
+
st["accepted"] = payload.accepted
|
|
165
|
+
st["authorized"] = True
|
|
166
|
+
st["verified"] = False
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if kind == "verify_response":
|
|
170
|
+
if not st["authorized"]:
|
|
171
|
+
return fail(i, "AuthorizationBeforeVerification", "facilitator verify occurred before authorization")
|
|
172
|
+
data = _wire(event)
|
|
173
|
+
if not isinstance(data, Mapping) or not isinstance(data.get("isValid"), bool):
|
|
174
|
+
return fail(i, "InvalidVerifyResponse", "verify_response requires boolean isValid")
|
|
175
|
+
st["verified"] = bool(data["isValid"])
|
|
176
|
+
if not st["verified"]:
|
|
177
|
+
st["authorized"] = False
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if kind == "resource_executed":
|
|
181
|
+
if not st["verified"]:
|
|
182
|
+
return fail(
|
|
183
|
+
i,
|
|
184
|
+
"VerificationBeforeResourceExecution",
|
|
185
|
+
f"protected resource {url} executed before successful verify",
|
|
186
|
+
)
|
|
187
|
+
if st["resource_executed"]:
|
|
188
|
+
return fail(i, "SingleResourceExecution", f"protected resource {url} executed more than once")
|
|
189
|
+
st["resource_executed"] = True
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if kind == "resource_failed":
|
|
193
|
+
if not st["verified"]:
|
|
194
|
+
return fail(
|
|
195
|
+
i,
|
|
196
|
+
"VerificationBeforeResourceExecution",
|
|
197
|
+
f"protected resource {url} failed before successful verify",
|
|
198
|
+
)
|
|
199
|
+
st["resource_failed"] = True
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
if kind in {"settle_response", "payment_response"}:
|
|
203
|
+
if st["resource_failed"]:
|
|
204
|
+
return fail(
|
|
205
|
+
i,
|
|
206
|
+
"NoSettlementAfterResourceFailure",
|
|
207
|
+
f"authorization flow attempted settlement after protected resource {url} failed",
|
|
208
|
+
)
|
|
209
|
+
if not st["resource_executed"]:
|
|
210
|
+
return fail(
|
|
211
|
+
i,
|
|
212
|
+
"ResourceExecutionBeforeSettlement",
|
|
213
|
+
f"authorization flow settled {url} before protected resource execution",
|
|
214
|
+
)
|
|
215
|
+
response = parse_settlement_response(_wire(event))
|
|
216
|
+
accepted = st.get("accepted")
|
|
217
|
+
if accepted is not None and response.network != accepted.network:
|
|
218
|
+
return fail(
|
|
219
|
+
i,
|
|
220
|
+
"SettlementReceiptConsistent",
|
|
221
|
+
f"settlement network {response.network} != authorized network {accepted.network}",
|
|
222
|
+
)
|
|
223
|
+
if response.reconciliation_required:
|
|
224
|
+
pending[url] = response.transaction
|
|
225
|
+
st["settled"] = False
|
|
226
|
+
elif response.success:
|
|
227
|
+
if not response.transaction:
|
|
228
|
+
return fail(
|
|
229
|
+
i,
|
|
230
|
+
"SettlementReceiptConsistent",
|
|
231
|
+
"successful settlement requires a non-empty transaction hash",
|
|
232
|
+
)
|
|
233
|
+
pending.pop(url, None)
|
|
234
|
+
st["settled"] = True
|
|
235
|
+
else:
|
|
236
|
+
pending.pop(url, None)
|
|
237
|
+
st["settled"] = False
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if kind == "transport_timeout":
|
|
241
|
+
phase = str(event.get("phase", ""))
|
|
242
|
+
if phase in {"after_payment_submission", "during_settlement"}:
|
|
243
|
+
pending[url] = str(event.get("transaction", "unknown"))
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
if kind == "reconcile":
|
|
247
|
+
outcome = str(event.get("outcome", ""))
|
|
248
|
+
if outcome not in {"confirmed", "failed", "not_found_terminal"}:
|
|
249
|
+
return fail(i, "InvalidTrace", "invalid reconcile outcome")
|
|
250
|
+
pending.pop(url, None)
|
|
251
|
+
st["settled"] = outcome == "confirmed"
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
if kind == "retry":
|
|
255
|
+
if url in pending:
|
|
256
|
+
return fail(
|
|
257
|
+
i,
|
|
258
|
+
"PaymentStateReconciledBeforeRetry",
|
|
259
|
+
f"retry attempted for {url} before ambiguous settlement was reconciled",
|
|
260
|
+
)
|
|
261
|
+
if st["settled"]:
|
|
262
|
+
return fail(i, "NoDuplicateEconomicEffect", f"retry attempted after {url} already settled")
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
return fail(i, "InvalidTrace", f"unsupported full-cycle event kind: {kind}")
|
|
266
|
+
|
|
267
|
+
except X402DecodeError as exc:
|
|
268
|
+
return fail(i, "InvalidX402WireData", str(exc))
|
|
269
|
+
|
|
270
|
+
return FullCycleResult(True, len(events), None, tuple(trace), tuple(sorted(pending.values())))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def verify_fullcycle(path: str | Path) -> FullCycleResult:
|
|
274
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
275
|
+
if not isinstance(raw, Mapping):
|
|
276
|
+
return FullCycleResult(False, 0, Violation("InvalidTrace", "trace root must be an object"))
|
|
277
|
+
return verify_fullcycle_obj(raw)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def fullcycle_result_dict(result: FullCycleResult) -> dict[str, Any]:
|
|
281
|
+
return {
|
|
282
|
+
"passed": result.passed,
|
|
283
|
+
"processed_events": result.processed_events,
|
|
284
|
+
"violation": None if result.violation is None else {
|
|
285
|
+
"invariant": result.violation.invariant,
|
|
286
|
+
"message": result.violation.message,
|
|
287
|
+
},
|
|
288
|
+
"trace": list(result.trace),
|
|
289
|
+
"pending_transactions": list(result.pending_transactions),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def fullcycle_result_text(result: FullCycleResult) -> str:
|
|
294
|
+
if result.passed:
|
|
295
|
+
pending_text = ", ".join(result.pending_transactions) if result.pending_transactions else "none"
|
|
296
|
+
return (
|
|
297
|
+
"PASS — X402 FULL-CYCLE INVARIANTS\n"
|
|
298
|
+
f"events processed: {result.processed_events}\n"
|
|
299
|
+
f"pending reconciliation: {pending_text}\n"
|
|
300
|
+
"economic invariant violations: 0"
|
|
301
|
+
)
|
|
302
|
+
return (
|
|
303
|
+
"FAIL — X402_FULLCYCLE_COUNTEREXAMPLE\n"
|
|
304
|
+
f"invariant: {result.violation.invariant if result.violation else 'unknown'}\n"
|
|
305
|
+
f"reason: {result.violation.message if result.violation else 'unknown'}\n"
|
|
306
|
+
"trace:\n " + "\n ".join(result.trace)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def create_testnet_wallet(path: str | Path) -> dict[str, str]:
|
|
311
|
+
"""Create a disposable EVM wallet file for testnet use only."""
|
|
312
|
+
try:
|
|
313
|
+
from eth_account import Account
|
|
314
|
+
except ImportError as exc: # pragma: no cover - user's optional integration env
|
|
315
|
+
raise RuntimeError(
|
|
316
|
+
"eth-account is missing. Install Q Runtime with: "
|
|
317
|
+
'python -m pip install -e ".[official-x402]"'
|
|
318
|
+
) from exc
|
|
319
|
+
|
|
320
|
+
destination = Path(path)
|
|
321
|
+
if destination.exists():
|
|
322
|
+
raise FileExistsError(f"refusing to overwrite existing wallet file: {destination}")
|
|
323
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
account = Account.create()
|
|
325
|
+
private_key = account.key.hex()
|
|
326
|
+
if not private_key.startswith("0x"):
|
|
327
|
+
private_key = "0x" + private_key
|
|
328
|
+
data = {
|
|
329
|
+
"purpose": "Q Runtime Base Sepolia disposable testnet wallet",
|
|
330
|
+
"network": BASE_SEPOLIA,
|
|
331
|
+
"address": account.address,
|
|
332
|
+
"private_key": private_key,
|
|
333
|
+
"warning": "TESTNET ONLY. Never fund this address with mainnet assets or reuse this key on mainnet.",
|
|
334
|
+
}
|
|
335
|
+
destination.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
336
|
+
return {"address": account.address, "path": str(destination)}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _header_ci(headers: Mapping[str, str], *names: str) -> str | None:
|
|
340
|
+
lowered = {str(k).lower(): str(v) for k, v in headers.items()}
|
|
341
|
+
for name in names:
|
|
342
|
+
if name.lower() in lowered:
|
|
343
|
+
return lowered[name.lower()]
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _live_requirement_matches_preflight(preflight: Any, current: Any) -> bool:
|
|
349
|
+
"""Require the SDK-selected requirement to equal the preflight-approved quote."""
|
|
350
|
+
try:
|
|
351
|
+
current_canonical = {
|
|
352
|
+
"scheme": current.scheme,
|
|
353
|
+
"network": current.network,
|
|
354
|
+
"amount": str(current.amount),
|
|
355
|
+
"asset": current.asset,
|
|
356
|
+
"payTo": current.pay_to,
|
|
357
|
+
"maxTimeoutSeconds": current.max_timeout_seconds,
|
|
358
|
+
"extra": dict(current.extra or {}),
|
|
359
|
+
}
|
|
360
|
+
return preflight.canonical_dict() == current_canonical
|
|
361
|
+
except (AttributeError, TypeError, ValueError):
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
def _make_live_quote_binding_hook(preflight: Any, abort_result_cls: Any):
|
|
365
|
+
"""Abort before signing if the live SDK quote differs from preflight."""
|
|
366
|
+
fingerprint = preflight.fingerprint()
|
|
367
|
+
|
|
368
|
+
def before_payment(ctx: Any):
|
|
369
|
+
current = ctx.selected_requirements
|
|
370
|
+
if not _live_requirement_matches_preflight(preflight, current):
|
|
371
|
+
return abort_result_cls(
|
|
372
|
+
reason="qruntime_preflight_quote_changed",
|
|
373
|
+
message=(
|
|
374
|
+
"payment requirement selected for signing no longer "
|
|
375
|
+
f"matches preflight-approved quote {fingerprint}"
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
return before_payment
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
PENDING_STORE_ENV = "Q_RUNTIME_PENDING_STORE"
|
|
385
|
+
PENDING_STORE_FORMAT = "qruntime-x402-pending-store-v1"
|
|
386
|
+
PENDING_STORE_ENTRY_STATES = frozenset(
|
|
387
|
+
{
|
|
388
|
+
"reserved",
|
|
389
|
+
"submission_unknown",
|
|
390
|
+
"pending",
|
|
391
|
+
"resolved_confirmed",
|
|
392
|
+
"resolved_failed",
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _pending_store_file() -> Path:
|
|
398
|
+
configured = os.environ.get(PENDING_STORE_ENV, "").strip()
|
|
399
|
+
if configured:
|
|
400
|
+
return Path(configured)
|
|
401
|
+
return Path.home() / ".qruntime" / "pending-settlements.json"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _pending_store_temp_file(
|
|
405
|
+
path: Path,
|
|
406
|
+
) -> Path:
|
|
407
|
+
return path.with_name(
|
|
408
|
+
path.name + ".tmp"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _pending_store_is_unambiguously_absent(
|
|
413
|
+
path: Path,
|
|
414
|
+
) -> bool:
|
|
415
|
+
"""Return True only when neither main nor temp state exists."""
|
|
416
|
+
|
|
417
|
+
temp = _pending_store_temp_file(
|
|
418
|
+
path
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if temp.exists():
|
|
422
|
+
raise SettlementStoreError(
|
|
423
|
+
"persistent settlement store has orphaned temp state; "
|
|
424
|
+
"refusing payment fail-closed"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return not path.exists()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _read_pending_store_document(
|
|
433
|
+
path: Path,
|
|
434
|
+
) -> dict[str, Any]:
|
|
435
|
+
"""Read and structurally validate one persisted pending store."""
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
raw = json.loads(
|
|
439
|
+
path.read_text(
|
|
440
|
+
encoding="utf-8"
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
except (
|
|
444
|
+
OSError,
|
|
445
|
+
UnicodeDecodeError,
|
|
446
|
+
json.JSONDecodeError,
|
|
447
|
+
) as exc:
|
|
448
|
+
raise SettlementStoreError(
|
|
449
|
+
"persistent settlement store is unreadable; "
|
|
450
|
+
"refusing payment fail-closed"
|
|
451
|
+
) from exc
|
|
452
|
+
|
|
453
|
+
if not isinstance(
|
|
454
|
+
raw,
|
|
455
|
+
Mapping,
|
|
456
|
+
):
|
|
457
|
+
raise SettlementStoreError(
|
|
458
|
+
"persistent settlement store root must be an object"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
entries = raw.get(
|
|
462
|
+
"entries"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
if not isinstance(
|
|
466
|
+
entries,
|
|
467
|
+
Mapping,
|
|
468
|
+
):
|
|
469
|
+
raise SettlementStoreError(
|
|
470
|
+
"persistent settlement store entries must be an object"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
for outer_key, entry in entries.items():
|
|
474
|
+
if not isinstance(
|
|
475
|
+
entry,
|
|
476
|
+
Mapping,
|
|
477
|
+
):
|
|
478
|
+
raise SettlementStoreError(
|
|
479
|
+
"persistent settlement store entry must be an object"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
state = entry.get(
|
|
483
|
+
"state"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if (
|
|
487
|
+
not isinstance(
|
|
488
|
+
state,
|
|
489
|
+
str,
|
|
490
|
+
)
|
|
491
|
+
or state
|
|
492
|
+
not in PENDING_STORE_ENTRY_STATES
|
|
493
|
+
):
|
|
494
|
+
raise SettlementStoreError(
|
|
495
|
+
"persistent settlement store entry state is unsupported"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
stored_intent_key = entry.get(
|
|
499
|
+
"intent_key"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Legacy entries may omit intent_key.
|
|
503
|
+
# Once present, it is an integrity assertion.
|
|
504
|
+
if stored_intent_key is not None:
|
|
505
|
+
if (
|
|
506
|
+
not isinstance(
|
|
507
|
+
stored_intent_key,
|
|
508
|
+
str,
|
|
509
|
+
)
|
|
510
|
+
or stored_intent_key
|
|
511
|
+
!= outer_key
|
|
512
|
+
):
|
|
513
|
+
raise SettlementStoreError(
|
|
514
|
+
"persistent settlement store entry key is inconsistent"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
stored_intent_id = entry.get(
|
|
518
|
+
"intent_id"
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if stored_intent_id is not None:
|
|
522
|
+
if (
|
|
523
|
+
not isinstance(
|
|
524
|
+
stored_intent_id,
|
|
525
|
+
str,
|
|
526
|
+
)
|
|
527
|
+
or _persistent_intent_key(
|
|
528
|
+
stored_intent_id
|
|
529
|
+
)
|
|
530
|
+
!= stored_intent_key
|
|
531
|
+
):
|
|
532
|
+
raise SettlementStoreError(
|
|
533
|
+
"persistent settlement store entry intent identity is inconsistent"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# A persisted pending settlement is already economically
|
|
537
|
+
# significant. Its transaction identity must therefore remain
|
|
538
|
+
# recoverable; otherwise retry safety cannot be established.
|
|
539
|
+
for raw_entry in entries.values():
|
|
540
|
+
if not isinstance(
|
|
541
|
+
raw_entry,
|
|
542
|
+
Mapping,
|
|
543
|
+
):
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
if raw_entry.get(
|
|
547
|
+
"state"
|
|
548
|
+
) != "pending":
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
for field in (
|
|
552
|
+
"transaction",
|
|
553
|
+
"network",
|
|
554
|
+
):
|
|
555
|
+
value = raw_entry.get(
|
|
556
|
+
field
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
if (
|
|
560
|
+
not isinstance(
|
|
561
|
+
value,
|
|
562
|
+
str,
|
|
563
|
+
)
|
|
564
|
+
or not value.strip()
|
|
565
|
+
):
|
|
566
|
+
raise SettlementStoreError(
|
|
567
|
+
"persistent pending settlement entry "
|
|
568
|
+
f"requires non-empty {field}; "
|
|
569
|
+
"refusing payment fail-closed"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# Terminal reconciliation states must retain the
|
|
573
|
+
# economic identity that was reconciled, and the persisted
|
|
574
|
+
# outcome must agree with the terminal state.
|
|
575
|
+
for raw_entry in entries.values():
|
|
576
|
+
if not isinstance(
|
|
577
|
+
raw_entry,
|
|
578
|
+
Mapping,
|
|
579
|
+
):
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
state = raw_entry.get(
|
|
583
|
+
"state"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
expected_outcome = {
|
|
587
|
+
"resolved_confirmed": "confirmed",
|
|
588
|
+
"resolved_failed": "failed",
|
|
589
|
+
}.get(
|
|
590
|
+
state
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if expected_outcome is None:
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
for field in (
|
|
597
|
+
"transaction",
|
|
598
|
+
"network",
|
|
599
|
+
):
|
|
600
|
+
value = raw_entry.get(
|
|
601
|
+
field
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
if (
|
|
605
|
+
not isinstance(
|
|
606
|
+
value,
|
|
607
|
+
str,
|
|
608
|
+
)
|
|
609
|
+
or not value.strip()
|
|
610
|
+
):
|
|
611
|
+
raise SettlementStoreError(
|
|
612
|
+
"persistent resolved settlement entry "
|
|
613
|
+
f"requires non-empty {field}; "
|
|
614
|
+
"refusing payment fail-closed"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
outcome = raw_entry.get(
|
|
618
|
+
"reconciliation_outcome"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# Older v1 entries may not carry reconciliation_outcome.
|
|
622
|
+
# Absence remains readable for compatibility. Once present,
|
|
623
|
+
# however, the outcome is an integrity assertion and must
|
|
624
|
+
# agree with the terminal state.
|
|
625
|
+
if (
|
|
626
|
+
outcome is not None
|
|
627
|
+
and outcome != expected_outcome
|
|
628
|
+
):
|
|
629
|
+
raise SettlementStoreError(
|
|
630
|
+
"persistent resolved settlement entry "
|
|
631
|
+
"has reconciliation outcome inconsistent "
|
|
632
|
+
f"with state {state}; "
|
|
633
|
+
"refusing payment fail-closed"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# P4.7c persisted authorization nonce integrity
|
|
637
|
+
# The write path already treats a nonce as unique within one
|
|
638
|
+
# persisted intent history. Exact replay is idempotent; conflicting
|
|
639
|
+
# durable envelopes for the same nonce are corruption.
|
|
640
|
+
for raw_entry in entries.values():
|
|
641
|
+
if not isinstance(
|
|
642
|
+
raw_entry,
|
|
643
|
+
Mapping,
|
|
644
|
+
):
|
|
645
|
+
continue
|
|
646
|
+
|
|
647
|
+
if raw_entry.get(
|
|
648
|
+
"state"
|
|
649
|
+
) != "submission_unknown":
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
history_raw = raw_entry.get(
|
|
653
|
+
"authorizations"
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# P4.7d authorization history structural integrity
|
|
657
|
+
# Missing/null history is legitimate while submission_unknown
|
|
658
|
+
# exists before authorization evidence reaches durable storage.
|
|
659
|
+
if history_raw is None:
|
|
660
|
+
continue
|
|
661
|
+
|
|
662
|
+
# Once materially present, authorizations must retain
|
|
663
|
+
# the list shape established by the write path.
|
|
664
|
+
if not isinstance(
|
|
665
|
+
history_raw,
|
|
666
|
+
list,
|
|
667
|
+
):
|
|
668
|
+
raise SettlementStoreError(
|
|
669
|
+
"persistent authorization history "
|
|
670
|
+
"must be a list; refusing payment "
|
|
671
|
+
"fail-closed"
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
seen_by_nonce: dict[
|
|
675
|
+
str,
|
|
676
|
+
dict[str, str],
|
|
677
|
+
] = {}
|
|
678
|
+
|
|
679
|
+
for authorization in history_raw:
|
|
680
|
+
if not isinstance(
|
|
681
|
+
authorization,
|
|
682
|
+
Mapping,
|
|
683
|
+
):
|
|
684
|
+
raise SettlementStoreError(
|
|
685
|
+
"persistent authorization history "
|
|
686
|
+
"contains invalid entry; refusing "
|
|
687
|
+
"payment fail-closed"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# P4.7f authorization envelope completeness
|
|
691
|
+
# Modern persisted authorization history must retain every
|
|
692
|
+
# field produced by the durable writer. This mirrors the
|
|
693
|
+
# write-time contract without imposing additional typing.
|
|
694
|
+
required_fields = (
|
|
695
|
+
"from",
|
|
696
|
+
"to",
|
|
697
|
+
"value",
|
|
698
|
+
"validAfter",
|
|
699
|
+
"validBefore",
|
|
700
|
+
"nonce",
|
|
701
|
+
"network",
|
|
702
|
+
"asset",
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
for field in required_fields:
|
|
706
|
+
if authorization.get(
|
|
707
|
+
field
|
|
708
|
+
) is None:
|
|
709
|
+
raise SettlementStoreError(
|
|
710
|
+
"persistent authorization envelope "
|
|
711
|
+
f"is missing required field {field}; "
|
|
712
|
+
"refusing payment fail-closed"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# P4.7g authorization economic binding
|
|
716
|
+
# When multiple persisted representations explicitly carry
|
|
717
|
+
# the same economic dimensions, they must agree. Missing
|
|
718
|
+
# legacy representations remain outside this contract.
|
|
719
|
+
requirement_raw = raw_entry.get(
|
|
720
|
+
"requirement"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
if isinstance(
|
|
724
|
+
requirement_raw,
|
|
725
|
+
Mapping,
|
|
726
|
+
):
|
|
727
|
+
entry_network = raw_entry.get(
|
|
728
|
+
"network"
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
requirement_network = (
|
|
732
|
+
requirement_raw.get(
|
|
733
|
+
"network"
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
if (
|
|
738
|
+
entry_network is not None
|
|
739
|
+
and requirement_network is not None
|
|
740
|
+
and str(entry_network)
|
|
741
|
+
!= str(requirement_network)
|
|
742
|
+
):
|
|
743
|
+
raise SettlementStoreError(
|
|
744
|
+
"persistent submission_unknown network "
|
|
745
|
+
"is inconsistent with payment requirement; "
|
|
746
|
+
"refusing payment fail-closed"
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
authorization_network = (
|
|
750
|
+
authorization.get(
|
|
751
|
+
"network"
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
if (
|
|
756
|
+
entry_network is not None
|
|
757
|
+
and authorization_network is not None
|
|
758
|
+
and str(authorization_network)
|
|
759
|
+
!= str(entry_network)
|
|
760
|
+
):
|
|
761
|
+
raise SettlementStoreError(
|
|
762
|
+
"persistent authorization network "
|
|
763
|
+
"is inconsistent with intent network; "
|
|
764
|
+
"refusing payment fail-closed"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
requirement_asset = (
|
|
768
|
+
requirement_raw.get(
|
|
769
|
+
"asset"
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
authorization_asset = (
|
|
774
|
+
authorization.get(
|
|
775
|
+
"asset"
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
if (
|
|
780
|
+
requirement_asset is not None
|
|
781
|
+
and authorization_asset is not None
|
|
782
|
+
and str(authorization_asset)
|
|
783
|
+
!= str(requirement_asset)
|
|
784
|
+
):
|
|
785
|
+
raise SettlementStoreError(
|
|
786
|
+
"persistent authorization asset "
|
|
787
|
+
"is inconsistent with payment requirement; "
|
|
788
|
+
"refusing payment fail-closed"
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# P4.7h authorization transfer binding
|
|
793
|
+
# For the exact EVM authorization flow, the durable
|
|
794
|
+
# authorization must describe the same amount and recipient
|
|
795
|
+
# as the selected payment requirement.
|
|
796
|
+
if isinstance(
|
|
797
|
+
requirement_raw,
|
|
798
|
+
Mapping,
|
|
799
|
+
):
|
|
800
|
+
scheme = requirement_raw.get(
|
|
801
|
+
"scheme"
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
if str(
|
|
805
|
+
scheme
|
|
806
|
+
) == "exact":
|
|
807
|
+
requirement_amount = (
|
|
808
|
+
requirement_raw.get(
|
|
809
|
+
"amount"
|
|
810
|
+
)
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
authorization_value = (
|
|
814
|
+
authorization.get(
|
|
815
|
+
"value"
|
|
816
|
+
)
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
if (
|
|
820
|
+
requirement_amount is not None
|
|
821
|
+
and authorization_value is not None
|
|
822
|
+
and str(
|
|
823
|
+
authorization_value
|
|
824
|
+
)
|
|
825
|
+
!= str(
|
|
826
|
+
requirement_amount
|
|
827
|
+
)
|
|
828
|
+
):
|
|
829
|
+
raise SettlementStoreError(
|
|
830
|
+
"persistent authorization value "
|
|
831
|
+
"is inconsistent with exact "
|
|
832
|
+
"payment requirement amount; "
|
|
833
|
+
"refusing payment fail-closed"
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
requirement_pay_to = (
|
|
837
|
+
requirement_raw.get(
|
|
838
|
+
"payTo"
|
|
839
|
+
)
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
authorization_to = (
|
|
843
|
+
authorization.get(
|
|
844
|
+
"to"
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
if (
|
|
849
|
+
requirement_pay_to is not None
|
|
850
|
+
and authorization_to is not None
|
|
851
|
+
and str(
|
|
852
|
+
authorization_to
|
|
853
|
+
).lower()
|
|
854
|
+
!= str(
|
|
855
|
+
requirement_pay_to
|
|
856
|
+
).lower()
|
|
857
|
+
):
|
|
858
|
+
raise SettlementStoreError(
|
|
859
|
+
"persistent authorization recipient "
|
|
860
|
+
"is inconsistent with exact "
|
|
861
|
+
"payment requirement payTo; "
|
|
862
|
+
"refusing payment fail-closed"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
nonce_raw = authorization.get(
|
|
867
|
+
"nonce"
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
if nonce_raw is None:
|
|
871
|
+
continue
|
|
872
|
+
|
|
873
|
+
nonce = str(
|
|
874
|
+
nonce_raw
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
normalized = {
|
|
878
|
+
str(key): str(value)
|
|
879
|
+
for key, value
|
|
880
|
+
in authorization.items()
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
previous = seen_by_nonce.get(
|
|
884
|
+
nonce
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
if (
|
|
888
|
+
previous is not None
|
|
889
|
+
and previous != normalized
|
|
890
|
+
):
|
|
891
|
+
raise SettlementStoreError(
|
|
892
|
+
"persistent authorization history "
|
|
893
|
+
"contains conflicting envelopes for "
|
|
894
|
+
"the same nonce; refusing payment "
|
|
895
|
+
"fail-closed"
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
seen_by_nonce[
|
|
899
|
+
nonce
|
|
900
|
+
] = normalized
|
|
901
|
+
|
|
902
|
+
# P4.7e authorization alias coherence
|
|
903
|
+
# The singular authorization field is a backwards-compatible
|
|
904
|
+
# alias for the most recently persisted authorization envelope.
|
|
905
|
+
# Its absence remains valid. Once explicitly present alongside
|
|
906
|
+
# non-empty history, however, both representations must agree.
|
|
907
|
+
for raw_entry in entries.values():
|
|
908
|
+
if not isinstance(
|
|
909
|
+
raw_entry,
|
|
910
|
+
Mapping,
|
|
911
|
+
):
|
|
912
|
+
continue
|
|
913
|
+
|
|
914
|
+
if raw_entry.get(
|
|
915
|
+
"state"
|
|
916
|
+
) != "submission_unknown":
|
|
917
|
+
continue
|
|
918
|
+
|
|
919
|
+
history_raw = raw_entry.get(
|
|
920
|
+
"authorizations"
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
if (
|
|
924
|
+
not isinstance(
|
|
925
|
+
history_raw,
|
|
926
|
+
list,
|
|
927
|
+
)
|
|
928
|
+
or not history_raw
|
|
929
|
+
):
|
|
930
|
+
continue
|
|
931
|
+
|
|
932
|
+
if "authorization" not in raw_entry:
|
|
933
|
+
continue
|
|
934
|
+
|
|
935
|
+
alias = raw_entry.get(
|
|
936
|
+
"authorization"
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
if not isinstance(
|
|
940
|
+
alias,
|
|
941
|
+
Mapping,
|
|
942
|
+
):
|
|
943
|
+
raise SettlementStoreError(
|
|
944
|
+
"persistent authorization alias "
|
|
945
|
+
"must be an object when history exists; "
|
|
946
|
+
"refusing payment fail-closed"
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
latest = history_raw[-1]
|
|
950
|
+
|
|
951
|
+
if not isinstance(
|
|
952
|
+
latest,
|
|
953
|
+
Mapping,
|
|
954
|
+
):
|
|
955
|
+
raise SettlementStoreError(
|
|
956
|
+
"persistent authorization history "
|
|
957
|
+
"contains invalid latest entry; "
|
|
958
|
+
"refusing payment fail-closed"
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
normalized_alias = {
|
|
962
|
+
str(key): str(value)
|
|
963
|
+
for key, value
|
|
964
|
+
in alias.items()
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
normalized_latest = {
|
|
968
|
+
str(key): str(value)
|
|
969
|
+
for key, value
|
|
970
|
+
in latest.items()
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (
|
|
974
|
+
normalized_alias
|
|
975
|
+
!= normalized_latest
|
|
976
|
+
):
|
|
977
|
+
raise SettlementStoreError(
|
|
978
|
+
"persistent authorization alias "
|
|
979
|
+
"is inconsistent with latest history; "
|
|
980
|
+
"refusing payment fail-closed"
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
# P4.7i reservation scope binding
|
|
984
|
+
# A flat durable budget reservation preserves the scope checked
|
|
985
|
+
# before economic handoff. When duplicated at entry/requirement
|
|
986
|
+
# level, those persisted dimensions must remain coherent.
|
|
987
|
+
for raw_entry in entries.values():
|
|
988
|
+
if not isinstance(
|
|
989
|
+
raw_entry,
|
|
990
|
+
Mapping,
|
|
991
|
+
):
|
|
992
|
+
continue
|
|
993
|
+
|
|
994
|
+
reservation_raw = raw_entry.get(
|
|
995
|
+
"reservation"
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
if not isinstance(
|
|
999
|
+
reservation_raw,
|
|
1000
|
+
Mapping,
|
|
1001
|
+
):
|
|
1002
|
+
continue
|
|
1003
|
+
|
|
1004
|
+
# BudgetSet stores constraints[] instead of one flat scope.
|
|
1005
|
+
# Its contract is deliberately outside P4.7i.
|
|
1006
|
+
if "constraints" in reservation_raw:
|
|
1007
|
+
continue
|
|
1008
|
+
|
|
1009
|
+
requirement_raw = raw_entry.get(
|
|
1010
|
+
"requirement"
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
if isinstance(
|
|
1014
|
+
requirement_raw,
|
|
1015
|
+
Mapping,
|
|
1016
|
+
):
|
|
1017
|
+
reservation_asset = (
|
|
1018
|
+
reservation_raw.get(
|
|
1019
|
+
"asset"
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
requirement_asset = (
|
|
1024
|
+
requirement_raw.get(
|
|
1025
|
+
"asset"
|
|
1026
|
+
)
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
if (
|
|
1030
|
+
reservation_asset is not None
|
|
1031
|
+
and requirement_asset is not None
|
|
1032
|
+
and str(
|
|
1033
|
+
reservation_asset
|
|
1034
|
+
)
|
|
1035
|
+
!= str(
|
|
1036
|
+
requirement_asset
|
|
1037
|
+
)
|
|
1038
|
+
):
|
|
1039
|
+
raise SettlementStoreError(
|
|
1040
|
+
"persistent reservation asset "
|
|
1041
|
+
"is inconsistent with payment requirement; "
|
|
1042
|
+
"refusing payment fail-closed"
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
for field in (
|
|
1046
|
+
"agent_id",
|
|
1047
|
+
"session_id",
|
|
1048
|
+
):
|
|
1049
|
+
reservation_scope = (
|
|
1050
|
+
reservation_raw.get(
|
|
1051
|
+
field
|
|
1052
|
+
)
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
entry_scope = raw_entry.get(
|
|
1056
|
+
field
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
if (
|
|
1060
|
+
reservation_scope is not None
|
|
1061
|
+
and entry_scope is not None
|
|
1062
|
+
and str(
|
|
1063
|
+
reservation_scope
|
|
1064
|
+
)
|
|
1065
|
+
!= str(
|
|
1066
|
+
entry_scope
|
|
1067
|
+
)
|
|
1068
|
+
):
|
|
1069
|
+
raise SettlementStoreError(
|
|
1070
|
+
"persistent reservation "
|
|
1071
|
+
f"{field} is inconsistent with "
|
|
1072
|
+
"payment entry scope; "
|
|
1073
|
+
"refusing payment fail-closed"
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
reservation_origin = (
|
|
1077
|
+
reservation_raw.get(
|
|
1078
|
+
"origin"
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
resource = raw_entry.get(
|
|
1083
|
+
"resource"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
if (
|
|
1087
|
+
reservation_origin is not None
|
|
1088
|
+
and resource is not None
|
|
1089
|
+
):
|
|
1090
|
+
from qruntime.policy import (
|
|
1091
|
+
_normalize_origin,
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
try:
|
|
1095
|
+
resource_origin = (
|
|
1096
|
+
_normalize_origin(
|
|
1097
|
+
str(
|
|
1098
|
+
resource
|
|
1099
|
+
)
|
|
1100
|
+
)
|
|
1101
|
+
)
|
|
1102
|
+
except ValueError as exc:
|
|
1103
|
+
raise SettlementStoreError(
|
|
1104
|
+
"persistent reservation resource "
|
|
1105
|
+
"has invalid origin; refusing "
|
|
1106
|
+
"payment fail-closed"
|
|
1107
|
+
) from exc
|
|
1108
|
+
|
|
1109
|
+
if (
|
|
1110
|
+
str(
|
|
1111
|
+
reservation_origin
|
|
1112
|
+
)
|
|
1113
|
+
!= resource_origin
|
|
1114
|
+
):
|
|
1115
|
+
raise SettlementStoreError(
|
|
1116
|
+
"persistent reservation origin "
|
|
1117
|
+
"is inconsistent with payment resource; "
|
|
1118
|
+
"refusing payment fail-closed"
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
return dict(raw)
|
|
1122
|
+
|
|
1123
|
+
def _classify_pending_store_format(
|
|
1124
|
+
raw: Mapping[str, Any],
|
|
1125
|
+
) -> str:
|
|
1126
|
+
"""Classify a validated pending-store document."""
|
|
1127
|
+
|
|
1128
|
+
if "format" not in raw:
|
|
1129
|
+
return "legacy"
|
|
1130
|
+
|
|
1131
|
+
if raw["format"] != PENDING_STORE_FORMAT:
|
|
1132
|
+
raise SettlementStoreError(
|
|
1133
|
+
"persistent settlement store format is unsupported; "
|
|
1134
|
+
"refusing payment fail-closed"
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
return "current"
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _load_pending_store() -> dict[str, Any]:
|
|
1141
|
+
path = _pending_store_file()
|
|
1142
|
+
|
|
1143
|
+
if _pending_store_is_unambiguously_absent(
|
|
1144
|
+
path
|
|
1145
|
+
):
|
|
1146
|
+
return {
|
|
1147
|
+
"format": PENDING_STORE_FORMAT,
|
|
1148
|
+
"entries": {},
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
raw = _read_pending_store_document(
|
|
1152
|
+
path
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
_classify_pending_store_format(
|
|
1156
|
+
raw
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
return {
|
|
1160
|
+
"format": PENDING_STORE_FORMAT,
|
|
1161
|
+
"entries": dict(
|
|
1162
|
+
raw["entries"]
|
|
1163
|
+
),
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _write_pending_store(store: Mapping[str, Any]) -> None:
|
|
1172
|
+
path = _pending_store_file()
|
|
1173
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1174
|
+
|
|
1175
|
+
temp = _pending_store_temp_file(
|
|
1176
|
+
path
|
|
1177
|
+
)
|
|
1178
|
+
payload = json.dumps(
|
|
1179
|
+
store,
|
|
1180
|
+
indent=2,
|
|
1181
|
+
ensure_ascii=False,
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
with temp.open(
|
|
1185
|
+
"w",
|
|
1186
|
+
encoding="utf-8",
|
|
1187
|
+
) as handle:
|
|
1188
|
+
handle.write(payload)
|
|
1189
|
+
handle.flush()
|
|
1190
|
+
os.fsync(handle.fileno())
|
|
1191
|
+
|
|
1192
|
+
temp.replace(path)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _pending_store_transaction_lock_file() -> Path:
|
|
1196
|
+
store = _pending_store_file()
|
|
1197
|
+
return store.with_name(store.name + ".transaction.lock")
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _acquire_pending_store_transaction_lock(
|
|
1201
|
+
timeout: float = 5.0,
|
|
1202
|
+
) -> tuple[Path, int]:
|
|
1203
|
+
"""Acquire an OS-backed cross-process lock for store mutation."""
|
|
1204
|
+
import time
|
|
1205
|
+
|
|
1206
|
+
if timeout <= 0:
|
|
1207
|
+
raise ValueError("store lock timeout must be positive")
|
|
1208
|
+
|
|
1209
|
+
path = _pending_store_transaction_lock_file()
|
|
1210
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1211
|
+
|
|
1212
|
+
# The lock file itself may exist forever. Existence is NOT ownership.
|
|
1213
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
1214
|
+
flags |= getattr(os, "O_BINARY", 0)
|
|
1215
|
+
flags |= getattr(os, "O_NOINHERIT", 0)
|
|
1216
|
+
|
|
1217
|
+
fd = os.open(path, flags, 0o600)
|
|
1218
|
+
|
|
1219
|
+
try:
|
|
1220
|
+
# Windows byte-range locking needs a byte to lock.
|
|
1221
|
+
if os.fstat(fd).st_size == 0:
|
|
1222
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
1223
|
+
os.write(fd, b"\0")
|
|
1224
|
+
os.fsync(fd)
|
|
1225
|
+
|
|
1226
|
+
deadline = time.monotonic() + timeout
|
|
1227
|
+
|
|
1228
|
+
if os.name == "nt":
|
|
1229
|
+
import msvcrt
|
|
1230
|
+
|
|
1231
|
+
while True:
|
|
1232
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
1233
|
+
|
|
1234
|
+
try:
|
|
1235
|
+
msvcrt.locking(
|
|
1236
|
+
fd,
|
|
1237
|
+
msvcrt.LK_NBLCK,
|
|
1238
|
+
1,
|
|
1239
|
+
)
|
|
1240
|
+
break
|
|
1241
|
+
|
|
1242
|
+
except OSError as exc:
|
|
1243
|
+
if time.monotonic() >= deadline:
|
|
1244
|
+
raise SettlementStoreError(
|
|
1245
|
+
"persistent settlement store is busy; "
|
|
1246
|
+
"refusing mutation fail-closed"
|
|
1247
|
+
) from exc
|
|
1248
|
+
|
|
1249
|
+
time.sleep(0.01)
|
|
1250
|
+
|
|
1251
|
+
else:
|
|
1252
|
+
import errno
|
|
1253
|
+
import fcntl
|
|
1254
|
+
|
|
1255
|
+
while True:
|
|
1256
|
+
try:
|
|
1257
|
+
fcntl.flock(
|
|
1258
|
+
fd,
|
|
1259
|
+
fcntl.LOCK_EX | fcntl.LOCK_NB,
|
|
1260
|
+
)
|
|
1261
|
+
break
|
|
1262
|
+
|
|
1263
|
+
except OSError as exc:
|
|
1264
|
+
if exc.errno not in {
|
|
1265
|
+
errno.EACCES,
|
|
1266
|
+
errno.EAGAIN,
|
|
1267
|
+
}:
|
|
1268
|
+
raise
|
|
1269
|
+
|
|
1270
|
+
if time.monotonic() >= deadline:
|
|
1271
|
+
raise SettlementStoreError(
|
|
1272
|
+
"persistent settlement store is busy; "
|
|
1273
|
+
"refusing mutation fail-closed"
|
|
1274
|
+
) from exc
|
|
1275
|
+
|
|
1276
|
+
time.sleep(0.01)
|
|
1277
|
+
|
|
1278
|
+
return path, fd
|
|
1279
|
+
|
|
1280
|
+
except Exception:
|
|
1281
|
+
os.close(fd)
|
|
1282
|
+
raise
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _release_pending_store_transaction_lock(
|
|
1286
|
+
handle: tuple[Path, int],
|
|
1287
|
+
) -> None:
|
|
1288
|
+
_path, fd = handle
|
|
1289
|
+
|
|
1290
|
+
try:
|
|
1291
|
+
if os.name == "nt":
|
|
1292
|
+
import msvcrt
|
|
1293
|
+
|
|
1294
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
1295
|
+
msvcrt.locking(
|
|
1296
|
+
fd,
|
|
1297
|
+
msvcrt.LK_UNLCK,
|
|
1298
|
+
1,
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
else:
|
|
1302
|
+
import fcntl
|
|
1303
|
+
|
|
1304
|
+
fcntl.flock(
|
|
1305
|
+
fd,
|
|
1306
|
+
fcntl.LOCK_UN,
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
finally:
|
|
1310
|
+
os.close(fd)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _migrate_pending_store_to_current_format() -> bool:
|
|
1314
|
+
"""Migrate one legacy versionless pending store to current format."""
|
|
1315
|
+
|
|
1316
|
+
lock = _acquire_pending_store_transaction_lock()
|
|
1317
|
+
|
|
1318
|
+
try:
|
|
1319
|
+
path = _pending_store_file()
|
|
1320
|
+
|
|
1321
|
+
if _pending_store_is_unambiguously_absent(
|
|
1322
|
+
path
|
|
1323
|
+
):
|
|
1324
|
+
return False
|
|
1325
|
+
|
|
1326
|
+
raw = _read_pending_store_document(
|
|
1327
|
+
path
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
format_kind = (
|
|
1331
|
+
_classify_pending_store_format(
|
|
1332
|
+
raw
|
|
1333
|
+
)
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
if format_kind == "current":
|
|
1337
|
+
# Already current: preserve bytes exactly.
|
|
1338
|
+
return False
|
|
1339
|
+
|
|
1340
|
+
migrated = {
|
|
1341
|
+
"format": PENDING_STORE_FORMAT,
|
|
1342
|
+
"entries": dict(
|
|
1343
|
+
raw["entries"]
|
|
1344
|
+
),
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
_write_pending_store(
|
|
1348
|
+
migrated
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
return True
|
|
1352
|
+
|
|
1353
|
+
finally:
|
|
1354
|
+
_release_pending_store_transaction_lock(
|
|
1355
|
+
lock
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _mutate_pending_store(mutator: Any) -> Any:
|
|
1362
|
+
"""Perform one atomic load-mutate-write store transaction."""
|
|
1363
|
+
lock = _acquire_pending_store_transaction_lock()
|
|
1364
|
+
|
|
1365
|
+
try:
|
|
1366
|
+
store = _load_pending_store()
|
|
1367
|
+
|
|
1368
|
+
result, changed = mutator(store)
|
|
1369
|
+
|
|
1370
|
+
if changed:
|
|
1371
|
+
_write_pending_store(store)
|
|
1372
|
+
|
|
1373
|
+
return result
|
|
1374
|
+
|
|
1375
|
+
finally:
|
|
1376
|
+
_release_pending_store_transaction_lock(lock)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _persistent_intent_key(intent_id: str) -> str:
|
|
1380
|
+
import hashlib
|
|
1381
|
+
|
|
1382
|
+
return hashlib.sha256(intent_id.encode("utf-8")).hexdigest()
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def list_persistent_payment_intents() -> tuple[dict[str, Any], ...]:
|
|
1387
|
+
"""Return a consistent read-only snapshot of persisted payment intents."""
|
|
1388
|
+
|
|
1389
|
+
def read(store: dict[str, Any]):
|
|
1390
|
+
entries = [
|
|
1391
|
+
dict(raw_entry)
|
|
1392
|
+
for raw_entry in store["entries"].values()
|
|
1393
|
+
if isinstance(raw_entry, Mapping)
|
|
1394
|
+
]
|
|
1395
|
+
|
|
1396
|
+
entries.sort(
|
|
1397
|
+
key=lambda entry: (
|
|
1398
|
+
str(entry.get("updated_at", "")),
|
|
1399
|
+
str(entry.get("intent_id", "")),
|
|
1400
|
+
),
|
|
1401
|
+
reverse=True,
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
return tuple(entries), False
|
|
1405
|
+
|
|
1406
|
+
return _mutate_pending_store(read)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def get_persistent_payment_intent(
|
|
1410
|
+
intent_id: str,
|
|
1411
|
+
) -> dict[str, Any] | None:
|
|
1412
|
+
"""Return one persisted logical payment intent without mutating state."""
|
|
1413
|
+
|
|
1414
|
+
normalized = intent_id.strip()
|
|
1415
|
+
|
|
1416
|
+
if not normalized:
|
|
1417
|
+
raise ValueError("intent_id must not be empty")
|
|
1418
|
+
|
|
1419
|
+
if len(normalized) > 256:
|
|
1420
|
+
raise ValueError("intent_id must be at most 256 characters")
|
|
1421
|
+
|
|
1422
|
+
intent_key = _persistent_intent_key(normalized)
|
|
1423
|
+
|
|
1424
|
+
def read(store: dict[str, Any]):
|
|
1425
|
+
raw_entry = store["entries"].get(intent_key)
|
|
1426
|
+
|
|
1427
|
+
if not isinstance(raw_entry, Mapping):
|
|
1428
|
+
return None, False
|
|
1429
|
+
|
|
1430
|
+
return dict(raw_entry), False
|
|
1431
|
+
|
|
1432
|
+
return _mutate_pending_store(read)
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def _resolve_payment_intent_id(
|
|
1436
|
+
intent_id: str | None,
|
|
1437
|
+
resource: str,
|
|
1438
|
+
requirement: Any,
|
|
1439
|
+
) -> str:
|
|
1440
|
+
"""Return a stable logical operation id.
|
|
1441
|
+
|
|
1442
|
+
Explicit intent ids survive quote changes. Legacy callers fall back to
|
|
1443
|
+
resource + quote fingerprint for backward compatibility.
|
|
1444
|
+
"""
|
|
1445
|
+
import hashlib
|
|
1446
|
+
|
|
1447
|
+
if intent_id is not None:
|
|
1448
|
+
normalized = intent_id.strip()
|
|
1449
|
+
|
|
1450
|
+
if not normalized:
|
|
1451
|
+
raise ValueError("intent_id must not be empty")
|
|
1452
|
+
|
|
1453
|
+
if len(normalized) > 256:
|
|
1454
|
+
raise ValueError("intent_id must be at most 256 characters")
|
|
1455
|
+
|
|
1456
|
+
return normalized
|
|
1457
|
+
|
|
1458
|
+
legacy_raw = (
|
|
1459
|
+
f"{resource}\n{requirement.fingerprint()}".encode("utf-8")
|
|
1460
|
+
)
|
|
1461
|
+
legacy_digest = hashlib.sha256(legacy_raw).hexdigest()
|
|
1462
|
+
return f"legacy:{legacy_digest}"
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _intent_claim_directory() -> Path:
|
|
1467
|
+
store = _pending_store_file()
|
|
1468
|
+
return store.parent / f"{store.name}.claims"
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _probe_process_identity(
|
|
1472
|
+
pid: int,
|
|
1473
|
+
) -> dict[str, str | None]:
|
|
1474
|
+
"""Probe one process without sending signals.
|
|
1475
|
+
|
|
1476
|
+
Returns:
|
|
1477
|
+
- alive: process exists and has a stable creation identity;
|
|
1478
|
+
- missing: process is proven absent/terminated;
|
|
1479
|
+
- indeterminate: existence/identity could not be proven safely.
|
|
1480
|
+
|
|
1481
|
+
PID alone is insufficient because operating systems may reuse it.
|
|
1482
|
+
"""
|
|
1483
|
+
|
|
1484
|
+
import sys
|
|
1485
|
+
|
|
1486
|
+
if (
|
|
1487
|
+
isinstance(pid, bool)
|
|
1488
|
+
or not isinstance(pid, int)
|
|
1489
|
+
):
|
|
1490
|
+
return {
|
|
1491
|
+
"status": "indeterminate",
|
|
1492
|
+
"identity": None,
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
if pid <= 0:
|
|
1496
|
+
return {
|
|
1497
|
+
"status": "missing",
|
|
1498
|
+
"identity": None,
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
# ---------------------------------------------------------
|
|
1502
|
+
# Windows
|
|
1503
|
+
# ---------------------------------------------------------
|
|
1504
|
+
|
|
1505
|
+
if os.name == "nt":
|
|
1506
|
+
import ctypes
|
|
1507
|
+
from ctypes import wintypes
|
|
1508
|
+
|
|
1509
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
1510
|
+
SYNCHRONIZE = 0x00100000
|
|
1511
|
+
|
|
1512
|
+
WAIT_OBJECT_0 = 0x00000000
|
|
1513
|
+
WAIT_TIMEOUT = 0x00000102
|
|
1514
|
+
WAIT_FAILED = 0xFFFFFFFF
|
|
1515
|
+
|
|
1516
|
+
ERROR_INVALID_PARAMETER = 87
|
|
1517
|
+
|
|
1518
|
+
kernel32 = ctypes.WinDLL(
|
|
1519
|
+
"kernel32",
|
|
1520
|
+
use_last_error=True,
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
open_process = kernel32.OpenProcess
|
|
1524
|
+
open_process.argtypes = (
|
|
1525
|
+
wintypes.DWORD,
|
|
1526
|
+
wintypes.BOOL,
|
|
1527
|
+
wintypes.DWORD,
|
|
1528
|
+
)
|
|
1529
|
+
open_process.restype = wintypes.HANDLE
|
|
1530
|
+
|
|
1531
|
+
wait_for_single_object = (
|
|
1532
|
+
kernel32.WaitForSingleObject
|
|
1533
|
+
)
|
|
1534
|
+
wait_for_single_object.argtypes = (
|
|
1535
|
+
wintypes.HANDLE,
|
|
1536
|
+
wintypes.DWORD,
|
|
1537
|
+
)
|
|
1538
|
+
wait_for_single_object.restype = (
|
|
1539
|
+
wintypes.DWORD
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
get_process_times = (
|
|
1543
|
+
kernel32.GetProcessTimes
|
|
1544
|
+
)
|
|
1545
|
+
get_process_times.argtypes = (
|
|
1546
|
+
wintypes.HANDLE,
|
|
1547
|
+
ctypes.POINTER(
|
|
1548
|
+
wintypes.FILETIME
|
|
1549
|
+
),
|
|
1550
|
+
ctypes.POINTER(
|
|
1551
|
+
wintypes.FILETIME
|
|
1552
|
+
),
|
|
1553
|
+
ctypes.POINTER(
|
|
1554
|
+
wintypes.FILETIME
|
|
1555
|
+
),
|
|
1556
|
+
ctypes.POINTER(
|
|
1557
|
+
wintypes.FILETIME
|
|
1558
|
+
),
|
|
1559
|
+
)
|
|
1560
|
+
get_process_times.restype = (
|
|
1561
|
+
wintypes.BOOL
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
close_handle = kernel32.CloseHandle
|
|
1565
|
+
close_handle.argtypes = (
|
|
1566
|
+
wintypes.HANDLE,
|
|
1567
|
+
)
|
|
1568
|
+
close_handle.restype = (
|
|
1569
|
+
wintypes.BOOL
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
access = (
|
|
1573
|
+
PROCESS_QUERY_LIMITED_INFORMATION
|
|
1574
|
+
| SYNCHRONIZE
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
handle = open_process(
|
|
1578
|
+
access,
|
|
1579
|
+
False,
|
|
1580
|
+
pid,
|
|
1581
|
+
)
|
|
1582
|
+
|
|
1583
|
+
if not handle:
|
|
1584
|
+
error = ctypes.get_last_error()
|
|
1585
|
+
|
|
1586
|
+
if error == ERROR_INVALID_PARAMETER:
|
|
1587
|
+
return {
|
|
1588
|
+
"status": "missing",
|
|
1589
|
+
"identity": None,
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
# Access denied or another unknown condition must
|
|
1593
|
+
# never be interpreted as proof that the process died.
|
|
1594
|
+
return {
|
|
1595
|
+
"status": "indeterminate",
|
|
1596
|
+
"identity": None,
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
try:
|
|
1600
|
+
wait_result = (
|
|
1601
|
+
wait_for_single_object(
|
|
1602
|
+
handle,
|
|
1603
|
+
0,
|
|
1604
|
+
)
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
if wait_result == WAIT_OBJECT_0:
|
|
1608
|
+
return {
|
|
1609
|
+
"status": "missing",
|
|
1610
|
+
"identity": None,
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if wait_result == WAIT_FAILED:
|
|
1614
|
+
return {
|
|
1615
|
+
"status": "indeterminate",
|
|
1616
|
+
"identity": None,
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if wait_result != WAIT_TIMEOUT:
|
|
1620
|
+
return {
|
|
1621
|
+
"status": "indeterminate",
|
|
1622
|
+
"identity": None,
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
creation = wintypes.FILETIME()
|
|
1626
|
+
exit_time = wintypes.FILETIME()
|
|
1627
|
+
kernel_time = wintypes.FILETIME()
|
|
1628
|
+
user_time = wintypes.FILETIME()
|
|
1629
|
+
|
|
1630
|
+
ok = get_process_times(
|
|
1631
|
+
handle,
|
|
1632
|
+
ctypes.byref(
|
|
1633
|
+
creation
|
|
1634
|
+
),
|
|
1635
|
+
ctypes.byref(
|
|
1636
|
+
exit_time
|
|
1637
|
+
),
|
|
1638
|
+
ctypes.byref(
|
|
1639
|
+
kernel_time
|
|
1640
|
+
),
|
|
1641
|
+
ctypes.byref(
|
|
1642
|
+
user_time
|
|
1643
|
+
),
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
if not ok:
|
|
1647
|
+
return {
|
|
1648
|
+
"status": "indeterminate",
|
|
1649
|
+
"identity": None,
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
creation_ticks = (
|
|
1653
|
+
(
|
|
1654
|
+
int(
|
|
1655
|
+
creation.dwHighDateTime
|
|
1656
|
+
)
|
|
1657
|
+
<< 32
|
|
1658
|
+
)
|
|
1659
|
+
| int(
|
|
1660
|
+
creation.dwLowDateTime
|
|
1661
|
+
)
|
|
1662
|
+
)
|
|
1663
|
+
|
|
1664
|
+
if creation_ticks <= 0:
|
|
1665
|
+
return {
|
|
1666
|
+
"status": "indeterminate",
|
|
1667
|
+
"identity": None,
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
return {
|
|
1671
|
+
"status": "alive",
|
|
1672
|
+
"identity": (
|
|
1673
|
+
f"windows:{creation_ticks}"
|
|
1674
|
+
),
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
finally:
|
|
1678
|
+
close_handle(
|
|
1679
|
+
handle
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
# ---------------------------------------------------------
|
|
1683
|
+
# Linux
|
|
1684
|
+
# ---------------------------------------------------------
|
|
1685
|
+
|
|
1686
|
+
if sys.platform.startswith(
|
|
1687
|
+
"linux"
|
|
1688
|
+
):
|
|
1689
|
+
stat_path = Path(
|
|
1690
|
+
f"/proc/{pid}/stat"
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
try:
|
|
1694
|
+
raw = stat_path.read_text(
|
|
1695
|
+
encoding="utf-8",
|
|
1696
|
+
errors="strict",
|
|
1697
|
+
)
|
|
1698
|
+
|
|
1699
|
+
except FileNotFoundError:
|
|
1700
|
+
return {
|
|
1701
|
+
"status": "missing",
|
|
1702
|
+
"identity": None,
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
except OSError:
|
|
1706
|
+
return {
|
|
1707
|
+
"status": "indeterminate",
|
|
1708
|
+
"identity": None,
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
# Field 2 (comm) is parenthesized and may contain spaces.
|
|
1712
|
+
# Parse from the final ')' so the remaining sequence starts
|
|
1713
|
+
# at field 3 (state).
|
|
1714
|
+
close_paren = raw.rfind(
|
|
1715
|
+
")"
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
if close_paren == -1:
|
|
1719
|
+
return {
|
|
1720
|
+
"status": "indeterminate",
|
|
1721
|
+
"identity": None,
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
remaining = raw[
|
|
1725
|
+
close_paren + 1:
|
|
1726
|
+
].strip().split()
|
|
1727
|
+
|
|
1728
|
+
# remaining[0] == field 3.
|
|
1729
|
+
# starttime is field 22 => index 19 here.
|
|
1730
|
+
if len(remaining) <= 19:
|
|
1731
|
+
return {
|
|
1732
|
+
"status": "indeterminate",
|
|
1733
|
+
"identity": None,
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
starttime = remaining[
|
|
1737
|
+
19
|
|
1738
|
+
]
|
|
1739
|
+
|
|
1740
|
+
if not starttime.isdigit():
|
|
1741
|
+
return {
|
|
1742
|
+
"status": "indeterminate",
|
|
1743
|
+
"identity": None,
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
return {
|
|
1747
|
+
"status": "alive",
|
|
1748
|
+
"identity": (
|
|
1749
|
+
f"linux:{starttime}"
|
|
1750
|
+
),
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
# Unsupported platforms remain fail-closed.
|
|
1754
|
+
return {
|
|
1755
|
+
"status": "indeterminate",
|
|
1756
|
+
"identity": None,
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _classify_payment_intent_claim_recovery(
|
|
1761
|
+
claim: Mapping[str, Any],
|
|
1762
|
+
*,
|
|
1763
|
+
process_probe: Mapping[str, Any],
|
|
1764
|
+
) -> dict[str, Any]:
|
|
1765
|
+
"""Classify one persisted payment-intent claim without mutation.
|
|
1766
|
+
|
|
1767
|
+
A claim is reclaimable only when process evidence positively proves
|
|
1768
|
+
that the original claiming process is gone or that its PID has been
|
|
1769
|
+
reused by a different process instance.
|
|
1770
|
+
|
|
1771
|
+
Any malformed or incomplete evidence fails closed.
|
|
1772
|
+
"""
|
|
1773
|
+
|
|
1774
|
+
if not isinstance(
|
|
1775
|
+
claim,
|
|
1776
|
+
Mapping,
|
|
1777
|
+
):
|
|
1778
|
+
return {
|
|
1779
|
+
"status": "indeterminate",
|
|
1780
|
+
"safe_to_reclaim": False,
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
if claim.get(
|
|
1784
|
+
"format"
|
|
1785
|
+
) != "qruntime-x402-intent-claim-v2":
|
|
1786
|
+
return {
|
|
1787
|
+
"status": "indeterminate",
|
|
1788
|
+
"safe_to_reclaim": False,
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
pid = claim.get(
|
|
1792
|
+
"pid"
|
|
1793
|
+
)
|
|
1794
|
+
|
|
1795
|
+
claimed_identity = claim.get(
|
|
1796
|
+
"process_identity"
|
|
1797
|
+
)
|
|
1798
|
+
|
|
1799
|
+
claimed_identity_status = claim.get(
|
|
1800
|
+
"process_identity_status"
|
|
1801
|
+
)
|
|
1802
|
+
|
|
1803
|
+
if (
|
|
1804
|
+
isinstance(pid, bool)
|
|
1805
|
+
or not isinstance(pid, int)
|
|
1806
|
+
or pid <= 0
|
|
1807
|
+
or not isinstance(
|
|
1808
|
+
claimed_identity,
|
|
1809
|
+
str,
|
|
1810
|
+
)
|
|
1811
|
+
or not claimed_identity
|
|
1812
|
+
or claimed_identity_status != "alive"
|
|
1813
|
+
):
|
|
1814
|
+
return {
|
|
1815
|
+
"status": "indeterminate",
|
|
1816
|
+
"safe_to_reclaim": False,
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
if not isinstance(
|
|
1820
|
+
process_probe,
|
|
1821
|
+
Mapping,
|
|
1822
|
+
):
|
|
1823
|
+
return {
|
|
1824
|
+
"status": "indeterminate",
|
|
1825
|
+
"safe_to_reclaim": False,
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
probe_status = process_probe.get(
|
|
1829
|
+
"status"
|
|
1830
|
+
)
|
|
1831
|
+
|
|
1832
|
+
probe_identity = process_probe.get(
|
|
1833
|
+
"identity"
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
if probe_status == "missing":
|
|
1837
|
+
return {
|
|
1838
|
+
"status": "stale_missing",
|
|
1839
|
+
"safe_to_reclaim": True,
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
if probe_status != "alive":
|
|
1843
|
+
return {
|
|
1844
|
+
"status": "indeterminate",
|
|
1845
|
+
"safe_to_reclaim": False,
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
if (
|
|
1849
|
+
not isinstance(
|
|
1850
|
+
probe_identity,
|
|
1851
|
+
str,
|
|
1852
|
+
)
|
|
1853
|
+
or not probe_identity
|
|
1854
|
+
):
|
|
1855
|
+
return {
|
|
1856
|
+
"status": "indeterminate",
|
|
1857
|
+
"safe_to_reclaim": False,
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
if probe_identity == claimed_identity:
|
|
1861
|
+
return {
|
|
1862
|
+
"status": "active",
|
|
1863
|
+
"safe_to_reclaim": False,
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
return {
|
|
1867
|
+
"status": "stale_pid_reused",
|
|
1868
|
+
"safe_to_reclaim": True,
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def _preview_payment_intent_claim_recovery(
|
|
1873
|
+
logical_intent_id: str,
|
|
1874
|
+
*,
|
|
1875
|
+
probe_process_identity=None,
|
|
1876
|
+
) -> dict[str, Any]:
|
|
1877
|
+
"""Read and classify one persisted intent claim without mutation."""
|
|
1878
|
+
|
|
1879
|
+
if probe_process_identity is None:
|
|
1880
|
+
probe_process_identity = _probe_process_identity
|
|
1881
|
+
|
|
1882
|
+
claim_path = _intent_claim_path(
|
|
1883
|
+
logical_intent_id
|
|
1884
|
+
)
|
|
1885
|
+
|
|
1886
|
+
try:
|
|
1887
|
+
raw = claim_path.read_bytes()
|
|
1888
|
+
except OSError as exc:
|
|
1889
|
+
raise SettlementStoreError(
|
|
1890
|
+
"payment intent claim could not be read"
|
|
1891
|
+
) from exc
|
|
1892
|
+
|
|
1893
|
+
try:
|
|
1894
|
+
claim = json.loads(raw)
|
|
1895
|
+
except (
|
|
1896
|
+
json.JSONDecodeError,
|
|
1897
|
+
UnicodeDecodeError,
|
|
1898
|
+
) as exc:
|
|
1899
|
+
raise SettlementStoreError(
|
|
1900
|
+
"payment intent claim contains invalid JSON"
|
|
1901
|
+
) from exc
|
|
1902
|
+
|
|
1903
|
+
if not isinstance(claim, Mapping):
|
|
1904
|
+
raise SettlementStoreError(
|
|
1905
|
+
"payment intent claim must contain a JSON object"
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1908
|
+
persisted_intent_id = claim.get(
|
|
1909
|
+
"intent_id"
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1912
|
+
if (
|
|
1913
|
+
not isinstance(
|
|
1914
|
+
persisted_intent_id,
|
|
1915
|
+
str,
|
|
1916
|
+
)
|
|
1917
|
+
or persisted_intent_id != logical_intent_id
|
|
1918
|
+
):
|
|
1919
|
+
raise SettlementStoreError(
|
|
1920
|
+
"payment intent claim identity does not match requested intent"
|
|
1921
|
+
)
|
|
1922
|
+
|
|
1923
|
+
pid = claim.get(
|
|
1924
|
+
"pid"
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
if (
|
|
1928
|
+
isinstance(pid, bool)
|
|
1929
|
+
or not isinstance(pid, int)
|
|
1930
|
+
or pid <= 0
|
|
1931
|
+
):
|
|
1932
|
+
process_probe = {
|
|
1933
|
+
"status": "indeterminate",
|
|
1934
|
+
"identity": None,
|
|
1935
|
+
}
|
|
1936
|
+
else:
|
|
1937
|
+
try:
|
|
1938
|
+
process_probe = (
|
|
1939
|
+
probe_process_identity(pid)
|
|
1940
|
+
)
|
|
1941
|
+
except Exception:
|
|
1942
|
+
process_probe = {
|
|
1943
|
+
"status": "indeterminate",
|
|
1944
|
+
"identity": None,
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
if not isinstance(
|
|
1948
|
+
process_probe,
|
|
1949
|
+
Mapping,
|
|
1950
|
+
):
|
|
1951
|
+
process_probe = {
|
|
1952
|
+
"status": "indeterminate",
|
|
1953
|
+
"identity": None,
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
decision = (
|
|
1957
|
+
_classify_payment_intent_claim_recovery(
|
|
1958
|
+
claim,
|
|
1959
|
+
process_probe=process_probe,
|
|
1960
|
+
)
|
|
1961
|
+
)
|
|
1962
|
+
|
|
1963
|
+
return {
|
|
1964
|
+
"intent_id": logical_intent_id,
|
|
1965
|
+
"status": decision["status"],
|
|
1966
|
+
"safe_to_reclaim": decision[
|
|
1967
|
+
"safe_to_reclaim"
|
|
1968
|
+
],
|
|
1969
|
+
"pid": pid,
|
|
1970
|
+
"process_identity": claim.get(
|
|
1971
|
+
"process_identity"
|
|
1972
|
+
),
|
|
1973
|
+
"process_probe": dict(
|
|
1974
|
+
process_probe
|
|
1975
|
+
),
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
def _reclaim_payment_intent_claim_if_stale_unlocked(
|
|
1980
|
+
logical_intent_id: str,
|
|
1981
|
+
*,
|
|
1982
|
+
probe_process_identity=None,
|
|
1983
|
+
) -> bool:
|
|
1984
|
+
"""Reclaim one positively stale claim while caller holds OS guard."""
|
|
1985
|
+
|
|
1986
|
+
if probe_process_identity is None:
|
|
1987
|
+
probe_process_identity = _probe_process_identity
|
|
1988
|
+
|
|
1989
|
+
claim_path = _intent_claim_path(
|
|
1990
|
+
logical_intent_id
|
|
1991
|
+
)
|
|
1992
|
+
|
|
1993
|
+
try:
|
|
1994
|
+
initial_raw = claim_path.read_bytes()
|
|
1995
|
+
except OSError as exc:
|
|
1996
|
+
raise SettlementStoreError(
|
|
1997
|
+
"payment intent claim could not be read for reclaim"
|
|
1998
|
+
) from exc
|
|
1999
|
+
|
|
2000
|
+
try:
|
|
2001
|
+
claim = json.loads(
|
|
2002
|
+
initial_raw
|
|
2003
|
+
)
|
|
2004
|
+
except (
|
|
2005
|
+
json.JSONDecodeError,
|
|
2006
|
+
UnicodeDecodeError,
|
|
2007
|
+
) as exc:
|
|
2008
|
+
raise SettlementStoreError(
|
|
2009
|
+
"payment intent claim contains invalid JSON"
|
|
2010
|
+
) from exc
|
|
2011
|
+
|
|
2012
|
+
if not isinstance(
|
|
2013
|
+
claim,
|
|
2014
|
+
Mapping,
|
|
2015
|
+
):
|
|
2016
|
+
raise SettlementStoreError(
|
|
2017
|
+
"payment intent claim must contain a JSON object"
|
|
2018
|
+
)
|
|
2019
|
+
|
|
2020
|
+
persisted_intent_id = claim.get(
|
|
2021
|
+
"intent_id"
|
|
2022
|
+
)
|
|
2023
|
+
|
|
2024
|
+
if (
|
|
2025
|
+
not isinstance(
|
|
2026
|
+
persisted_intent_id,
|
|
2027
|
+
str,
|
|
2028
|
+
)
|
|
2029
|
+
or persisted_intent_id
|
|
2030
|
+
!= logical_intent_id
|
|
2031
|
+
):
|
|
2032
|
+
raise SettlementStoreError(
|
|
2033
|
+
"payment intent claim identity does not match requested intent"
|
|
2034
|
+
)
|
|
2035
|
+
|
|
2036
|
+
pid = claim.get(
|
|
2037
|
+
"pid"
|
|
2038
|
+
)
|
|
2039
|
+
|
|
2040
|
+
if (
|
|
2041
|
+
isinstance(pid, bool)
|
|
2042
|
+
or not isinstance(pid, int)
|
|
2043
|
+
or pid <= 0
|
|
2044
|
+
):
|
|
2045
|
+
process_probe = {
|
|
2046
|
+
"status": "indeterminate",
|
|
2047
|
+
"identity": None,
|
|
2048
|
+
}
|
|
2049
|
+
else:
|
|
2050
|
+
try:
|
|
2051
|
+
process_probe = probe_process_identity(
|
|
2052
|
+
pid
|
|
2053
|
+
)
|
|
2054
|
+
except Exception:
|
|
2055
|
+
process_probe = {
|
|
2056
|
+
"status": "indeterminate",
|
|
2057
|
+
"identity": None,
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
if not isinstance(
|
|
2061
|
+
process_probe,
|
|
2062
|
+
Mapping,
|
|
2063
|
+
):
|
|
2064
|
+
process_probe = {
|
|
2065
|
+
"status": "indeterminate",
|
|
2066
|
+
"identity": None,
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
decision = (
|
|
2070
|
+
_classify_payment_intent_claim_recovery(
|
|
2071
|
+
claim,
|
|
2072
|
+
process_probe=process_probe,
|
|
2073
|
+
)
|
|
2074
|
+
)
|
|
2075
|
+
|
|
2076
|
+
if not decision["safe_to_reclaim"]:
|
|
2077
|
+
return False
|
|
2078
|
+
|
|
2079
|
+
try:
|
|
2080
|
+
current_raw = claim_path.read_bytes()
|
|
2081
|
+
except OSError:
|
|
2082
|
+
return False
|
|
2083
|
+
|
|
2084
|
+
if current_raw != initial_raw:
|
|
2085
|
+
return False
|
|
2086
|
+
|
|
2087
|
+
try:
|
|
2088
|
+
claim_path.unlink()
|
|
2089
|
+
except FileNotFoundError:
|
|
2090
|
+
return False
|
|
2091
|
+
except OSError as exc:
|
|
2092
|
+
raise SettlementStoreError(
|
|
2093
|
+
"payment intent claim could not be reclaimed"
|
|
2094
|
+
) from exc
|
|
2095
|
+
|
|
2096
|
+
return True
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def _reclaim_payment_intent_claim_if_stale(
|
|
2100
|
+
logical_intent_id: str,
|
|
2101
|
+
*,
|
|
2102
|
+
probe_process_identity=None,
|
|
2103
|
+
) -> bool:
|
|
2104
|
+
"""Reclaim one stale claim under its OS guard."""
|
|
2105
|
+
|
|
2106
|
+
guard = _acquire_payment_intent_claim_guard(
|
|
2107
|
+
logical_intent_id
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
try:
|
|
2111
|
+
return _reclaim_payment_intent_claim_if_stale_unlocked(
|
|
2112
|
+
logical_intent_id,
|
|
2113
|
+
probe_process_identity=probe_process_identity,
|
|
2114
|
+
)
|
|
2115
|
+
finally:
|
|
2116
|
+
_release_payment_intent_claim_guard(
|
|
2117
|
+
guard
|
|
2118
|
+
)
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def _intent_claim_guard_path(
|
|
2126
|
+
logical_intent_id: str,
|
|
2127
|
+
) -> Path:
|
|
2128
|
+
return (
|
|
2129
|
+
_intent_claim_directory()
|
|
2130
|
+
/ f"{_persistent_intent_key(logical_intent_id)}.guard"
|
|
2131
|
+
)
|
|
2132
|
+
|
|
2133
|
+
|
|
2134
|
+
def _acquire_payment_intent_claim_guard(
|
|
2135
|
+
logical_intent_id: str,
|
|
2136
|
+
) -> dict[str, Any]:
|
|
2137
|
+
"""Acquire a non-blocking OS-level guard for one payment intent."""
|
|
2138
|
+
|
|
2139
|
+
import errno
|
|
2140
|
+
|
|
2141
|
+
guard_path = _intent_claim_guard_path(
|
|
2142
|
+
logical_intent_id
|
|
2143
|
+
)
|
|
2144
|
+
|
|
2145
|
+
guard_path.parent.mkdir(
|
|
2146
|
+
parents=True,
|
|
2147
|
+
exist_ok=True,
|
|
2148
|
+
)
|
|
2149
|
+
|
|
2150
|
+
try:
|
|
2151
|
+
handle = open(
|
|
2152
|
+
guard_path,
|
|
2153
|
+
"a+b",
|
|
2154
|
+
buffering=0,
|
|
2155
|
+
)
|
|
2156
|
+
except OSError as exc:
|
|
2157
|
+
raise SettlementStoreError(
|
|
2158
|
+
"payment intent claim guard could not be opened"
|
|
2159
|
+
) from exc
|
|
2160
|
+
|
|
2161
|
+
try:
|
|
2162
|
+
if os.name == "nt":
|
|
2163
|
+
import msvcrt
|
|
2164
|
+
|
|
2165
|
+
handle.seek(0)
|
|
2166
|
+
|
|
2167
|
+
try:
|
|
2168
|
+
msvcrt.locking(
|
|
2169
|
+
handle.fileno(),
|
|
2170
|
+
msvcrt.LK_NBLCK,
|
|
2171
|
+
1,
|
|
2172
|
+
)
|
|
2173
|
+
except OSError as exc:
|
|
2174
|
+
handle.close()
|
|
2175
|
+
|
|
2176
|
+
if exc.errno in (
|
|
2177
|
+
errno.EACCES,
|
|
2178
|
+
errno.EAGAIN,
|
|
2179
|
+
):
|
|
2180
|
+
raise IntentInFlightError(
|
|
2181
|
+
"payment intent guard is already held"
|
|
2182
|
+
) from exc
|
|
2183
|
+
|
|
2184
|
+
raise SettlementStoreError(
|
|
2185
|
+
"payment intent claim guard lock failed"
|
|
2186
|
+
) from exc
|
|
2187
|
+
|
|
2188
|
+
return {
|
|
2189
|
+
"backend": "msvcrt",
|
|
2190
|
+
"handle": handle,
|
|
2191
|
+
"path": guard_path,
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
try:
|
|
2195
|
+
import fcntl
|
|
2196
|
+
except ImportError as exc:
|
|
2197
|
+
handle.close()
|
|
2198
|
+
|
|
2199
|
+
raise SettlementStoreError(
|
|
2200
|
+
"payment intent claim guard is unsupported "
|
|
2201
|
+
"on this platform"
|
|
2202
|
+
) from exc
|
|
2203
|
+
|
|
2204
|
+
try:
|
|
2205
|
+
fcntl.flock(
|
|
2206
|
+
handle.fileno(),
|
|
2207
|
+
fcntl.LOCK_EX
|
|
2208
|
+
| fcntl.LOCK_NB,
|
|
2209
|
+
)
|
|
2210
|
+
except OSError as exc:
|
|
2211
|
+
handle.close()
|
|
2212
|
+
|
|
2213
|
+
if exc.errno in (
|
|
2214
|
+
errno.EACCES,
|
|
2215
|
+
errno.EAGAIN,
|
|
2216
|
+
):
|
|
2217
|
+
raise IntentInFlightError(
|
|
2218
|
+
"payment intent guard is already held"
|
|
2219
|
+
) from exc
|
|
2220
|
+
|
|
2221
|
+
raise SettlementStoreError(
|
|
2222
|
+
"payment intent claim guard lock failed"
|
|
2223
|
+
) from exc
|
|
2224
|
+
|
|
2225
|
+
return {
|
|
2226
|
+
"backend": "fcntl",
|
|
2227
|
+
"handle": handle,
|
|
2228
|
+
"path": guard_path,
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
except Exception:
|
|
2232
|
+
if not handle.closed:
|
|
2233
|
+
handle.close()
|
|
2234
|
+
raise
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _release_payment_intent_claim_guard(
|
|
2238
|
+
guard: Mapping[str, Any],
|
|
2239
|
+
) -> None:
|
|
2240
|
+
"""Release an OS-level payment-intent guard."""
|
|
2241
|
+
|
|
2242
|
+
handle = guard.get(
|
|
2243
|
+
"handle"
|
|
2244
|
+
)
|
|
2245
|
+
|
|
2246
|
+
backend = guard.get(
|
|
2247
|
+
"backend"
|
|
2248
|
+
)
|
|
2249
|
+
|
|
2250
|
+
if handle is None:
|
|
2251
|
+
raise SettlementStoreError(
|
|
2252
|
+
"payment intent claim guard handle is missing"
|
|
2253
|
+
)
|
|
2254
|
+
|
|
2255
|
+
if handle.closed:
|
|
2256
|
+
return
|
|
2257
|
+
|
|
2258
|
+
try:
|
|
2259
|
+
if backend == "msvcrt":
|
|
2260
|
+
import msvcrt
|
|
2261
|
+
|
|
2262
|
+
handle.seek(0)
|
|
2263
|
+
|
|
2264
|
+
msvcrt.locking(
|
|
2265
|
+
handle.fileno(),
|
|
2266
|
+
msvcrt.LK_UNLCK,
|
|
2267
|
+
1,
|
|
2268
|
+
)
|
|
2269
|
+
|
|
2270
|
+
elif backend == "fcntl":
|
|
2271
|
+
import fcntl
|
|
2272
|
+
|
|
2273
|
+
fcntl.flock(
|
|
2274
|
+
handle.fileno(),
|
|
2275
|
+
fcntl.LOCK_UN,
|
|
2276
|
+
)
|
|
2277
|
+
|
|
2278
|
+
else:
|
|
2279
|
+
raise SettlementStoreError(
|
|
2280
|
+
"payment intent claim guard backend is invalid"
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
finally:
|
|
2284
|
+
handle.close()
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def _intent_claim_path(logical_intent_id: str) -> Path:
|
|
2288
|
+
return (
|
|
2289
|
+
_intent_claim_directory()
|
|
2290
|
+
/ f"{_persistent_intent_key(logical_intent_id)}.claim"
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
def _acquire_payment_intent_claim_unlocked(
|
|
2295
|
+
logical_intent_id: str,
|
|
2296
|
+
resource: str,
|
|
2297
|
+
) -> Path:
|
|
2298
|
+
"""Atomically claim one logical payment intent across processes."""
|
|
2299
|
+
claims = _intent_claim_directory()
|
|
2300
|
+
claims.mkdir(parents=True, exist_ok=True)
|
|
2301
|
+
|
|
2302
|
+
path = _intent_claim_path(logical_intent_id)
|
|
2303
|
+
|
|
2304
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
2305
|
+
flags |= getattr(os, "O_BINARY", 0)
|
|
2306
|
+
|
|
2307
|
+
try:
|
|
2308
|
+
fd = os.open(path, flags, 0o600)
|
|
2309
|
+
except FileExistsError as exc:
|
|
2310
|
+
raise IntentInFlightError(
|
|
2311
|
+
"preflight refused payment: payment intent "
|
|
2312
|
+
f"{logical_intent_id} is already in-flight in another process"
|
|
2313
|
+
) from exc
|
|
2314
|
+
|
|
2315
|
+
try:
|
|
2316
|
+
pid = os.getpid()
|
|
2317
|
+
probe = _probe_process_identity(pid)
|
|
2318
|
+
status = str(probe.get("status", ""))
|
|
2319
|
+
identity = probe.get("identity")
|
|
2320
|
+
if status != "alive" or not isinstance(identity, str) or not identity:
|
|
2321
|
+
raise SettlementStoreError(
|
|
2322
|
+
"current process identity could not be established safely for intent claim"
|
|
2323
|
+
)
|
|
2324
|
+
except Exception:
|
|
2325
|
+
os.close(fd)
|
|
2326
|
+
path.unlink(missing_ok=True)
|
|
2327
|
+
raise
|
|
2328
|
+
|
|
2329
|
+
payload = {
|
|
2330
|
+
"format": "qruntime-x402-intent-claim-v2",
|
|
2331
|
+
"intent_id": logical_intent_id,
|
|
2332
|
+
"intent_key": _persistent_intent_key(logical_intent_id),
|
|
2333
|
+
"resource": resource,
|
|
2334
|
+
"pid": pid,
|
|
2335
|
+
"process_identity": identity,
|
|
2336
|
+
"process_identity_status": status,
|
|
2337
|
+
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
try:
|
|
2341
|
+
encoded = json.dumps(
|
|
2342
|
+
payload,
|
|
2343
|
+
indent=2,
|
|
2344
|
+
ensure_ascii=False,
|
|
2345
|
+
).encode("utf-8")
|
|
2346
|
+
|
|
2347
|
+
os.write(fd, encoded)
|
|
2348
|
+
os.fsync(fd)
|
|
2349
|
+
except Exception:
|
|
2350
|
+
os.close(fd)
|
|
2351
|
+
path.unlink(missing_ok=True)
|
|
2352
|
+
raise
|
|
2353
|
+
else:
|
|
2354
|
+
os.close(fd)
|
|
2355
|
+
|
|
2356
|
+
return path
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
def _acquire_payment_intent_claim(
|
|
2360
|
+
logical_intent_id: str,
|
|
2361
|
+
resource: str,
|
|
2362
|
+
) -> Path:
|
|
2363
|
+
"""Acquire a durable claim, recovering one proven stale claim once."""
|
|
2364
|
+
|
|
2365
|
+
guard = _acquire_payment_intent_claim_guard(
|
|
2366
|
+
logical_intent_id
|
|
2367
|
+
)
|
|
2368
|
+
|
|
2369
|
+
try:
|
|
2370
|
+
try:
|
|
2371
|
+
return _acquire_payment_intent_claim_unlocked(
|
|
2372
|
+
logical_intent_id,
|
|
2373
|
+
resource,
|
|
2374
|
+
)
|
|
2375
|
+
|
|
2376
|
+
except IntentInFlightError:
|
|
2377
|
+
reclaimed = (
|
|
2378
|
+
_reclaim_payment_intent_claim_if_stale_unlocked(
|
|
2379
|
+
logical_intent_id
|
|
2380
|
+
)
|
|
2381
|
+
)
|
|
2382
|
+
|
|
2383
|
+
if not reclaimed:
|
|
2384
|
+
raise
|
|
2385
|
+
|
|
2386
|
+
# Retry exactly once while still holding the same guard.
|
|
2387
|
+
# If another non-cooperating writer created a claim anyway,
|
|
2388
|
+
# O_EXCL fails closed again.
|
|
2389
|
+
return _acquire_payment_intent_claim_unlocked(
|
|
2390
|
+
logical_intent_id,
|
|
2391
|
+
resource,
|
|
2392
|
+
)
|
|
2393
|
+
|
|
2394
|
+
finally:
|
|
2395
|
+
_release_payment_intent_claim_guard(
|
|
2396
|
+
guard
|
|
2397
|
+
)
|
|
2398
|
+
|
|
2399
|
+
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
def _release_payment_intent_claim(path: Path) -> None:
|
|
2405
|
+
path.unlink(missing_ok=True)
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def _reserve_aggregate_budget(
|
|
2409
|
+
logical_intent_id: str,
|
|
2410
|
+
resource: str,
|
|
2411
|
+
requirement: Any,
|
|
2412
|
+
budget: Any,
|
|
2413
|
+
*,
|
|
2414
|
+
agent_id: str | None = None,
|
|
2415
|
+
session_id: str | None = None,
|
|
2416
|
+
):
|
|
2417
|
+
"""Atomically check and reserve aggregate budget.
|
|
2418
|
+
|
|
2419
|
+
The budget decision and reservation write happen
|
|
2420
|
+
under the same persistent-store transaction lock.
|
|
2421
|
+
"""
|
|
2422
|
+
from qruntime.budget import (
|
|
2423
|
+
BudgetLimit,
|
|
2424
|
+
evaluate_budget,
|
|
2425
|
+
)
|
|
2426
|
+
from qruntime.spend import (
|
|
2427
|
+
_normalize_agent_id,
|
|
2428
|
+
_normalize_session_id,
|
|
2429
|
+
summarize_spend_entries,
|
|
2430
|
+
)
|
|
2431
|
+
|
|
2432
|
+
if not isinstance(
|
|
2433
|
+
budget,
|
|
2434
|
+
BudgetLimit,
|
|
2435
|
+
):
|
|
2436
|
+
raise TypeError(
|
|
2437
|
+
"budget must be a BudgetLimit"
|
|
2438
|
+
)
|
|
2439
|
+
|
|
2440
|
+
if (
|
|
2441
|
+
budget.network
|
|
2442
|
+
!= requirement.network
|
|
2443
|
+
or budget.asset
|
|
2444
|
+
!= requirement.asset
|
|
2445
|
+
):
|
|
2446
|
+
raise ValueError(
|
|
2447
|
+
"budget dimensions do not match "
|
|
2448
|
+
"selected payment requirement"
|
|
2449
|
+
)
|
|
2450
|
+
|
|
2451
|
+
if budget.origin is not None:
|
|
2452
|
+
from qruntime.policy import (
|
|
2453
|
+
_normalize_origin,
|
|
2454
|
+
)
|
|
2455
|
+
|
|
2456
|
+
resource_origin = (
|
|
2457
|
+
_normalize_origin(resource)
|
|
2458
|
+
)
|
|
2459
|
+
|
|
2460
|
+
if resource_origin != budget.origin:
|
|
2461
|
+
raise ValueError(
|
|
2462
|
+
"budget origin does not match "
|
|
2463
|
+
"selected payment resource"
|
|
2464
|
+
)
|
|
2465
|
+
|
|
2466
|
+
payment_agent_id = None
|
|
2467
|
+
|
|
2468
|
+
if agent_id is not None:
|
|
2469
|
+
payment_agent_id = (
|
|
2470
|
+
_normalize_agent_id(
|
|
2471
|
+
agent_id
|
|
2472
|
+
)
|
|
2473
|
+
)
|
|
2474
|
+
|
|
2475
|
+
if budget.agent_id is not None:
|
|
2476
|
+
if payment_agent_id is None:
|
|
2477
|
+
raise ValueError(
|
|
2478
|
+
"agent-scoped budget requires agent_id"
|
|
2479
|
+
)
|
|
2480
|
+
|
|
2481
|
+
if payment_agent_id != budget.agent_id:
|
|
2482
|
+
raise ValueError(
|
|
2483
|
+
"budget agent does not match "
|
|
2484
|
+
"payment agent"
|
|
2485
|
+
)
|
|
2486
|
+
|
|
2487
|
+
payment_session_id = None
|
|
2488
|
+
|
|
2489
|
+
if session_id is not None:
|
|
2490
|
+
payment_session_id = (
|
|
2491
|
+
_normalize_session_id(
|
|
2492
|
+
session_id
|
|
2493
|
+
)
|
|
2494
|
+
)
|
|
2495
|
+
|
|
2496
|
+
if budget.session_id is not None:
|
|
2497
|
+
if payment_session_id is None:
|
|
2498
|
+
raise ValueError(
|
|
2499
|
+
"session-scoped budget requires session_id"
|
|
2500
|
+
)
|
|
2501
|
+
|
|
2502
|
+
if (
|
|
2503
|
+
payment_session_id
|
|
2504
|
+
!= budget.session_id
|
|
2505
|
+
):
|
|
2506
|
+
raise ValueError(
|
|
2507
|
+
"budget session does not match "
|
|
2508
|
+
"payment session"
|
|
2509
|
+
)
|
|
2510
|
+
|
|
2511
|
+
intent_key = _persistent_intent_key(
|
|
2512
|
+
logical_intent_id
|
|
2513
|
+
)
|
|
2514
|
+
|
|
2515
|
+
fingerprint = (
|
|
2516
|
+
requirement.fingerprint()
|
|
2517
|
+
)
|
|
2518
|
+
|
|
2519
|
+
now = datetime.now(
|
|
2520
|
+
timezone.utc
|
|
2521
|
+
).isoformat()
|
|
2522
|
+
|
|
2523
|
+
reservation = {
|
|
2524
|
+
"intent_id": logical_intent_id,
|
|
2525
|
+
"intent_key": intent_key,
|
|
2526
|
+
"resource": resource,
|
|
2527
|
+
"agent_id": payment_agent_id,
|
|
2528
|
+
"session_id": payment_session_id,
|
|
2529
|
+
"quote_fingerprint": fingerprint,
|
|
2530
|
+
"requirement": (
|
|
2531
|
+
requirement.canonical_dict()
|
|
2532
|
+
),
|
|
2533
|
+
"transaction": None,
|
|
2534
|
+
"network": requirement.network,
|
|
2535
|
+
"state": "reserved",
|
|
2536
|
+
"first_seen_at": now,
|
|
2537
|
+
"updated_at": now,
|
|
2538
|
+
"reservation": {
|
|
2539
|
+
"reserved_at": now,
|
|
2540
|
+
"budget_limit_atomic": (
|
|
2541
|
+
budget.max_amount_atomic
|
|
2542
|
+
),
|
|
2543
|
+
"network": budget.network,
|
|
2544
|
+
"asset": budget.asset,
|
|
2545
|
+
"origin": budget.origin,
|
|
2546
|
+
"agent_id": budget.agent_id,
|
|
2547
|
+
"session_id": budget.session_id,
|
|
2548
|
+
},
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
def mutate(
|
|
2552
|
+
store: dict[str, Any],
|
|
2553
|
+
):
|
|
2554
|
+
existing = store[
|
|
2555
|
+
"entries"
|
|
2556
|
+
].get(intent_key)
|
|
2557
|
+
|
|
2558
|
+
if existing is not None:
|
|
2559
|
+
raise SettlementStoreError(
|
|
2560
|
+
"persistent economic state "
|
|
2561
|
+
"already exists for payment intent"
|
|
2562
|
+
)
|
|
2563
|
+
|
|
2564
|
+
try:
|
|
2565
|
+
summary = (
|
|
2566
|
+
summarize_spend_entries(
|
|
2567
|
+
(
|
|
2568
|
+
entry
|
|
2569
|
+
for entry in (
|
|
2570
|
+
store[
|
|
2571
|
+
"entries"
|
|
2572
|
+
].values()
|
|
2573
|
+
)
|
|
2574
|
+
if isinstance(
|
|
2575
|
+
entry,
|
|
2576
|
+
Mapping,
|
|
2577
|
+
)
|
|
2578
|
+
),
|
|
2579
|
+
origin=budget.origin,
|
|
2580
|
+
agent_id=budget.agent_id,
|
|
2581
|
+
session_id=budget.session_id,
|
|
2582
|
+
)
|
|
2583
|
+
)
|
|
2584
|
+
except ValueError as exc:
|
|
2585
|
+
raise SettlementStoreError(
|
|
2586
|
+
"persistent economic state "
|
|
2587
|
+
"is invalid; refusing budget "
|
|
2588
|
+
"reservation fail-closed"
|
|
2589
|
+
) from exc
|
|
2590
|
+
|
|
2591
|
+
decision = evaluate_budget(
|
|
2592
|
+
budget,
|
|
2593
|
+
summary=summary,
|
|
2594
|
+
requested_amount_atomic=(
|
|
2595
|
+
requirement.amount_atomic
|
|
2596
|
+
),
|
|
2597
|
+
)
|
|
2598
|
+
|
|
2599
|
+
if not decision.allowed:
|
|
2600
|
+
return decision, False
|
|
2601
|
+
|
|
2602
|
+
store[
|
|
2603
|
+
"entries"
|
|
2604
|
+
][intent_key] = reservation
|
|
2605
|
+
|
|
2606
|
+
return decision, True
|
|
2607
|
+
|
|
2608
|
+
return _mutate_pending_store(
|
|
2609
|
+
mutate
|
|
2610
|
+
)
|
|
2611
|
+
|
|
2612
|
+
|
|
2613
|
+
def _reserve_budget_set(
|
|
2614
|
+
logical_intent_id: str,
|
|
2615
|
+
resource: str,
|
|
2616
|
+
requirement: Any,
|
|
2617
|
+
budget_set: Any,
|
|
2618
|
+
*,
|
|
2619
|
+
agent_id: str | None = None,
|
|
2620
|
+
session_id: str | None = None,
|
|
2621
|
+
):
|
|
2622
|
+
"""Atomically evaluate all budget constraints and reserve once."""
|
|
2623
|
+
from qruntime.budget import (
|
|
2624
|
+
BudgetSet,
|
|
2625
|
+
BudgetSetDecision,
|
|
2626
|
+
evaluate_budget,
|
|
2627
|
+
)
|
|
2628
|
+
from qruntime.policy import (
|
|
2629
|
+
_normalize_origin,
|
|
2630
|
+
)
|
|
2631
|
+
from qruntime.spend import (
|
|
2632
|
+
_normalize_agent_id,
|
|
2633
|
+
_normalize_session_id,
|
|
2634
|
+
summarize_spend_entries,
|
|
2635
|
+
)
|
|
2636
|
+
|
|
2637
|
+
if not isinstance(
|
|
2638
|
+
budget_set,
|
|
2639
|
+
BudgetSet,
|
|
2640
|
+
):
|
|
2641
|
+
raise TypeError(
|
|
2642
|
+
"budget_set must be a BudgetSet"
|
|
2643
|
+
)
|
|
2644
|
+
|
|
2645
|
+
resource_origin = (
|
|
2646
|
+
_normalize_origin(resource)
|
|
2647
|
+
)
|
|
2648
|
+
|
|
2649
|
+
payment_agent_id = None
|
|
2650
|
+
|
|
2651
|
+
if agent_id is not None:
|
|
2652
|
+
payment_agent_id = (
|
|
2653
|
+
_normalize_agent_id(
|
|
2654
|
+
agent_id
|
|
2655
|
+
)
|
|
2656
|
+
)
|
|
2657
|
+
|
|
2658
|
+
payment_session_id = None
|
|
2659
|
+
|
|
2660
|
+
if session_id is not None:
|
|
2661
|
+
payment_session_id = (
|
|
2662
|
+
_normalize_session_id(
|
|
2663
|
+
session_id
|
|
2664
|
+
)
|
|
2665
|
+
)
|
|
2666
|
+
|
|
2667
|
+
for limit in budget_set.limits:
|
|
2668
|
+
if (
|
|
2669
|
+
limit.network
|
|
2670
|
+
!= requirement.network
|
|
2671
|
+
or limit.asset
|
|
2672
|
+
!= requirement.asset
|
|
2673
|
+
):
|
|
2674
|
+
raise ValueError(
|
|
2675
|
+
"budget dimensions do not match "
|
|
2676
|
+
"selected payment requirement"
|
|
2677
|
+
)
|
|
2678
|
+
|
|
2679
|
+
if (
|
|
2680
|
+
limit.origin is not None
|
|
2681
|
+
and limit.origin
|
|
2682
|
+
!= resource_origin
|
|
2683
|
+
):
|
|
2684
|
+
raise ValueError(
|
|
2685
|
+
"budget origin does not match "
|
|
2686
|
+
"selected payment resource"
|
|
2687
|
+
)
|
|
2688
|
+
|
|
2689
|
+
if limit.agent_id is not None:
|
|
2690
|
+
if payment_agent_id is None:
|
|
2691
|
+
raise ValueError(
|
|
2692
|
+
"agent-scoped budget requires agent_id"
|
|
2693
|
+
)
|
|
2694
|
+
|
|
2695
|
+
if (
|
|
2696
|
+
payment_agent_id
|
|
2697
|
+
!= limit.agent_id
|
|
2698
|
+
):
|
|
2699
|
+
raise ValueError(
|
|
2700
|
+
"budget agent does not match "
|
|
2701
|
+
"payment agent"
|
|
2702
|
+
)
|
|
2703
|
+
|
|
2704
|
+
if limit.session_id is not None:
|
|
2705
|
+
if payment_session_id is None:
|
|
2706
|
+
raise ValueError(
|
|
2707
|
+
"session-scoped budget "
|
|
2708
|
+
"requires session_id"
|
|
2709
|
+
)
|
|
2710
|
+
|
|
2711
|
+
if (
|
|
2712
|
+
payment_session_id
|
|
2713
|
+
!= limit.session_id
|
|
2714
|
+
):
|
|
2715
|
+
raise ValueError(
|
|
2716
|
+
"budget session does not match "
|
|
2717
|
+
"payment session"
|
|
2718
|
+
)
|
|
2719
|
+
|
|
2720
|
+
intent_key = _persistent_intent_key(
|
|
2721
|
+
logical_intent_id
|
|
2722
|
+
)
|
|
2723
|
+
|
|
2724
|
+
now = datetime.now(
|
|
2725
|
+
timezone.utc
|
|
2726
|
+
).isoformat()
|
|
2727
|
+
|
|
2728
|
+
reservation = {
|
|
2729
|
+
"intent_id": logical_intent_id,
|
|
2730
|
+
"intent_key": intent_key,
|
|
2731
|
+
"resource": resource,
|
|
2732
|
+
"agent_id": payment_agent_id,
|
|
2733
|
+
"session_id": payment_session_id,
|
|
2734
|
+
"quote_fingerprint": (
|
|
2735
|
+
requirement.fingerprint()
|
|
2736
|
+
),
|
|
2737
|
+
"requirement": (
|
|
2738
|
+
requirement.canonical_dict()
|
|
2739
|
+
),
|
|
2740
|
+
"transaction": None,
|
|
2741
|
+
"network": requirement.network,
|
|
2742
|
+
"state": "reserved",
|
|
2743
|
+
"first_seen_at": now,
|
|
2744
|
+
"updated_at": now,
|
|
2745
|
+
"reservation": {
|
|
2746
|
+
"reserved_at": now,
|
|
2747
|
+
"constraints": [
|
|
2748
|
+
{
|
|
2749
|
+
"network": limit.network,
|
|
2750
|
+
"asset": limit.asset,
|
|
2751
|
+
"origin": limit.origin,
|
|
2752
|
+
"agent_id": limit.agent_id,
|
|
2753
|
+
"session_id": limit.session_id,
|
|
2754
|
+
"budget_limit_atomic": (
|
|
2755
|
+
limit.max_amount_atomic
|
|
2756
|
+
),
|
|
2757
|
+
}
|
|
2758
|
+
for limit in budget_set.limits
|
|
2759
|
+
],
|
|
2760
|
+
},
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
def mutate(
|
|
2764
|
+
store: dict[str, Any],
|
|
2765
|
+
):
|
|
2766
|
+
existing = store[
|
|
2767
|
+
"entries"
|
|
2768
|
+
].get(intent_key)
|
|
2769
|
+
|
|
2770
|
+
if existing is not None:
|
|
2771
|
+
raise SettlementStoreError(
|
|
2772
|
+
"persistent economic state "
|
|
2773
|
+
"already exists for payment intent"
|
|
2774
|
+
)
|
|
2775
|
+
|
|
2776
|
+
entries = tuple(
|
|
2777
|
+
entry
|
|
2778
|
+
for entry in (
|
|
2779
|
+
store[
|
|
2780
|
+
"entries"
|
|
2781
|
+
].values()
|
|
2782
|
+
)
|
|
2783
|
+
if isinstance(
|
|
2784
|
+
entry,
|
|
2785
|
+
Mapping,
|
|
2786
|
+
)
|
|
2787
|
+
)
|
|
2788
|
+
|
|
2789
|
+
decisions = []
|
|
2790
|
+
|
|
2791
|
+
try:
|
|
2792
|
+
for limit in budget_set.limits:
|
|
2793
|
+
summary = (
|
|
2794
|
+
summarize_spend_entries(
|
|
2795
|
+
entries,
|
|
2796
|
+
origin=limit.origin,
|
|
2797
|
+
agent_id=limit.agent_id,
|
|
2798
|
+
session_id=limit.session_id,
|
|
2799
|
+
)
|
|
2800
|
+
)
|
|
2801
|
+
|
|
2802
|
+
decision = evaluate_budget(
|
|
2803
|
+
limit,
|
|
2804
|
+
summary=summary,
|
|
2805
|
+
requested_amount_atomic=(
|
|
2806
|
+
requirement.amount_atomic
|
|
2807
|
+
),
|
|
2808
|
+
)
|
|
2809
|
+
|
|
2810
|
+
decisions.append(
|
|
2811
|
+
decision
|
|
2812
|
+
)
|
|
2813
|
+
|
|
2814
|
+
except ValueError as exc:
|
|
2815
|
+
raise SettlementStoreError(
|
|
2816
|
+
"persistent economic state "
|
|
2817
|
+
"is invalid; refusing budget "
|
|
2818
|
+
"reservation fail-closed"
|
|
2819
|
+
) from exc
|
|
2820
|
+
|
|
2821
|
+
result = BudgetSetDecision(
|
|
2822
|
+
allowed=all(
|
|
2823
|
+
decision.allowed
|
|
2824
|
+
for decision in decisions
|
|
2825
|
+
),
|
|
2826
|
+
decisions=tuple(
|
|
2827
|
+
decisions
|
|
2828
|
+
),
|
|
2829
|
+
)
|
|
2830
|
+
|
|
2831
|
+
if not result.allowed:
|
|
2832
|
+
return result, False
|
|
2833
|
+
|
|
2834
|
+
store[
|
|
2835
|
+
"entries"
|
|
2836
|
+
][intent_key] = reservation
|
|
2837
|
+
|
|
2838
|
+
return result, True
|
|
2839
|
+
|
|
2840
|
+
return _mutate_pending_store(
|
|
2841
|
+
mutate
|
|
2842
|
+
)
|
|
2843
|
+
|
|
2844
|
+
|
|
2845
|
+
def _mark_payment_submission_unknown(
|
|
2846
|
+
logical_intent_id: str,
|
|
2847
|
+
resource: str | None = None,
|
|
2848
|
+
requirement: Any | None = None,
|
|
2849
|
+
*,
|
|
2850
|
+
agent_id: str | None = None,
|
|
2851
|
+
session_id: str | None = None,
|
|
2852
|
+
) -> bool:
|
|
2853
|
+
"""Durably mark an intent before the potentially economic handoff.
|
|
2854
|
+
|
|
2855
|
+
Existing reserved state is advanced to submission_unknown.
|
|
2856
|
+
Without a budget reservation, a new durable economic intent entry
|
|
2857
|
+
is created so a crash cannot leave only an orphaned claim file.
|
|
2858
|
+
"""
|
|
2859
|
+
intent_key = _persistent_intent_key(
|
|
2860
|
+
logical_intent_id
|
|
2861
|
+
)
|
|
2862
|
+
|
|
2863
|
+
normalized_agent_id = None
|
|
2864
|
+
if agent_id is not None:
|
|
2865
|
+
from qruntime.spend import _normalize_agent_id
|
|
2866
|
+
|
|
2867
|
+
normalized_agent_id = _normalize_agent_id(
|
|
2868
|
+
agent_id
|
|
2869
|
+
)
|
|
2870
|
+
|
|
2871
|
+
normalized_session_id = None
|
|
2872
|
+
if session_id is not None:
|
|
2873
|
+
from qruntime.spend import _normalize_session_id
|
|
2874
|
+
|
|
2875
|
+
normalized_session_id = _normalize_session_id(
|
|
2876
|
+
session_id
|
|
2877
|
+
)
|
|
2878
|
+
|
|
2879
|
+
now = datetime.now(
|
|
2880
|
+
timezone.utc
|
|
2881
|
+
).isoformat()
|
|
2882
|
+
|
|
2883
|
+
def mutate(
|
|
2884
|
+
store: dict[str, Any],
|
|
2885
|
+
):
|
|
2886
|
+
raw = store[
|
|
2887
|
+
"entries"
|
|
2888
|
+
].get(intent_key)
|
|
2889
|
+
|
|
2890
|
+
if isinstance(
|
|
2891
|
+
raw,
|
|
2892
|
+
Mapping,
|
|
2893
|
+
):
|
|
2894
|
+
state = str(
|
|
2895
|
+
raw.get("state", "")
|
|
2896
|
+
)
|
|
2897
|
+
|
|
2898
|
+
if state == "submission_unknown":
|
|
2899
|
+
return True, False
|
|
2900
|
+
|
|
2901
|
+
if state != "reserved":
|
|
2902
|
+
return False, False
|
|
2903
|
+
|
|
2904
|
+
entry = dict(raw)
|
|
2905
|
+
|
|
2906
|
+
entry["state"] = "submission_unknown"
|
|
2907
|
+
entry["submission_unknown_since"] = now
|
|
2908
|
+
entry["updated_at"] = now
|
|
2909
|
+
|
|
2910
|
+
store[
|
|
2911
|
+
"entries"
|
|
2912
|
+
][intent_key] = entry
|
|
2913
|
+
|
|
2914
|
+
return True, True
|
|
2915
|
+
|
|
2916
|
+
if (
|
|
2917
|
+
resource is None
|
|
2918
|
+
or requirement is None
|
|
2919
|
+
):
|
|
2920
|
+
return False, False
|
|
2921
|
+
|
|
2922
|
+
entry = {
|
|
2923
|
+
"intent_id": logical_intent_id,
|
|
2924
|
+
"intent_key": intent_key,
|
|
2925
|
+
"resource": resource,
|
|
2926
|
+
"agent_id": normalized_agent_id,
|
|
2927
|
+
"session_id": normalized_session_id,
|
|
2928
|
+
"quote_fingerprint": (
|
|
2929
|
+
requirement.fingerprint()
|
|
2930
|
+
),
|
|
2931
|
+
"requirement": (
|
|
2932
|
+
requirement.canonical_dict()
|
|
2933
|
+
),
|
|
2934
|
+
"transaction": None,
|
|
2935
|
+
"network": requirement.network,
|
|
2936
|
+
"state": "submission_unknown",
|
|
2937
|
+
"first_seen_at": now,
|
|
2938
|
+
"updated_at": now,
|
|
2939
|
+
"submission_unknown_since": now,
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
store[
|
|
2943
|
+
"entries"
|
|
2944
|
+
][intent_key] = entry
|
|
2945
|
+
|
|
2946
|
+
return True, True
|
|
2947
|
+
|
|
2948
|
+
return _mutate_pending_store(
|
|
2949
|
+
mutate
|
|
2950
|
+
)
|
|
2951
|
+
|
|
2952
|
+
|
|
2953
|
+
def _classify_eip3009_authorization_recovery(
|
|
2954
|
+
authorization: Mapping[str, Any],
|
|
2955
|
+
*,
|
|
2956
|
+
now_unix: int,
|
|
2957
|
+
authorization_state: bool | None,
|
|
2958
|
+
event_kind: str | None,
|
|
2959
|
+
) -> dict[str, Any]:
|
|
2960
|
+
"""Classify persisted EIP-3009 recovery evidence conservatively."""
|
|
2961
|
+
|
|
2962
|
+
if event_kind not in {
|
|
2963
|
+
None,
|
|
2964
|
+
"used",
|
|
2965
|
+
"canceled",
|
|
2966
|
+
}:
|
|
2967
|
+
raise ValueError(
|
|
2968
|
+
"event_kind must be None, 'used', or 'canceled'"
|
|
2969
|
+
)
|
|
2970
|
+
|
|
2971
|
+
try:
|
|
2972
|
+
valid_after = int(
|
|
2973
|
+
authorization["validAfter"]
|
|
2974
|
+
)
|
|
2975
|
+
valid_before = int(
|
|
2976
|
+
authorization["validBefore"]
|
|
2977
|
+
)
|
|
2978
|
+
except (
|
|
2979
|
+
KeyError,
|
|
2980
|
+
TypeError,
|
|
2981
|
+
ValueError,
|
|
2982
|
+
):
|
|
2983
|
+
return {
|
|
2984
|
+
"status": "indeterminate",
|
|
2985
|
+
"safe_to_release": False,
|
|
2986
|
+
"requires_reconciliation": True,
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
if valid_before <= valid_after:
|
|
2990
|
+
return {
|
|
2991
|
+
"status": "indeterminate",
|
|
2992
|
+
"safe_to_release": False,
|
|
2993
|
+
"requires_reconciliation": True,
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
# Event evidence cannot coherently coexist with
|
|
2997
|
+
# authorization_state=False.
|
|
2998
|
+
if (
|
|
2999
|
+
event_kind is not None
|
|
3000
|
+
and authorization_state is False
|
|
3001
|
+
):
|
|
3002
|
+
return {
|
|
3003
|
+
"status": "indeterminate",
|
|
3004
|
+
"safe_to_release": False,
|
|
3005
|
+
"requires_reconciliation": True,
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
if event_kind == "used":
|
|
3009
|
+
return {
|
|
3010
|
+
"status": "used",
|
|
3011
|
+
"safe_to_release": False,
|
|
3012
|
+
"requires_reconciliation": True,
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
if event_kind == "canceled":
|
|
3016
|
+
return {
|
|
3017
|
+
"status": "canceled",
|
|
3018
|
+
"safe_to_release": True,
|
|
3019
|
+
"requires_reconciliation": False,
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
if authorization_state is True:
|
|
3023
|
+
return {
|
|
3024
|
+
"status": "consumed_indeterminate",
|
|
3025
|
+
"safe_to_release": False,
|
|
3026
|
+
"requires_reconciliation": True,
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
if authorization_state is None:
|
|
3030
|
+
return {
|
|
3031
|
+
"status": "indeterminate",
|
|
3032
|
+
"safe_to_release": False,
|
|
3033
|
+
"requires_reconciliation": True,
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
# ERC-3009 uses strict validity:
|
|
3037
|
+
# block time must be > validAfter and < validBefore.
|
|
3038
|
+
if now_unix <= valid_after:
|
|
3039
|
+
return {
|
|
3040
|
+
"status": "unused_not_yet_valid",
|
|
3041
|
+
"safe_to_release": False,
|
|
3042
|
+
"requires_reconciliation": True,
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
if now_unix >= valid_before:
|
|
3046
|
+
return {
|
|
3047
|
+
"status": "expired_unused",
|
|
3048
|
+
"safe_to_release": True,
|
|
3049
|
+
"requires_reconciliation": False,
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
return {
|
|
3053
|
+
"status": "unused_live",
|
|
3054
|
+
"safe_to_release": False,
|
|
3055
|
+
"requires_reconciliation": True,
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
|
|
3059
|
+
def _aggregate_eip3009_recovery_decisions(
|
|
3060
|
+
authorizations: list[Mapping[str, Any]],
|
|
3061
|
+
*,
|
|
3062
|
+
now_unix: int,
|
|
3063
|
+
evidence_by_nonce: Mapping[str, Mapping[str, Any]],
|
|
3064
|
+
) -> dict[str, Any]:
|
|
3065
|
+
"""Aggregate recovery evidence across all authorizations of one intent.
|
|
3066
|
+
|
|
3067
|
+
Release is safe only when at least one authorization exists and
|
|
3068
|
+
every authorization is independently proven safe to release.
|
|
3069
|
+
"""
|
|
3070
|
+
|
|
3071
|
+
if not authorizations:
|
|
3072
|
+
return {
|
|
3073
|
+
"safe_to_release": False,
|
|
3074
|
+
"requires_reconciliation": True,
|
|
3075
|
+
"decisions": [],
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
decisions: list[dict[str, Any]] = []
|
|
3079
|
+
|
|
3080
|
+
for authorization in authorizations:
|
|
3081
|
+
nonce = str(
|
|
3082
|
+
authorization.get(
|
|
3083
|
+
"nonce",
|
|
3084
|
+
"",
|
|
3085
|
+
)
|
|
3086
|
+
)
|
|
3087
|
+
|
|
3088
|
+
evidence = evidence_by_nonce.get(
|
|
3089
|
+
nonce
|
|
3090
|
+
)
|
|
3091
|
+
|
|
3092
|
+
if not isinstance(
|
|
3093
|
+
evidence,
|
|
3094
|
+
Mapping,
|
|
3095
|
+
):
|
|
3096
|
+
decision = {
|
|
3097
|
+
"status": "indeterminate",
|
|
3098
|
+
"safe_to_release": False,
|
|
3099
|
+
"requires_reconciliation": True,
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
else:
|
|
3103
|
+
decision = (
|
|
3104
|
+
_classify_eip3009_authorization_recovery(
|
|
3105
|
+
authorization,
|
|
3106
|
+
now_unix=now_unix,
|
|
3107
|
+
authorization_state=(
|
|
3108
|
+
evidence.get(
|
|
3109
|
+
"authorization_state"
|
|
3110
|
+
)
|
|
3111
|
+
),
|
|
3112
|
+
event_kind=(
|
|
3113
|
+
evidence.get(
|
|
3114
|
+
"event_kind"
|
|
3115
|
+
)
|
|
3116
|
+
),
|
|
3117
|
+
)
|
|
3118
|
+
)
|
|
3119
|
+
|
|
3120
|
+
decisions.append(
|
|
3121
|
+
decision
|
|
3122
|
+
)
|
|
3123
|
+
|
|
3124
|
+
safe_to_release = all(
|
|
3125
|
+
decision[
|
|
3126
|
+
"safe_to_release"
|
|
3127
|
+
]
|
|
3128
|
+
for decision in decisions
|
|
3129
|
+
)
|
|
3130
|
+
|
|
3131
|
+
requires_reconciliation = (
|
|
3132
|
+
not safe_to_release
|
|
3133
|
+
or any(
|
|
3134
|
+
decision[
|
|
3135
|
+
"requires_reconciliation"
|
|
3136
|
+
]
|
|
3137
|
+
for decision in decisions
|
|
3138
|
+
)
|
|
3139
|
+
)
|
|
3140
|
+
|
|
3141
|
+
return {
|
|
3142
|
+
"safe_to_release": safe_to_release,
|
|
3143
|
+
"requires_reconciliation": (
|
|
3144
|
+
requires_reconciliation
|
|
3145
|
+
),
|
|
3146
|
+
"decisions": decisions,
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
def _collect_eip3009_recovery_evidence(
|
|
3151
|
+
authorizations: list[Mapping[str, Any]],
|
|
3152
|
+
*,
|
|
3153
|
+
read_authorization_state,
|
|
3154
|
+
read_authorization_event,
|
|
3155
|
+
) -> dict[str, dict[str, Any]]:
|
|
3156
|
+
"""Collect chain evidence for persisted EIP-3009 authorizations.
|
|
3157
|
+
|
|
3158
|
+
Each evidence source is queried independently. Failure of either
|
|
3159
|
+
source becomes indeterminate evidence and never fails open.
|
|
3160
|
+
"""
|
|
3161
|
+
|
|
3162
|
+
evidence_by_nonce: dict[
|
|
3163
|
+
str,
|
|
3164
|
+
dict[str, Any],
|
|
3165
|
+
] = {}
|
|
3166
|
+
|
|
3167
|
+
for authorization in authorizations:
|
|
3168
|
+
network = str(
|
|
3169
|
+
authorization.get(
|
|
3170
|
+
"network",
|
|
3171
|
+
"",
|
|
3172
|
+
)
|
|
3173
|
+
)
|
|
3174
|
+
|
|
3175
|
+
asset = str(
|
|
3176
|
+
authorization.get(
|
|
3177
|
+
"asset",
|
|
3178
|
+
"",
|
|
3179
|
+
)
|
|
3180
|
+
)
|
|
3181
|
+
|
|
3182
|
+
authorizer = str(
|
|
3183
|
+
authorization.get(
|
|
3184
|
+
"from",
|
|
3185
|
+
"",
|
|
3186
|
+
)
|
|
3187
|
+
)
|
|
3188
|
+
|
|
3189
|
+
nonce = str(
|
|
3190
|
+
authorization.get(
|
|
3191
|
+
"nonce",
|
|
3192
|
+
"",
|
|
3193
|
+
)
|
|
3194
|
+
)
|
|
3195
|
+
|
|
3196
|
+
if not (
|
|
3197
|
+
network
|
|
3198
|
+
and asset
|
|
3199
|
+
and authorizer
|
|
3200
|
+
and nonce
|
|
3201
|
+
):
|
|
3202
|
+
# Malformed persisted evidence is never interpreted
|
|
3203
|
+
# optimistically.
|
|
3204
|
+
if nonce:
|
|
3205
|
+
evidence_by_nonce[
|
|
3206
|
+
nonce
|
|
3207
|
+
] = {
|
|
3208
|
+
"authorization_state": None,
|
|
3209
|
+
"event_kind": None,
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
continue
|
|
3213
|
+
|
|
3214
|
+
try:
|
|
3215
|
+
authorization_state = (
|
|
3216
|
+
read_authorization_state(
|
|
3217
|
+
network=network,
|
|
3218
|
+
asset=asset,
|
|
3219
|
+
authorizer=authorizer,
|
|
3220
|
+
nonce=nonce,
|
|
3221
|
+
)
|
|
3222
|
+
)
|
|
3223
|
+
|
|
3224
|
+
if not isinstance(
|
|
3225
|
+
authorization_state,
|
|
3226
|
+
bool,
|
|
3227
|
+
):
|
|
3228
|
+
authorization_state = None
|
|
3229
|
+
|
|
3230
|
+
except Exception:
|
|
3231
|
+
authorization_state = None
|
|
3232
|
+
|
|
3233
|
+
try:
|
|
3234
|
+
event_kind = (
|
|
3235
|
+
read_authorization_event(
|
|
3236
|
+
network=network,
|
|
3237
|
+
asset=asset,
|
|
3238
|
+
authorizer=authorizer,
|
|
3239
|
+
nonce=nonce,
|
|
3240
|
+
)
|
|
3241
|
+
)
|
|
3242
|
+
|
|
3243
|
+
if event_kind not in {
|
|
3244
|
+
None,
|
|
3245
|
+
"used",
|
|
3246
|
+
"canceled",
|
|
3247
|
+
}:
|
|
3248
|
+
event_kind = None
|
|
3249
|
+
|
|
3250
|
+
except Exception:
|
|
3251
|
+
event_kind = None
|
|
3252
|
+
|
|
3253
|
+
evidence_by_nonce[
|
|
3254
|
+
nonce
|
|
3255
|
+
] = {
|
|
3256
|
+
"authorization_state": (
|
|
3257
|
+
authorization_state
|
|
3258
|
+
),
|
|
3259
|
+
"event_kind": event_kind,
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
return evidence_by_nonce
|
|
3263
|
+
|
|
3264
|
+
|
|
3265
|
+
def _preview_eip3009_intent_recovery(
|
|
3266
|
+
logical_intent_id: str,
|
|
3267
|
+
*,
|
|
3268
|
+
now_unix: int,
|
|
3269
|
+
read_authorization_state,
|
|
3270
|
+
read_authorization_event,
|
|
3271
|
+
) -> dict[str, Any]:
|
|
3272
|
+
"""Preview recovery for one persisted economic intent.
|
|
3273
|
+
|
|
3274
|
+
This operation is observational only. It reads durable state and
|
|
3275
|
+
external evidence but never mutates the persistent store.
|
|
3276
|
+
"""
|
|
3277
|
+
|
|
3278
|
+
entry = get_persistent_payment_intent(
|
|
3279
|
+
logical_intent_id
|
|
3280
|
+
)
|
|
3281
|
+
|
|
3282
|
+
if not isinstance(
|
|
3283
|
+
entry,
|
|
3284
|
+
Mapping,
|
|
3285
|
+
):
|
|
3286
|
+
raise SettlementStoreError(
|
|
3287
|
+
"persistent payment intent was not found"
|
|
3288
|
+
)
|
|
3289
|
+
|
|
3290
|
+
state = str(
|
|
3291
|
+
entry.get(
|
|
3292
|
+
"state",
|
|
3293
|
+
"",
|
|
3294
|
+
)
|
|
3295
|
+
)
|
|
3296
|
+
|
|
3297
|
+
history_raw = entry.get(
|
|
3298
|
+
"authorizations"
|
|
3299
|
+
)
|
|
3300
|
+
|
|
3301
|
+
authorizations: list[
|
|
3302
|
+
Mapping[str, Any]
|
|
3303
|
+
] = []
|
|
3304
|
+
|
|
3305
|
+
if isinstance(
|
|
3306
|
+
history_raw,
|
|
3307
|
+
list,
|
|
3308
|
+
):
|
|
3309
|
+
for item in history_raw:
|
|
3310
|
+
if not isinstance(
|
|
3311
|
+
item,
|
|
3312
|
+
Mapping,
|
|
3313
|
+
):
|
|
3314
|
+
raise SettlementStoreError(
|
|
3315
|
+
"persistent authorization history "
|
|
3316
|
+
"contains invalid entry"
|
|
3317
|
+
)
|
|
3318
|
+
|
|
3319
|
+
authorizations.append(
|
|
3320
|
+
dict(item)
|
|
3321
|
+
)
|
|
3322
|
+
|
|
3323
|
+
else:
|
|
3324
|
+
# Backwards-compatible support for entries created before
|
|
3325
|
+
# authorization history was introduced.
|
|
3326
|
+
legacy = entry.get(
|
|
3327
|
+
"authorization"
|
|
3328
|
+
)
|
|
3329
|
+
|
|
3330
|
+
if isinstance(
|
|
3331
|
+
legacy,
|
|
3332
|
+
Mapping,
|
|
3333
|
+
):
|
|
3334
|
+
authorizations.append(
|
|
3335
|
+
dict(legacy)
|
|
3336
|
+
)
|
|
3337
|
+
|
|
3338
|
+
evidence = (
|
|
3339
|
+
_collect_eip3009_recovery_evidence(
|
|
3340
|
+
authorizations,
|
|
3341
|
+
read_authorization_state=(
|
|
3342
|
+
read_authorization_state
|
|
3343
|
+
),
|
|
3344
|
+
read_authorization_event=(
|
|
3345
|
+
read_authorization_event
|
|
3346
|
+
),
|
|
3347
|
+
)
|
|
3348
|
+
)
|
|
3349
|
+
|
|
3350
|
+
aggregate = (
|
|
3351
|
+
_aggregate_eip3009_recovery_decisions(
|
|
3352
|
+
authorizations,
|
|
3353
|
+
now_unix=now_unix,
|
|
3354
|
+
evidence_by_nonce=evidence,
|
|
3355
|
+
)
|
|
3356
|
+
)
|
|
3357
|
+
|
|
3358
|
+
return {
|
|
3359
|
+
"intent_id": logical_intent_id,
|
|
3360
|
+
"state": state,
|
|
3361
|
+
"authorization_count": len(
|
|
3362
|
+
authorizations
|
|
3363
|
+
),
|
|
3364
|
+
"safe_to_release": aggregate[
|
|
3365
|
+
"safe_to_release"
|
|
3366
|
+
],
|
|
3367
|
+
"requires_reconciliation": aggregate[
|
|
3368
|
+
"requires_reconciliation"
|
|
3369
|
+
],
|
|
3370
|
+
"decisions": aggregate[
|
|
3371
|
+
"decisions"
|
|
3372
|
+
],
|
|
3373
|
+
"evidence_by_nonce": evidence,
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
|
|
3377
|
+
def _resolve_eip3009_intent_if_safe(
|
|
3378
|
+
logical_intent_id: str,
|
|
3379
|
+
*,
|
|
3380
|
+
now_unix: int,
|
|
3381
|
+
read_authorization_state,
|
|
3382
|
+
read_authorization_event,
|
|
3383
|
+
) -> bool:
|
|
3384
|
+
"""Release ambiguous economic exposure only after proven-safe recovery.
|
|
3385
|
+
|
|
3386
|
+
The operation is fail-closed against TOCTOU changes. Chain evidence
|
|
3387
|
+
is collected outside the store lock, then the exact durable snapshot
|
|
3388
|
+
is compared again inside the mutation transaction before deletion.
|
|
3389
|
+
"""
|
|
3390
|
+
|
|
3391
|
+
initial = get_persistent_payment_intent(
|
|
3392
|
+
logical_intent_id
|
|
3393
|
+
)
|
|
3394
|
+
|
|
3395
|
+
if not isinstance(
|
|
3396
|
+
initial,
|
|
3397
|
+
Mapping,
|
|
3398
|
+
):
|
|
3399
|
+
return False
|
|
3400
|
+
|
|
3401
|
+
if initial.get("state") != "submission_unknown":
|
|
3402
|
+
return False
|
|
3403
|
+
|
|
3404
|
+
# Never destroy an intent claim blindly. A claim may belong to a
|
|
3405
|
+
# process that is still alive. Stale-claim recovery is a separate
|
|
3406
|
+
# operation with its own safety proof.
|
|
3407
|
+
claim_path = _intent_claim_path(
|
|
3408
|
+
logical_intent_id
|
|
3409
|
+
)
|
|
3410
|
+
|
|
3411
|
+
if claim_path.exists():
|
|
3412
|
+
return False
|
|
3413
|
+
|
|
3414
|
+
snapshot = json.dumps(
|
|
3415
|
+
initial,
|
|
3416
|
+
sort_keys=True,
|
|
3417
|
+
separators=(",", ":"),
|
|
3418
|
+
ensure_ascii=False,
|
|
3419
|
+
)
|
|
3420
|
+
|
|
3421
|
+
preview = (
|
|
3422
|
+
_preview_eip3009_intent_recovery(
|
|
3423
|
+
logical_intent_id,
|
|
3424
|
+
now_unix=now_unix,
|
|
3425
|
+
read_authorization_state=(
|
|
3426
|
+
read_authorization_state
|
|
3427
|
+
),
|
|
3428
|
+
read_authorization_event=(
|
|
3429
|
+
read_authorization_event
|
|
3430
|
+
),
|
|
3431
|
+
)
|
|
3432
|
+
)
|
|
3433
|
+
|
|
3434
|
+
if not preview.get(
|
|
3435
|
+
"safe_to_release",
|
|
3436
|
+
False,
|
|
3437
|
+
):
|
|
3438
|
+
return False
|
|
3439
|
+
|
|
3440
|
+
if preview.get(
|
|
3441
|
+
"requires_reconciliation",
|
|
3442
|
+
True,
|
|
3443
|
+
):
|
|
3444
|
+
return False
|
|
3445
|
+
|
|
3446
|
+
intent_key = _persistent_intent_key(
|
|
3447
|
+
logical_intent_id
|
|
3448
|
+
)
|
|
3449
|
+
|
|
3450
|
+
def mutate(
|
|
3451
|
+
store: dict[str, Any],
|
|
3452
|
+
):
|
|
3453
|
+
raw = store[
|
|
3454
|
+
"entries"
|
|
3455
|
+
].get(intent_key)
|
|
3456
|
+
|
|
3457
|
+
if not isinstance(
|
|
3458
|
+
raw,
|
|
3459
|
+
Mapping,
|
|
3460
|
+
):
|
|
3461
|
+
return False, False
|
|
3462
|
+
|
|
3463
|
+
if raw.get("state") != "submission_unknown":
|
|
3464
|
+
return False, False
|
|
3465
|
+
|
|
3466
|
+
current_snapshot = json.dumps(
|
|
3467
|
+
raw,
|
|
3468
|
+
sort_keys=True,
|
|
3469
|
+
separators=(",", ":"),
|
|
3470
|
+
ensure_ascii=False,
|
|
3471
|
+
)
|
|
3472
|
+
|
|
3473
|
+
# Anything changed after evidence was collected:
|
|
3474
|
+
# new authorization, timestamp, state, reconciliation data, etc.
|
|
3475
|
+
# Old evidence must not authorize deletion of newer state.
|
|
3476
|
+
if current_snapshot != snapshot:
|
|
3477
|
+
return False, False
|
|
3478
|
+
|
|
3479
|
+
# Re-check the claim immediately before economic release.
|
|
3480
|
+
if _intent_claim_path(
|
|
3481
|
+
logical_intent_id
|
|
3482
|
+
).exists():
|
|
3483
|
+
return False, False
|
|
3484
|
+
|
|
3485
|
+
del store[
|
|
3486
|
+
"entries"
|
|
3487
|
+
][intent_key]
|
|
3488
|
+
|
|
3489
|
+
return True, True
|
|
3490
|
+
|
|
3491
|
+
return _mutate_pending_store(
|
|
3492
|
+
mutate
|
|
3493
|
+
)
|
|
3494
|
+
|
|
3495
|
+
|
|
3496
|
+
def _make_payment_authorization_persistence_hook(
|
|
3497
|
+
logical_intent_id: str,
|
|
3498
|
+
):
|
|
3499
|
+
"""Persist non-secret recovery metadata after payment creation.
|
|
3500
|
+
|
|
3501
|
+
The signed payment payload exists at this point, but only the
|
|
3502
|
+
authorization envelope is persisted. The executable signature and
|
|
3503
|
+
private key are never written to durable state.
|
|
3504
|
+
"""
|
|
3505
|
+
|
|
3506
|
+
def persist(context):
|
|
3507
|
+
payment_payload = getattr(
|
|
3508
|
+
context,
|
|
3509
|
+
"payment_payload",
|
|
3510
|
+
None,
|
|
3511
|
+
)
|
|
3512
|
+
|
|
3513
|
+
selected = getattr(
|
|
3514
|
+
context,
|
|
3515
|
+
"selected_requirements",
|
|
3516
|
+
None,
|
|
3517
|
+
)
|
|
3518
|
+
|
|
3519
|
+
if payment_payload is None:
|
|
3520
|
+
raise SettlementStoreError(
|
|
3521
|
+
"after-payment hook received no payment payload"
|
|
3522
|
+
)
|
|
3523
|
+
|
|
3524
|
+
if selected is None:
|
|
3525
|
+
raise SettlementStoreError(
|
|
3526
|
+
"after-payment hook received no selected requirements"
|
|
3527
|
+
)
|
|
3528
|
+
|
|
3529
|
+
payload = getattr(
|
|
3530
|
+
payment_payload,
|
|
3531
|
+
"payload",
|
|
3532
|
+
None,
|
|
3533
|
+
)
|
|
3534
|
+
|
|
3535
|
+
if not isinstance(
|
|
3536
|
+
payload,
|
|
3537
|
+
Mapping,
|
|
3538
|
+
):
|
|
3539
|
+
raise SettlementStoreError(
|
|
3540
|
+
"after-payment hook received invalid payment payload"
|
|
3541
|
+
)
|
|
3542
|
+
|
|
3543
|
+
saved = (
|
|
3544
|
+
_record_payment_authorization_envelope(
|
|
3545
|
+
logical_intent_id,
|
|
3546
|
+
network=str(
|
|
3547
|
+
selected.network
|
|
3548
|
+
),
|
|
3549
|
+
asset=str(
|
|
3550
|
+
selected.asset
|
|
3551
|
+
),
|
|
3552
|
+
payload=payload,
|
|
3553
|
+
)
|
|
3554
|
+
)
|
|
3555
|
+
|
|
3556
|
+
if not saved:
|
|
3557
|
+
raise SettlementStoreError(
|
|
3558
|
+
"payment authorization envelope could not "
|
|
3559
|
+
"be persisted before paid request"
|
|
3560
|
+
)
|
|
3561
|
+
|
|
3562
|
+
return None
|
|
3563
|
+
|
|
3564
|
+
return persist
|
|
3565
|
+
|
|
3566
|
+
|
|
3567
|
+
def _record_payment_authorization_envelope(
|
|
3568
|
+
logical_intent_id: str,
|
|
3569
|
+
*,
|
|
3570
|
+
network: str,
|
|
3571
|
+
asset: str,
|
|
3572
|
+
payload: Mapping[str, Any],
|
|
3573
|
+
) -> bool:
|
|
3574
|
+
"""Persist non-secret authorization data for crash recovery.
|
|
3575
|
+
|
|
3576
|
+
The executable payment signature is deliberately never persisted.
|
|
3577
|
+
"""
|
|
3578
|
+
intent_key = _persistent_intent_key(
|
|
3579
|
+
logical_intent_id
|
|
3580
|
+
)
|
|
3581
|
+
|
|
3582
|
+
authorization = payload.get(
|
|
3583
|
+
"authorization"
|
|
3584
|
+
)
|
|
3585
|
+
|
|
3586
|
+
if not isinstance(
|
|
3587
|
+
authorization,
|
|
3588
|
+
Mapping,
|
|
3589
|
+
):
|
|
3590
|
+
raise SettlementStoreError(
|
|
3591
|
+
"payment authorization payload is missing"
|
|
3592
|
+
)
|
|
3593
|
+
|
|
3594
|
+
required_fields = (
|
|
3595
|
+
"from",
|
|
3596
|
+
"to",
|
|
3597
|
+
"value",
|
|
3598
|
+
"validAfter",
|
|
3599
|
+
"validBefore",
|
|
3600
|
+
"nonce",
|
|
3601
|
+
)
|
|
3602
|
+
|
|
3603
|
+
envelope: dict[str, str] = {}
|
|
3604
|
+
|
|
3605
|
+
for field in required_fields:
|
|
3606
|
+
value = authorization.get(
|
|
3607
|
+
field
|
|
3608
|
+
)
|
|
3609
|
+
|
|
3610
|
+
if value is None:
|
|
3611
|
+
raise SettlementStoreError(
|
|
3612
|
+
"payment authorization is missing "
|
|
3613
|
+
f"{field}"
|
|
3614
|
+
)
|
|
3615
|
+
|
|
3616
|
+
envelope[field] = str(
|
|
3617
|
+
value
|
|
3618
|
+
)
|
|
3619
|
+
|
|
3620
|
+
envelope["network"] = str(
|
|
3621
|
+
network
|
|
3622
|
+
)
|
|
3623
|
+
envelope["asset"] = str(
|
|
3624
|
+
asset
|
|
3625
|
+
)
|
|
3626
|
+
|
|
3627
|
+
now = datetime.now(
|
|
3628
|
+
timezone.utc
|
|
3629
|
+
).isoformat()
|
|
3630
|
+
|
|
3631
|
+
def mutate(
|
|
3632
|
+
store: dict[str, Any],
|
|
3633
|
+
):
|
|
3634
|
+
raw = store[
|
|
3635
|
+
"entries"
|
|
3636
|
+
].get(intent_key)
|
|
3637
|
+
|
|
3638
|
+
if not isinstance(
|
|
3639
|
+
raw,
|
|
3640
|
+
Mapping,
|
|
3641
|
+
):
|
|
3642
|
+
return False, False
|
|
3643
|
+
|
|
3644
|
+
if raw.get("state") != "submission_unknown":
|
|
3645
|
+
return False, False
|
|
3646
|
+
|
|
3647
|
+
entry = dict(raw)
|
|
3648
|
+
|
|
3649
|
+
history_raw = entry.get(
|
|
3650
|
+
"authorizations"
|
|
3651
|
+
)
|
|
3652
|
+
|
|
3653
|
+
if history_raw is None:
|
|
3654
|
+
history = []
|
|
3655
|
+
|
|
3656
|
+
legacy = entry.get(
|
|
3657
|
+
"authorization"
|
|
3658
|
+
)
|
|
3659
|
+
|
|
3660
|
+
if isinstance(
|
|
3661
|
+
legacy,
|
|
3662
|
+
Mapping,
|
|
3663
|
+
):
|
|
3664
|
+
history.append(
|
|
3665
|
+
dict(legacy)
|
|
3666
|
+
)
|
|
3667
|
+
|
|
3668
|
+
elif isinstance(
|
|
3669
|
+
history_raw,
|
|
3670
|
+
list,
|
|
3671
|
+
):
|
|
3672
|
+
history = [
|
|
3673
|
+
dict(item)
|
|
3674
|
+
if isinstance(item, Mapping)
|
|
3675
|
+
else item
|
|
3676
|
+
for item in history_raw
|
|
3677
|
+
]
|
|
3678
|
+
|
|
3679
|
+
else:
|
|
3680
|
+
raise SettlementStoreError(
|
|
3681
|
+
"payment authorization history is invalid"
|
|
3682
|
+
)
|
|
3683
|
+
|
|
3684
|
+
nonce = envelope[
|
|
3685
|
+
"nonce"
|
|
3686
|
+
]
|
|
3687
|
+
|
|
3688
|
+
for existing in history:
|
|
3689
|
+
if not isinstance(
|
|
3690
|
+
existing,
|
|
3691
|
+
Mapping,
|
|
3692
|
+
):
|
|
3693
|
+
raise SettlementStoreError(
|
|
3694
|
+
"payment authorization history "
|
|
3695
|
+
"contains invalid entry"
|
|
3696
|
+
)
|
|
3697
|
+
|
|
3698
|
+
if str(
|
|
3699
|
+
existing.get(
|
|
3700
|
+
"nonce",
|
|
3701
|
+
"",
|
|
3702
|
+
)
|
|
3703
|
+
) != nonce:
|
|
3704
|
+
continue
|
|
3705
|
+
|
|
3706
|
+
normalized_existing = {
|
|
3707
|
+
str(key): str(value)
|
|
3708
|
+
for key, value in existing.items()
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
if normalized_existing != envelope:
|
|
3712
|
+
raise SettlementStoreError(
|
|
3713
|
+
"conflicting payment authorization "
|
|
3714
|
+
"envelope for existing nonce"
|
|
3715
|
+
)
|
|
3716
|
+
|
|
3717
|
+
# Idempotent replay of the exact same envelope.
|
|
3718
|
+
entry[
|
|
3719
|
+
"authorization"
|
|
3720
|
+
] = envelope
|
|
3721
|
+
|
|
3722
|
+
entry[
|
|
3723
|
+
"authorizations"
|
|
3724
|
+
] = history
|
|
3725
|
+
|
|
3726
|
+
store[
|
|
3727
|
+
"entries"
|
|
3728
|
+
][intent_key] = entry
|
|
3729
|
+
|
|
3730
|
+
return True, False
|
|
3731
|
+
|
|
3732
|
+
history.append(
|
|
3733
|
+
envelope
|
|
3734
|
+
)
|
|
3735
|
+
|
|
3736
|
+
entry[
|
|
3737
|
+
"authorizations"
|
|
3738
|
+
] = history
|
|
3739
|
+
|
|
3740
|
+
# Backwards-compatible alias for callers that need
|
|
3741
|
+
# the most recently created authorization.
|
|
3742
|
+
entry[
|
|
3743
|
+
"authorization"
|
|
3744
|
+
] = envelope
|
|
3745
|
+
|
|
3746
|
+
entry[
|
|
3747
|
+
"updated_at"
|
|
3748
|
+
] = now
|
|
3749
|
+
|
|
3750
|
+
store[
|
|
3751
|
+
"entries"
|
|
3752
|
+
][intent_key] = entry
|
|
3753
|
+
|
|
3754
|
+
return True, True
|
|
3755
|
+
|
|
3756
|
+
return _mutate_pending_store(
|
|
3757
|
+
mutate
|
|
3758
|
+
)
|
|
3759
|
+
|
|
3760
|
+
|
|
3761
|
+
def _release_submission_unknown_after_terminal_failure(
|
|
3762
|
+
logical_intent_id: str,
|
|
3763
|
+
) -> bool:
|
|
3764
|
+
"""Release exposure only after an explicit terminal settlement failure."""
|
|
3765
|
+
intent_key = _persistent_intent_key(
|
|
3766
|
+
logical_intent_id
|
|
3767
|
+
)
|
|
3768
|
+
|
|
3769
|
+
def mutate(
|
|
3770
|
+
store: dict[str, Any],
|
|
3771
|
+
):
|
|
3772
|
+
raw = store[
|
|
3773
|
+
"entries"
|
|
3774
|
+
].get(intent_key)
|
|
3775
|
+
|
|
3776
|
+
if not isinstance(
|
|
3777
|
+
raw,
|
|
3778
|
+
Mapping,
|
|
3779
|
+
):
|
|
3780
|
+
return False, False
|
|
3781
|
+
|
|
3782
|
+
if raw.get("state") != "submission_unknown":
|
|
3783
|
+
return False, False
|
|
3784
|
+
|
|
3785
|
+
del store[
|
|
3786
|
+
"entries"
|
|
3787
|
+
][intent_key]
|
|
3788
|
+
|
|
3789
|
+
return True, True
|
|
3790
|
+
|
|
3791
|
+
return _mutate_pending_store(
|
|
3792
|
+
mutate
|
|
3793
|
+
)
|
|
3794
|
+
|
|
3795
|
+
|
|
3796
|
+
def _release_aggregate_budget_reservation(
|
|
3797
|
+
logical_intent_id: str,
|
|
3798
|
+
) -> bool:
|
|
3799
|
+
"""Release only a still-unbroadcast reservation."""
|
|
3800
|
+
|
|
3801
|
+
intent_key = _persistent_intent_key(
|
|
3802
|
+
logical_intent_id
|
|
3803
|
+
)
|
|
3804
|
+
|
|
3805
|
+
def mutate(
|
|
3806
|
+
store: dict[str, Any],
|
|
3807
|
+
):
|
|
3808
|
+
raw = store[
|
|
3809
|
+
"entries"
|
|
3810
|
+
].get(intent_key)
|
|
3811
|
+
|
|
3812
|
+
if not isinstance(
|
|
3813
|
+
raw,
|
|
3814
|
+
Mapping,
|
|
3815
|
+
):
|
|
3816
|
+
return False, False
|
|
3817
|
+
|
|
3818
|
+
if raw.get("state") != "reserved":
|
|
3819
|
+
return False, False
|
|
3820
|
+
|
|
3821
|
+
del store[
|
|
3822
|
+
"entries"
|
|
3823
|
+
][intent_key]
|
|
3824
|
+
|
|
3825
|
+
return True, True
|
|
3826
|
+
|
|
3827
|
+
return _mutate_pending_store(
|
|
3828
|
+
mutate
|
|
3829
|
+
)
|
|
3830
|
+
|
|
3831
|
+
|
|
3832
|
+
def _record_pending_settlement(
|
|
3833
|
+
resource: str,
|
|
3834
|
+
requirement: Any,
|
|
3835
|
+
transaction: str,
|
|
3836
|
+
network: str = BASE_SEPOLIA,
|
|
3837
|
+
*,
|
|
3838
|
+
intent_id: str | None = None,
|
|
3839
|
+
agent_id: str | None = None,
|
|
3840
|
+
session_id: str | None = None,
|
|
3841
|
+
) -> dict[str, Any]:
|
|
3842
|
+
logical_intent_id = _resolve_payment_intent_id(
|
|
3843
|
+
intent_id,
|
|
3844
|
+
resource,
|
|
3845
|
+
requirement,
|
|
3846
|
+
)
|
|
3847
|
+
intent_key = _persistent_intent_key(logical_intent_id)
|
|
3848
|
+
|
|
3849
|
+
normalized_agent_id = None
|
|
3850
|
+
|
|
3851
|
+
if agent_id is not None:
|
|
3852
|
+
from qruntime.spend import (
|
|
3853
|
+
_normalize_agent_id,
|
|
3854
|
+
)
|
|
3855
|
+
|
|
3856
|
+
normalized_agent_id = (
|
|
3857
|
+
_normalize_agent_id(
|
|
3858
|
+
agent_id
|
|
3859
|
+
)
|
|
3860
|
+
)
|
|
3861
|
+
|
|
3862
|
+
normalized_session_id = None
|
|
3863
|
+
|
|
3864
|
+
if session_id is not None:
|
|
3865
|
+
from qruntime.spend import (
|
|
3866
|
+
_normalize_session_id,
|
|
3867
|
+
)
|
|
3868
|
+
|
|
3869
|
+
normalized_session_id = (
|
|
3870
|
+
_normalize_session_id(
|
|
3871
|
+
session_id
|
|
3872
|
+
)
|
|
3873
|
+
)
|
|
3874
|
+
|
|
3875
|
+
fingerprint = requirement.fingerprint()
|
|
3876
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
3877
|
+
|
|
3878
|
+
entry = {
|
|
3879
|
+
"intent_id": logical_intent_id,
|
|
3880
|
+
"intent_key": intent_key,
|
|
3881
|
+
"resource": resource,
|
|
3882
|
+
"agent_id": normalized_agent_id,
|
|
3883
|
+
"session_id": normalized_session_id,
|
|
3884
|
+
"quote_fingerprint": fingerprint,
|
|
3885
|
+
"requirement": requirement.canonical_dict(),
|
|
3886
|
+
"transaction": transaction,
|
|
3887
|
+
"network": network,
|
|
3888
|
+
"state": "pending",
|
|
3889
|
+
"first_seen_at": now,
|
|
3890
|
+
"updated_at": now,
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
def mutate(store: dict[str, Any]):
|
|
3894
|
+
pending_entry = dict(entry)
|
|
3895
|
+
|
|
3896
|
+
previous = store[
|
|
3897
|
+
"entries"
|
|
3898
|
+
].get(intent_key)
|
|
3899
|
+
|
|
3900
|
+
if (
|
|
3901
|
+
isinstance(
|
|
3902
|
+
previous,
|
|
3903
|
+
Mapping,
|
|
3904
|
+
)
|
|
3905
|
+
and previous.get("state")
|
|
3906
|
+
== "reserved"
|
|
3907
|
+
and isinstance(
|
|
3908
|
+
previous.get("reservation"),
|
|
3909
|
+
Mapping,
|
|
3910
|
+
)
|
|
3911
|
+
):
|
|
3912
|
+
pending_entry[
|
|
3913
|
+
"reservation"
|
|
3914
|
+
] = dict(
|
|
3915
|
+
previous["reservation"]
|
|
3916
|
+
)
|
|
3917
|
+
|
|
3918
|
+
previous_agent_id = (
|
|
3919
|
+
previous.get("agent_id")
|
|
3920
|
+
)
|
|
3921
|
+
|
|
3922
|
+
if (
|
|
3923
|
+
previous_agent_id is not None
|
|
3924
|
+
and normalized_agent_id is not None
|
|
3925
|
+
and previous_agent_id
|
|
3926
|
+
!= normalized_agent_id
|
|
3927
|
+
):
|
|
3928
|
+
raise SettlementStoreError(
|
|
3929
|
+
"reserved payment agent identity "
|
|
3930
|
+
"does not match settlement agent"
|
|
3931
|
+
)
|
|
3932
|
+
|
|
3933
|
+
if previous_agent_id is not None:
|
|
3934
|
+
pending_entry[
|
|
3935
|
+
"agent_id"
|
|
3936
|
+
] = previous_agent_id
|
|
3937
|
+
|
|
3938
|
+
previous_session_id = (
|
|
3939
|
+
previous.get("session_id")
|
|
3940
|
+
)
|
|
3941
|
+
|
|
3942
|
+
if (
|
|
3943
|
+
previous_session_id is not None
|
|
3944
|
+
and normalized_session_id is not None
|
|
3945
|
+
and previous_session_id
|
|
3946
|
+
!= normalized_session_id
|
|
3947
|
+
):
|
|
3948
|
+
raise SettlementStoreError(
|
|
3949
|
+
"reserved payment session identity "
|
|
3950
|
+
"does not match settlement session"
|
|
3951
|
+
)
|
|
3952
|
+
|
|
3953
|
+
if previous_session_id is not None:
|
|
3954
|
+
pending_entry[
|
|
3955
|
+
"session_id"
|
|
3956
|
+
] = previous_session_id
|
|
3957
|
+
|
|
3958
|
+
store[
|
|
3959
|
+
"entries"
|
|
3960
|
+
][intent_key] = pending_entry
|
|
3961
|
+
|
|
3962
|
+
return pending_entry, True
|
|
3963
|
+
|
|
3964
|
+
return _mutate_pending_store(mutate)
|
|
3965
|
+
|
|
3966
|
+
|
|
3967
|
+
def _assert_no_persistent_payment_conflict(
|
|
3968
|
+
resource: str,
|
|
3969
|
+
requirement: Any,
|
|
3970
|
+
*,
|
|
3971
|
+
intent_id: str | None = None,
|
|
3972
|
+
) -> None:
|
|
3973
|
+
logical_intent_id = _resolve_payment_intent_id(
|
|
3974
|
+
intent_id,
|
|
3975
|
+
resource,
|
|
3976
|
+
requirement,
|
|
3977
|
+
)
|
|
3978
|
+
|
|
3979
|
+
intent_key = _persistent_intent_key(
|
|
3980
|
+
logical_intent_id
|
|
3981
|
+
)
|
|
3982
|
+
|
|
3983
|
+
def inspect(
|
|
3984
|
+
store: Mapping[str, Any],
|
|
3985
|
+
) -> tuple[None, bool]:
|
|
3986
|
+
entry = store[
|
|
3987
|
+
"entries"
|
|
3988
|
+
].get(
|
|
3989
|
+
intent_key
|
|
3990
|
+
)
|
|
3991
|
+
|
|
3992
|
+
if not isinstance(
|
|
3993
|
+
entry,
|
|
3994
|
+
Mapping,
|
|
3995
|
+
):
|
|
3996
|
+
return None, False
|
|
3997
|
+
|
|
3998
|
+
stored_resource = str(
|
|
3999
|
+
entry.get(
|
|
4000
|
+
"resource",
|
|
4001
|
+
"",
|
|
4002
|
+
)
|
|
4003
|
+
)
|
|
4004
|
+
|
|
4005
|
+
if (
|
|
4006
|
+
stored_resource
|
|
4007
|
+
and stored_resource
|
|
4008
|
+
!= resource
|
|
4009
|
+
):
|
|
4010
|
+
raise IntentResourceMismatchError(
|
|
4011
|
+
"preflight refused payment: intent_id is already bound "
|
|
4012
|
+
"to a different resource"
|
|
4013
|
+
)
|
|
4014
|
+
|
|
4015
|
+
state = str(
|
|
4016
|
+
entry.get(
|
|
4017
|
+
"state",
|
|
4018
|
+
"",
|
|
4019
|
+
)
|
|
4020
|
+
)
|
|
4021
|
+
|
|
4022
|
+
if state == "reserved":
|
|
4023
|
+
raise IntentInFlightError(
|
|
4024
|
+
"preflight refused payment: durable budget "
|
|
4025
|
+
f"reservation exists for intent {logical_intent_id}; "
|
|
4026
|
+
"reservation must be explicitly released or "
|
|
4027
|
+
"advanced before retry"
|
|
4028
|
+
)
|
|
4029
|
+
|
|
4030
|
+
if state == "submission_unknown":
|
|
4031
|
+
raise IntentInFlightError(
|
|
4032
|
+
"preflight refused payment: submission outcome "
|
|
4033
|
+
"is unknown for intent "
|
|
4034
|
+
f"{logical_intent_id}; economic exposure must "
|
|
4035
|
+
"be reconciled before retry"
|
|
4036
|
+
)
|
|
4037
|
+
|
|
4038
|
+
if state == "pending":
|
|
4039
|
+
raise SettlementPendingError(
|
|
4040
|
+
"preflight refused payment: unresolved prior settlement "
|
|
4041
|
+
f"{entry.get('transaction')} exists for intent "
|
|
4042
|
+
f"{logical_intent_id}; reconcile before retry",
|
|
4043
|
+
transaction=(
|
|
4044
|
+
str(
|
|
4045
|
+
entry.get(
|
|
4046
|
+
"transaction"
|
|
4047
|
+
)
|
|
4048
|
+
)
|
|
4049
|
+
if entry.get(
|
|
4050
|
+
"transaction"
|
|
4051
|
+
)
|
|
4052
|
+
else None
|
|
4053
|
+
),
|
|
4054
|
+
network=(
|
|
4055
|
+
str(
|
|
4056
|
+
entry.get(
|
|
4057
|
+
"network"
|
|
4058
|
+
)
|
|
4059
|
+
)
|
|
4060
|
+
if entry.get(
|
|
4061
|
+
"network"
|
|
4062
|
+
)
|
|
4063
|
+
else None
|
|
4064
|
+
),
|
|
4065
|
+
)
|
|
4066
|
+
|
|
4067
|
+
if state == "resolved_confirmed":
|
|
4068
|
+
raise DuplicateIntentError(
|
|
4069
|
+
"preflight refused payment: prior settlement for intent "
|
|
4070
|
+
f"{logical_intent_id} is already confirmed; "
|
|
4071
|
+
"refusing duplicate economic effect"
|
|
4072
|
+
)
|
|
4073
|
+
|
|
4074
|
+
return None, False
|
|
4075
|
+
|
|
4076
|
+
_mutate_pending_store(
|
|
4077
|
+
inspect
|
|
4078
|
+
)
|
|
4079
|
+
|
|
4080
|
+
|
|
4081
|
+
|
|
4082
|
+
|
|
4083
|
+
|
|
4084
|
+
def _record_confirmed_settlement(
|
|
4085
|
+
resource: str,
|
|
4086
|
+
requirement: Any,
|
|
4087
|
+
transaction: str,
|
|
4088
|
+
network: str = BASE_SEPOLIA,
|
|
4089
|
+
*,
|
|
4090
|
+
intent_id: str | None = None,
|
|
4091
|
+
agent_id: str | None = None,
|
|
4092
|
+
session_id: str | None = None,
|
|
4093
|
+
) -> None:
|
|
4094
|
+
"""Durably tombstone a successful economic effect.
|
|
4095
|
+
|
|
4096
|
+
Write pending first so a crash between writes remains fail-closed.
|
|
4097
|
+
"""
|
|
4098
|
+
_record_pending_settlement(
|
|
4099
|
+
resource,
|
|
4100
|
+
requirement,
|
|
4101
|
+
transaction,
|
|
4102
|
+
network,
|
|
4103
|
+
intent_id=intent_id,
|
|
4104
|
+
agent_id=agent_id,
|
|
4105
|
+
session_id=session_id,
|
|
4106
|
+
)
|
|
4107
|
+
|
|
4108
|
+
if not _update_persistent_reconciliation(
|
|
4109
|
+
transaction,
|
|
4110
|
+
"confirmed",
|
|
4111
|
+
):
|
|
4112
|
+
raise SettlementStoreError(
|
|
4113
|
+
"successful settlement could not be marked confirmed "
|
|
4114
|
+
"in persistent state"
|
|
4115
|
+
)
|
|
4116
|
+
|
|
4117
|
+
|
|
4118
|
+
def _update_persistent_reconciliation(
|
|
4119
|
+
transaction: str,
|
|
4120
|
+
outcome: str,
|
|
4121
|
+
) -> bool:
|
|
4122
|
+
if outcome not in {"confirmed", "failed"}:
|
|
4123
|
+
return False
|
|
4124
|
+
|
|
4125
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
4126
|
+
|
|
4127
|
+
def mutate(store: dict[str, Any]):
|
|
4128
|
+
changed = False
|
|
4129
|
+
|
|
4130
|
+
for key, raw_entry in list(store["entries"].items()):
|
|
4131
|
+
if not isinstance(raw_entry, Mapping):
|
|
4132
|
+
continue
|
|
4133
|
+
|
|
4134
|
+
if (
|
|
4135
|
+
str(raw_entry.get("transaction", "")).lower()
|
|
4136
|
+
!= transaction.lower()
|
|
4137
|
+
):
|
|
4138
|
+
continue
|
|
4139
|
+
|
|
4140
|
+
entry = dict(raw_entry)
|
|
4141
|
+
entry["state"] = (
|
|
4142
|
+
"resolved_confirmed"
|
|
4143
|
+
if outcome == "confirmed"
|
|
4144
|
+
else "resolved_failed"
|
|
4145
|
+
)
|
|
4146
|
+
entry["reconciliation_outcome"] = outcome
|
|
4147
|
+
entry["updated_at"] = now
|
|
4148
|
+
|
|
4149
|
+
store["entries"][key] = entry
|
|
4150
|
+
changed = True
|
|
4151
|
+
|
|
4152
|
+
return changed, changed
|
|
4153
|
+
|
|
4154
|
+
return _mutate_pending_store(mutate)
|
|
4155
|
+
|
|
4156
|
+
|
|
4157
|
+
def _fetch_base_sepolia_transaction(
|
|
4158
|
+
transaction: str,
|
|
4159
|
+
timeout: float,
|
|
4160
|
+
) -> dict[str, Any] | None:
|
|
4161
|
+
"""Read one Base-Sepolia transaction from Blockscout.
|
|
4162
|
+
|
|
4163
|
+
None means "not currently found", never terminal failure.
|
|
4164
|
+
"""
|
|
4165
|
+
from urllib.error import HTTPError, URLError
|
|
4166
|
+
from urllib.request import Request, urlopen
|
|
4167
|
+
|
|
4168
|
+
endpoint = (
|
|
4169
|
+
"https://base-sepolia.blockscout.com/api/v2/transactions/"
|
|
4170
|
+
f"{transaction}"
|
|
4171
|
+
)
|
|
4172
|
+
request = Request(
|
|
4173
|
+
endpoint,
|
|
4174
|
+
headers={
|
|
4175
|
+
"Accept": "application/json",
|
|
4176
|
+
"User-Agent": "q-runtime/0.6",
|
|
4177
|
+
},
|
|
4178
|
+
)
|
|
4179
|
+
|
|
4180
|
+
try:
|
|
4181
|
+
with urlopen(request, timeout=timeout) as response:
|
|
4182
|
+
raw = json.loads(response.read().decode("utf-8"))
|
|
4183
|
+
except HTTPError as exc:
|
|
4184
|
+
if exc.code == 404:
|
|
4185
|
+
return None
|
|
4186
|
+
raise ReconciliationError(
|
|
4187
|
+
f"Blockscout reconciliation HTTP {exc.code}"
|
|
4188
|
+
) from exc
|
|
4189
|
+
except (URLError, TimeoutError) as exc:
|
|
4190
|
+
raise ReconciliationError(
|
|
4191
|
+
f"Blockscout reconciliation unavailable: {exc}"
|
|
4192
|
+
) from exc
|
|
4193
|
+
|
|
4194
|
+
if not isinstance(raw, Mapping):
|
|
4195
|
+
raise ReconciliationError(
|
|
4196
|
+
"Blockscout reconciliation returned a non-object response"
|
|
4197
|
+
)
|
|
4198
|
+
|
|
4199
|
+
return dict(raw)
|
|
4200
|
+
|
|
4201
|
+
|
|
4202
|
+
def reconcile_testnet_transaction(
|
|
4203
|
+
transaction: str,
|
|
4204
|
+
*,
|
|
4205
|
+
out: str | Path,
|
|
4206
|
+
timeout: float = 15.0,
|
|
4207
|
+
) -> dict[str, Any]:
|
|
4208
|
+
"""Reconcile an already-broadcast Base-Sepolia transaction.
|
|
4209
|
+
|
|
4210
|
+
This function is read-only. It never signs, broadcasts, or retries payment.
|
|
4211
|
+
"""
|
|
4212
|
+
import re
|
|
4213
|
+
|
|
4214
|
+
tx = transaction.strip()
|
|
4215
|
+
|
|
4216
|
+
if not re.fullmatch(r"0x[a-fA-F0-9]{64}", tx):
|
|
4217
|
+
raise ValueError(
|
|
4218
|
+
"transaction must be a 0x-prefixed 32-byte hash"
|
|
4219
|
+
)
|
|
4220
|
+
|
|
4221
|
+
if timeout <= 0:
|
|
4222
|
+
raise ValueError("timeout must be positive")
|
|
4223
|
+
|
|
4224
|
+
info = _fetch_base_sepolia_transaction(tx, timeout)
|
|
4225
|
+
|
|
4226
|
+
if info is None:
|
|
4227
|
+
outcome = "pending"
|
|
4228
|
+
reason = (
|
|
4229
|
+
"transaction is not currently visible; "
|
|
4230
|
+
"absence is not treated as terminal"
|
|
4231
|
+
)
|
|
4232
|
+
status = None
|
|
4233
|
+
else:
|
|
4234
|
+
status = str(info.get("status", "")).lower()
|
|
4235
|
+
|
|
4236
|
+
if status == "ok":
|
|
4237
|
+
outcome = "confirmed"
|
|
4238
|
+
reason = "transaction confirmed successfully on Base Sepolia"
|
|
4239
|
+
elif status == "error":
|
|
4240
|
+
outcome = "failed"
|
|
4241
|
+
reason = "transaction confirmed with failure on Base Sepolia"
|
|
4242
|
+
else:
|
|
4243
|
+
outcome = "pending"
|
|
4244
|
+
reason = (
|
|
4245
|
+
"transaction exists but does not yet have a terminal status"
|
|
4246
|
+
)
|
|
4247
|
+
|
|
4248
|
+
persistent_state_updated = _update_persistent_reconciliation(
|
|
4249
|
+
tx,
|
|
4250
|
+
outcome,
|
|
4251
|
+
)
|
|
4252
|
+
|
|
4253
|
+
report = {
|
|
4254
|
+
"format": "qruntime-x402-reconciliation-v1",
|
|
4255
|
+
"persistent_state_updated": persistent_state_updated,
|
|
4256
|
+
"checked_at": datetime.now(timezone.utc).isoformat(),
|
|
4257
|
+
"network": BASE_SEPOLIA,
|
|
4258
|
+
"transaction": tx,
|
|
4259
|
+
"outcome": outcome,
|
|
4260
|
+
"terminal": outcome in {"confirmed", "failed"},
|
|
4261
|
+
"reason": reason,
|
|
4262
|
+
"source": "base-sepolia.blockscout.com",
|
|
4263
|
+
"chain": {
|
|
4264
|
+
"status": status,
|
|
4265
|
+
"block_number": None if info is None else info.get("block_number"),
|
|
4266
|
+
"confirmations": None if info is None else info.get("confirmations"),
|
|
4267
|
+
"method": None if info is None else info.get("method"),
|
|
4268
|
+
},
|
|
4269
|
+
}
|
|
4270
|
+
|
|
4271
|
+
destination = Path(out)
|
|
4272
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
4273
|
+
destination.write_text(
|
|
4274
|
+
json.dumps(report, indent=2, ensure_ascii=False),
|
|
4275
|
+
encoding="utf-8",
|
|
4276
|
+
)
|
|
4277
|
+
|
|
4278
|
+
return report
|
|
4279
|
+
|
|
4280
|
+
|
|
4281
|
+
def run_local_testnet_payment(
|
|
4282
|
+
url: str,
|
|
4283
|
+
*,
|
|
4284
|
+
max_amount_atomic: int,
|
|
4285
|
+
out: str | Path,
|
|
4286
|
+
intent_id: str | None = None,
|
|
4287
|
+
key_env: str = DEFAULT_KEY_ENV,
|
|
4288
|
+
timeout: float = 30.0,
|
|
4289
|
+
policy: Policy | None = None,
|
|
4290
|
+
budget: Any | None = None,
|
|
4291
|
+
agent_id: str | None = None,
|
|
4292
|
+
session_id: str | None = None,
|
|
4293
|
+
) -> dict[str, Any]:
|
|
4294
|
+
"""Run one controlled Base-Sepolia x402 payment against localhost.
|
|
4295
|
+
|
|
4296
|
+
Safety properties:
|
|
4297
|
+
- only http(s)://127.0.0.1 or localhost is accepted;
|
|
4298
|
+
- only exact / eip155:84532 is accepted;
|
|
4299
|
+
- preflight amount must be <= max_amount_atomic;
|
|
4300
|
+
- private key is read from an environment variable and never serialized.
|
|
4301
|
+
"""
|
|
4302
|
+
parsed_url = urlparse(url)
|
|
4303
|
+
if parsed_url.scheme not in {"http", "https"} or parsed_url.hostname not in {"127.0.0.1", "localhost"}:
|
|
4304
|
+
raise ValueError("live-testnet is localhost-only; use the controlled Q Runtime demo server")
|
|
4305
|
+
if max_amount_atomic <= 0:
|
|
4306
|
+
raise ValueError("--max-amount-atomic must be positive")
|
|
4307
|
+
|
|
4308
|
+
try:
|
|
4309
|
+
capture = capture_payment_required(url, timeout=min(timeout, 15.0))
|
|
4310
|
+
except X402CaptureError as exc:
|
|
4311
|
+
raise RuntimeError(f"preflight 402 capture failed: {exc}") from exc
|
|
4312
|
+
|
|
4313
|
+
candidates = [
|
|
4314
|
+
req for req in capture.payment_required.accepts
|
|
4315
|
+
if req.scheme == "exact" and req.network == BASE_SEPOLIA and req.amount_atomic <= max_amount_atomic
|
|
4316
|
+
]
|
|
4317
|
+
if not candidates:
|
|
4318
|
+
offered = [
|
|
4319
|
+
{"scheme": r.scheme, "network": r.network, "amount": r.amount_atomic}
|
|
4320
|
+
for r in capture.payment_required.accepts
|
|
4321
|
+
]
|
|
4322
|
+
raise PaymentRequirementRejectedError(
|
|
4323
|
+
f"preflight refused payment: no exact/{BASE_SEPOLIA} requirement within cap "
|
|
4324
|
+
f"{max_amount_atomic}; offered={offered}"
|
|
4325
|
+
)
|
|
4326
|
+
selected = candidates[0]
|
|
4327
|
+
if capture.payment_required.resource_url != url:
|
|
4328
|
+
raise PaymentRequirementMismatchError(
|
|
4329
|
+
"preflight refused payment: PAYMENT-REQUIRED resource.url does not exactly match controlled request URL"
|
|
4330
|
+
)
|
|
4331
|
+
|
|
4332
|
+
if policy is not None:
|
|
4333
|
+
decision = evaluate_policy(
|
|
4334
|
+
policy,
|
|
4335
|
+
amount_atomic=selected.amount_atomic,
|
|
4336
|
+
origin=url,
|
|
4337
|
+
asset=selected.asset,
|
|
4338
|
+
)
|
|
4339
|
+
|
|
4340
|
+
if not decision.allowed:
|
|
4341
|
+
raise PolicyDeniedError(
|
|
4342
|
+
decision
|
|
4343
|
+
)
|
|
4344
|
+
|
|
4345
|
+
logical_intent_id = _resolve_payment_intent_id(
|
|
4346
|
+
intent_id,
|
|
4347
|
+
url,
|
|
4348
|
+
selected,
|
|
4349
|
+
)
|
|
4350
|
+
_assert_no_persistent_payment_conflict(
|
|
4351
|
+
url,
|
|
4352
|
+
selected,
|
|
4353
|
+
intent_id=logical_intent_id,
|
|
4354
|
+
)
|
|
4355
|
+
|
|
4356
|
+
intent_claim = _acquire_payment_intent_claim(
|
|
4357
|
+
logical_intent_id,
|
|
4358
|
+
url,
|
|
4359
|
+
)
|
|
4360
|
+
|
|
4361
|
+
budget_reserved = False
|
|
4362
|
+
|
|
4363
|
+
if budget is not None:
|
|
4364
|
+
try:
|
|
4365
|
+
from qruntime.budget import (
|
|
4366
|
+
BudgetSet,
|
|
4367
|
+
)
|
|
4368
|
+
|
|
4369
|
+
if isinstance(
|
|
4370
|
+
budget,
|
|
4371
|
+
BudgetSet,
|
|
4372
|
+
):
|
|
4373
|
+
budget_decision = (
|
|
4374
|
+
_reserve_budget_set(
|
|
4375
|
+
logical_intent_id,
|
|
4376
|
+
url,
|
|
4377
|
+
selected,
|
|
4378
|
+
budget,
|
|
4379
|
+
agent_id=agent_id,
|
|
4380
|
+
session_id=session_id,
|
|
4381
|
+
)
|
|
4382
|
+
)
|
|
4383
|
+
else:
|
|
4384
|
+
budget_decision = (
|
|
4385
|
+
_reserve_aggregate_budget(
|
|
4386
|
+
logical_intent_id,
|
|
4387
|
+
url,
|
|
4388
|
+
selected,
|
|
4389
|
+
budget,
|
|
4390
|
+
agent_id=agent_id,
|
|
4391
|
+
session_id=session_id,
|
|
4392
|
+
)
|
|
4393
|
+
)
|
|
4394
|
+
except Exception:
|
|
4395
|
+
_release_payment_intent_claim(
|
|
4396
|
+
intent_claim
|
|
4397
|
+
)
|
|
4398
|
+
raise
|
|
4399
|
+
|
|
4400
|
+
if not budget_decision.allowed:
|
|
4401
|
+
_release_payment_intent_claim(
|
|
4402
|
+
intent_claim
|
|
4403
|
+
)
|
|
4404
|
+
|
|
4405
|
+
from qruntime.exceptions import (
|
|
4406
|
+
BudgetDeniedError,
|
|
4407
|
+
)
|
|
4408
|
+
|
|
4409
|
+
denied_decision = getattr(
|
|
4410
|
+
budget_decision,
|
|
4411
|
+
"first_denied",
|
|
4412
|
+
None,
|
|
4413
|
+
)
|
|
4414
|
+
|
|
4415
|
+
if denied_decision is None:
|
|
4416
|
+
denied_decision = (
|
|
4417
|
+
budget_decision
|
|
4418
|
+
)
|
|
4419
|
+
|
|
4420
|
+
raise BudgetDeniedError(
|
|
4421
|
+
denied_decision
|
|
4422
|
+
)
|
|
4423
|
+
|
|
4424
|
+
budget_reserved = True
|
|
4425
|
+
|
|
4426
|
+
try:
|
|
4427
|
+
private_key = os.environ.get(key_env, "").strip()
|
|
4428
|
+
if not private_key:
|
|
4429
|
+
raise RuntimeError(
|
|
4430
|
+
f"missing testnet private key environment variable: {key_env}"
|
|
4431
|
+
)
|
|
4432
|
+
|
|
4433
|
+
try:
|
|
4434
|
+
from eth_account import Account
|
|
4435
|
+
from x402 import AbortResult, x402ClientSync, max_amount
|
|
4436
|
+
from x402.http.clients import x402_requests
|
|
4437
|
+
from x402.mechanisms.evm import EthAccountSigner
|
|
4438
|
+
from x402.mechanisms.evm.exact.register import register_exact_evm_client
|
|
4439
|
+
except ImportError as exc: # pragma: no cover
|
|
4440
|
+
raise RuntimeError(
|
|
4441
|
+
"official x402 client dependencies are missing. Install with: "
|
|
4442
|
+
'python -m pip install -e ".[official-x402]"'
|
|
4443
|
+
) from exc
|
|
4444
|
+
|
|
4445
|
+
account = Account.from_key(private_key)
|
|
4446
|
+
client = x402ClientSync()
|
|
4447
|
+
client.register_policy(max_amount(max_amount_atomic))
|
|
4448
|
+
client.on_before_payment_creation(
|
|
4449
|
+
_make_live_quote_binding_hook(selected, AbortResult)
|
|
4450
|
+
)
|
|
4451
|
+
client.on_after_payment_creation(
|
|
4452
|
+
_make_payment_authorization_persistence_hook(
|
|
4453
|
+
logical_intent_id
|
|
4454
|
+
)
|
|
4455
|
+
)
|
|
4456
|
+
register_exact_evm_client(
|
|
4457
|
+
client,
|
|
4458
|
+
EthAccountSigner(account),
|
|
4459
|
+
)
|
|
4460
|
+
|
|
4461
|
+
except Exception:
|
|
4462
|
+
if budget_reserved:
|
|
4463
|
+
released = (
|
|
4464
|
+
_release_aggregate_budget_reservation(
|
|
4465
|
+
logical_intent_id
|
|
4466
|
+
)
|
|
4467
|
+
)
|
|
4468
|
+
|
|
4469
|
+
if not released:
|
|
4470
|
+
_release_payment_intent_claim(
|
|
4471
|
+
intent_claim
|
|
4472
|
+
)
|
|
4473
|
+
|
|
4474
|
+
raise SettlementStoreError(
|
|
4475
|
+
"pre-broadcast failure could not "
|
|
4476
|
+
"release durable budget reservation; "
|
|
4477
|
+
"refusing to free budget silently"
|
|
4478
|
+
)
|
|
4479
|
+
|
|
4480
|
+
_release_payment_intent_claim(
|
|
4481
|
+
intent_claim
|
|
4482
|
+
)
|
|
4483
|
+
raise
|
|
4484
|
+
|
|
4485
|
+
try:
|
|
4486
|
+
submission_marked = (
|
|
4487
|
+
_mark_payment_submission_unknown(
|
|
4488
|
+
logical_intent_id,
|
|
4489
|
+
url,
|
|
4490
|
+
selected,
|
|
4491
|
+
agent_id=agent_id,
|
|
4492
|
+
session_id=session_id,
|
|
4493
|
+
)
|
|
4494
|
+
)
|
|
4495
|
+
except Exception:
|
|
4496
|
+
if budget_reserved:
|
|
4497
|
+
released = (
|
|
4498
|
+
_release_aggregate_budget_reservation(
|
|
4499
|
+
logical_intent_id
|
|
4500
|
+
)
|
|
4501
|
+
)
|
|
4502
|
+
|
|
4503
|
+
if not released:
|
|
4504
|
+
_release_payment_intent_claim(
|
|
4505
|
+
intent_claim
|
|
4506
|
+
)
|
|
4507
|
+
|
|
4508
|
+
raise SettlementStoreError(
|
|
4509
|
+
"pre-handoff failure could not release "
|
|
4510
|
+
"durable budget reservation"
|
|
4511
|
+
)
|
|
4512
|
+
|
|
4513
|
+
_release_payment_intent_claim(
|
|
4514
|
+
intent_claim
|
|
4515
|
+
)
|
|
4516
|
+
raise
|
|
4517
|
+
|
|
4518
|
+
if not submission_marked:
|
|
4519
|
+
if budget_reserved:
|
|
4520
|
+
_release_aggregate_budget_reservation(
|
|
4521
|
+
logical_intent_id
|
|
4522
|
+
)
|
|
4523
|
+
|
|
4524
|
+
_release_payment_intent_claim(
|
|
4525
|
+
intent_claim
|
|
4526
|
+
)
|
|
4527
|
+
|
|
4528
|
+
raise SettlementStoreError(
|
|
4529
|
+
"payment submission state could not be "
|
|
4530
|
+
"persisted before network handoff"
|
|
4531
|
+
)
|
|
4532
|
+
|
|
4533
|
+
started = datetime.now(timezone.utc).isoformat()
|
|
4534
|
+
with x402_requests(client) as session:
|
|
4535
|
+
response = session.get(url, timeout=timeout)
|
|
4536
|
+
finished = datetime.now(timezone.utc).isoformat()
|
|
4537
|
+
|
|
4538
|
+
settlement_header = _header_ci(response.headers, "PAYMENT-RESPONSE", "X-PAYMENT-RESPONSE")
|
|
4539
|
+
if not settlement_header:
|
|
4540
|
+
raise SettlementReceiptError(
|
|
4541
|
+
f"paid request returned HTTP {response.status_code} without PAYMENT-RESPONSE settlement receipt"
|
|
4542
|
+
)
|
|
4543
|
+
settlement = parse_settlement_response(settlement_header)
|
|
4544
|
+
|
|
4545
|
+
if settlement.reconciliation_required:
|
|
4546
|
+
_record_pending_settlement(
|
|
4547
|
+
url,
|
|
4548
|
+
selected,
|
|
4549
|
+
settlement.transaction,
|
|
4550
|
+
settlement.network,
|
|
4551
|
+
intent_id=logical_intent_id,
|
|
4552
|
+
agent_id=agent_id,
|
|
4553
|
+
session_id=session_id,
|
|
4554
|
+
)
|
|
4555
|
+
raise SettlementPendingError(
|
|
4556
|
+
"settlement pending after broadcast; transaction "
|
|
4557
|
+
f"{settlement.transaction} was persisted and must be reconciled "
|
|
4558
|
+
"before retry",
|
|
4559
|
+
transaction=settlement.transaction,
|
|
4560
|
+
network=settlement.network,
|
|
4561
|
+
)
|
|
4562
|
+
|
|
4563
|
+
if settlement.success:
|
|
4564
|
+
if settlement.network != BASE_SEPOLIA:
|
|
4565
|
+
raise SettlementReceiptError(
|
|
4566
|
+
f"unexpected settlement network: {settlement.network}"
|
|
4567
|
+
)
|
|
4568
|
+
if not settlement.transaction:
|
|
4569
|
+
raise SettlementReceiptError(
|
|
4570
|
+
"successful settlement did not return a transaction hash"
|
|
4571
|
+
)
|
|
4572
|
+
|
|
4573
|
+
_record_confirmed_settlement(
|
|
4574
|
+
url,
|
|
4575
|
+
selected,
|
|
4576
|
+
settlement.transaction,
|
|
4577
|
+
settlement.network,
|
|
4578
|
+
intent_id=logical_intent_id,
|
|
4579
|
+
agent_id=agent_id,
|
|
4580
|
+
session_id=session_id,
|
|
4581
|
+
)
|
|
4582
|
+
_release_payment_intent_claim(intent_claim)
|
|
4583
|
+
|
|
4584
|
+
if not settlement.success:
|
|
4585
|
+
released = (
|
|
4586
|
+
_release_submission_unknown_after_terminal_failure(
|
|
4587
|
+
logical_intent_id
|
|
4588
|
+
)
|
|
4589
|
+
)
|
|
4590
|
+
|
|
4591
|
+
if not released:
|
|
4592
|
+
raise SettlementStoreError(
|
|
4593
|
+
"terminal settlement failure could not "
|
|
4594
|
+
"release durable economic exposure"
|
|
4595
|
+
)
|
|
4596
|
+
|
|
4597
|
+
_release_payment_intent_claim(
|
|
4598
|
+
intent_claim
|
|
4599
|
+
)
|
|
4600
|
+
|
|
4601
|
+
raise SettlementFailedError(
|
|
4602
|
+
f"settlement failed: {settlement.error_reason or 'unknown'}",
|
|
4603
|
+
reason=settlement.error_reason,
|
|
4604
|
+
)
|
|
4605
|
+
|
|
4606
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
4607
|
+
raise PaidResourceError(
|
|
4608
|
+
f"paid request did not succeed: HTTP {response.status_code}",
|
|
4609
|
+
status_code=response.status_code,
|
|
4610
|
+
transaction=settlement.transaction or None,
|
|
4611
|
+
network=settlement.network or None,
|
|
4612
|
+
)
|
|
4613
|
+
|
|
4614
|
+
report = {
|
|
4615
|
+
"format": "qruntime-x402-live-testnet-v1",
|
|
4616
|
+
"started_at": started,
|
|
4617
|
+
"finished_at": finished,
|
|
4618
|
+
"safety": {
|
|
4619
|
+
"localhost_only": True,
|
|
4620
|
+
"network": BASE_SEPOLIA,
|
|
4621
|
+
"scheme": "exact",
|
|
4622
|
+
"max_amount_atomic": max_amount_atomic,
|
|
4623
|
+
"private_key_recorded": False,
|
|
4624
|
+
},
|
|
4625
|
+
"payer": account.address,
|
|
4626
|
+
"request_url": url,
|
|
4627
|
+
"intent_id": logical_intent_id,
|
|
4628
|
+
"preflight": {
|
|
4629
|
+
"status": capture.status_code,
|
|
4630
|
+
"resource": capture.payment_required.resource_url,
|
|
4631
|
+
"quote_fingerprint": selected.fingerprint(),
|
|
4632
|
+
"amount_atomic": selected.amount_atomic,
|
|
4633
|
+
"asset": selected.asset,
|
|
4634
|
+
"pay_to": selected.pay_to,
|
|
4635
|
+
},
|
|
4636
|
+
"paid_response": {
|
|
4637
|
+
"http_status": response.status_code,
|
|
4638
|
+
"body_preview": response.text[:2000],
|
|
4639
|
+
},
|
|
4640
|
+
"settlement": {
|
|
4641
|
+
"success": settlement.success,
|
|
4642
|
+
"transaction": settlement.transaction,
|
|
4643
|
+
"network": settlement.network,
|
|
4644
|
+
"payer": settlement.payer,
|
|
4645
|
+
"amount_atomic": settlement.amount_atomic,
|
|
4646
|
+
},
|
|
4647
|
+
}
|
|
4648
|
+
destination = Path(out)
|
|
4649
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
4650
|
+
destination.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
4651
|
+
return report
|