q-runtime-core 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- q_runtime_core-0.9.0.dist-info/METADATA +299 -0
- q_runtime_core-0.9.0.dist-info/RECORD +36 -0
- q_runtime_core-0.9.0.dist-info/WHEEL +5 -0
- q_runtime_core-0.9.0.dist-info/entry_points.txt +2 -0
- q_runtime_core-0.9.0.dist-info/licenses/LICENSE +3 -0
- q_runtime_core-0.9.0.dist-info/top_level.txt +1 -0
- qruntime/__init__.py +76 -0
- qruntime/budget.py +295 -0
- qruntime/cli.py +594 -0
- qruntime/core/__init__.py +0 -0
- qruntime/core/invariants.py +67 -0
- qruntime/core/model.py +140 -0
- qruntime/core/transition.py +92 -0
- qruntime/exceptions.py +132 -0
- qruntime/integrations/__init__.py +0 -0
- qruntime/integrations/official_x402_demo.py +94 -0
- qruntime/loader.py +39 -0
- qruntime/policies/__init__.py +0 -0
- qruntime/policies/authority.py +1 -0
- qruntime/policies/budget.py +1 -0
- qruntime/policies/evidence.py +1 -0
- qruntime/policy.py +248 -0
- qruntime/rails/__init__.py +0 -0
- qruntime/rails/simulated.py +4 -0
- qruntime/rails/x402.py +284 -0
- qruntime/rails/x402_capture.py +194 -0
- qruntime/rails/x402_external.py +575 -0
- qruntime/rails/x402_external_suite.py +65 -0
- qruntime/rails/x402_fullcycle.py +4651 -0
- qruntime/rails/x402_verify.py +202 -0
- qruntime/results.py +112 -0
- qruntime/runtime.py +243 -0
- qruntime/spend.py +584 -0
- qruntime/verify/__init__.py +0 -0
- qruntime/verify/explorer.py +59 -0
- qruntime/verify/report.py +38 -0
qruntime/policy.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class PolicyViolation:
|
|
9
|
+
code: str
|
|
10
|
+
message: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class PolicyDecision:
|
|
15
|
+
allowed: bool
|
|
16
|
+
violations: tuple[PolicyViolation, ...] = ()
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def reason_codes(self) -> tuple[str, ...]:
|
|
20
|
+
return tuple(
|
|
21
|
+
violation.code
|
|
22
|
+
for violation in self.violations
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Policy:
|
|
28
|
+
max_amount_atomic: int | None = None
|
|
29
|
+
allowed_origins: tuple[str, ...] | None = None
|
|
30
|
+
allowed_assets: tuple[str, ...] | None = None
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if self.max_amount_atomic is not None:
|
|
34
|
+
if (
|
|
35
|
+
isinstance(self.max_amount_atomic, bool)
|
|
36
|
+
or not isinstance(
|
|
37
|
+
self.max_amount_atomic,
|
|
38
|
+
int,
|
|
39
|
+
)
|
|
40
|
+
or self.max_amount_atomic <= 0
|
|
41
|
+
):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"max_amount_atomic must be "
|
|
44
|
+
"a positive integer or None"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if self.allowed_origins is not None:
|
|
48
|
+
normalized_origins = tuple(
|
|
49
|
+
dict.fromkeys(
|
|
50
|
+
_normalize_origin(origin)
|
|
51
|
+
for origin in self.allowed_origins
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
object.__setattr__(
|
|
56
|
+
self,
|
|
57
|
+
"allowed_origins",
|
|
58
|
+
normalized_origins,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if self.allowed_assets is not None:
|
|
62
|
+
assets: list[str] = []
|
|
63
|
+
|
|
64
|
+
for asset in self.allowed_assets:
|
|
65
|
+
if not isinstance(asset, str):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"allowed_assets entries "
|
|
68
|
+
"must be strings"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
value = asset.strip()
|
|
72
|
+
|
|
73
|
+
if not value:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"allowed_assets entries "
|
|
76
|
+
"must not be empty"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if value not in assets:
|
|
80
|
+
assets.append(value)
|
|
81
|
+
|
|
82
|
+
object.__setattr__(
|
|
83
|
+
self,
|
|
84
|
+
"allowed_assets",
|
|
85
|
+
tuple(assets),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_origin(value: str) -> str:
|
|
90
|
+
if not isinstance(value, str):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"origin must be a string"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
candidate = value.strip()
|
|
96
|
+
|
|
97
|
+
if not candidate:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"origin must not be empty"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
parsed = urlsplit(candidate)
|
|
103
|
+
|
|
104
|
+
scheme = parsed.scheme.lower()
|
|
105
|
+
|
|
106
|
+
if scheme not in {"http", "https"}:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"origin must use http or https"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if parsed.hostname is None:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"origin must include a hostname"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
parsed.username is not None
|
|
118
|
+
or parsed.password is not None
|
|
119
|
+
):
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"origin must not contain credentials"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
hostname = parsed.hostname.lower()
|
|
125
|
+
|
|
126
|
+
if ":" in hostname:
|
|
127
|
+
hostname = f"[{hostname}]"
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
port = parsed.port
|
|
131
|
+
except ValueError as exc:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"origin contains an invalid port"
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
if (
|
|
137
|
+
port is None
|
|
138
|
+
or (scheme == "http" and port == 80)
|
|
139
|
+
or (scheme == "https" and port == 443)
|
|
140
|
+
):
|
|
141
|
+
return f"{scheme}://{hostname}"
|
|
142
|
+
|
|
143
|
+
return f"{scheme}://{hostname}:{port}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def evaluate_policy(
|
|
147
|
+
policy: Policy,
|
|
148
|
+
*,
|
|
149
|
+
amount_atomic: int,
|
|
150
|
+
origin: str | None = None,
|
|
151
|
+
asset: str | None = None,
|
|
152
|
+
) -> PolicyDecision:
|
|
153
|
+
if (
|
|
154
|
+
isinstance(amount_atomic, bool)
|
|
155
|
+
or not isinstance(amount_atomic, int)
|
|
156
|
+
or amount_atomic <= 0
|
|
157
|
+
):
|
|
158
|
+
raise ValueError(
|
|
159
|
+
"amount_atomic must be a positive integer"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
violations: list[PolicyViolation] = []
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
policy.max_amount_atomic is not None
|
|
166
|
+
and amount_atomic > policy.max_amount_atomic
|
|
167
|
+
):
|
|
168
|
+
violations.append(
|
|
169
|
+
PolicyViolation(
|
|
170
|
+
code="amount_exceeds_max",
|
|
171
|
+
message=(
|
|
172
|
+
f"requested amount {amount_atomic} "
|
|
173
|
+
"exceeds policy maximum "
|
|
174
|
+
f"{policy.max_amount_atomic}"
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if policy.allowed_origins is not None:
|
|
180
|
+
if origin is None:
|
|
181
|
+
violations.append(
|
|
182
|
+
PolicyViolation(
|
|
183
|
+
code="origin_missing",
|
|
184
|
+
message=(
|
|
185
|
+
"origin is required by policy"
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
normalized_origin = _normalize_origin(
|
|
191
|
+
origin
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
normalized_origin
|
|
196
|
+
not in policy.allowed_origins
|
|
197
|
+
):
|
|
198
|
+
violations.append(
|
|
199
|
+
PolicyViolation(
|
|
200
|
+
code="origin_not_allowed",
|
|
201
|
+
message=(
|
|
202
|
+
f"origin {normalized_origin} "
|
|
203
|
+
"is not allowed by policy"
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if policy.allowed_assets is not None:
|
|
209
|
+
if asset is None:
|
|
210
|
+
violations.append(
|
|
211
|
+
PolicyViolation(
|
|
212
|
+
code="asset_missing",
|
|
213
|
+
message=(
|
|
214
|
+
"asset is required by policy"
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
if not isinstance(asset, str):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"asset must be a string"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
candidate_asset = asset.strip()
|
|
225
|
+
|
|
226
|
+
if not candidate_asset:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
"asset must not be empty"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
candidate_asset
|
|
233
|
+
not in policy.allowed_assets
|
|
234
|
+
):
|
|
235
|
+
violations.append(
|
|
236
|
+
PolicyViolation(
|
|
237
|
+
code="asset_not_allowed",
|
|
238
|
+
message=(
|
|
239
|
+
f"asset {candidate_asset} "
|
|
240
|
+
"is not allowed by policy"
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return PolicyDecision(
|
|
246
|
+
allowed=not violations,
|
|
247
|
+
violations=tuple(violations),
|
|
248
|
+
)
|
|
File without changes
|
qruntime/rails/x402.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Offline x402 v2 decoder and economic normalizer for Q Runtime v0.6.
|
|
2
|
+
|
|
3
|
+
This module deliberately does not sign, verify, settle, or broadcast payments.
|
|
4
|
+
It only parses x402 wire objects and maps them into deterministic economic facts
|
|
5
|
+
that can be checked before a real rail is allowed to execute.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, asdict
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Mapping
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class X402DecodeError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _b64decode_json(value: str) -> dict[str, Any]:
|
|
21
|
+
raw = value.strip()
|
|
22
|
+
# Accept ordinary JSON too; fixtures and logs often store the decoded object.
|
|
23
|
+
if raw.startswith("{"):
|
|
24
|
+
obj = json.loads(raw)
|
|
25
|
+
if not isinstance(obj, dict):
|
|
26
|
+
raise X402DecodeError("x402 object must be a JSON object")
|
|
27
|
+
return obj
|
|
28
|
+
|
|
29
|
+
# x402 HTTP v2 uses Base64-encoded JSON headers. Accept URL-safe alphabet as
|
|
30
|
+
# a pragmatic log-decoding convenience while preserving strict JSON parsing.
|
|
31
|
+
padded = raw + "=" * (-len(raw) % 4)
|
|
32
|
+
try:
|
|
33
|
+
decoded = base64.b64decode(padded, altchars=b"-_", validate=False)
|
|
34
|
+
obj = json.loads(decoded.decode("utf-8"))
|
|
35
|
+
except Exception as exc: # noqa: BLE001 - convert all wire failures to one public error
|
|
36
|
+
raise X402DecodeError(f"invalid base64 JSON x402 header: {exc}") from exc
|
|
37
|
+
if not isinstance(obj, dict):
|
|
38
|
+
raise X402DecodeError("decoded x402 header must contain a JSON object")
|
|
39
|
+
return obj
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _object(value: str | Mapping[str, Any]) -> dict[str, Any]:
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
return _b64decode_json(value)
|
|
45
|
+
if isinstance(value, Mapping):
|
|
46
|
+
return dict(value)
|
|
47
|
+
raise X402DecodeError(f"unsupported x402 input type: {type(value).__name__}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def encode_header(obj: Mapping[str, Any]) -> str:
|
|
51
|
+
"""Encode a decoded x402 v2 object exactly as an HTTP payment header payload."""
|
|
52
|
+
raw = json.dumps(dict(obj), separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
53
|
+
return base64.b64encode(raw).decode("ascii")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _required_string(obj: Mapping[str, Any], key: str) -> str:
|
|
57
|
+
value = obj.get(key)
|
|
58
|
+
if not isinstance(value, str) or not value:
|
|
59
|
+
raise X402DecodeError(f"missing/invalid required field: {key}")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _required_int(obj: Mapping[str, Any], key: str) -> int:
|
|
64
|
+
value = obj.get(key)
|
|
65
|
+
if isinstance(value, bool):
|
|
66
|
+
raise X402DecodeError(f"missing/invalid required integer field: {key}")
|
|
67
|
+
try:
|
|
68
|
+
return int(value)
|
|
69
|
+
except (TypeError, ValueError) as exc:
|
|
70
|
+
raise X402DecodeError(f"missing/invalid required integer field: {key}") from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class X402Requirement:
|
|
75
|
+
scheme: str
|
|
76
|
+
network: str
|
|
77
|
+
amount_atomic: int
|
|
78
|
+
asset: str
|
|
79
|
+
pay_to: str
|
|
80
|
+
max_timeout_seconds: int
|
|
81
|
+
extra_json: str = "{}"
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def from_dict(raw: Mapping[str, Any]) -> "X402Requirement":
|
|
85
|
+
amount = _required_int(raw, "amount")
|
|
86
|
+
timeout = _required_int(raw, "maxTimeoutSeconds")
|
|
87
|
+
if amount < 0:
|
|
88
|
+
raise X402DecodeError("amount must be non-negative")
|
|
89
|
+
if timeout < 0:
|
|
90
|
+
raise X402DecodeError("maxTimeoutSeconds must be non-negative")
|
|
91
|
+
extra = raw.get("extra", {})
|
|
92
|
+
if not isinstance(extra, Mapping):
|
|
93
|
+
raise X402DecodeError("extra must be an object when present")
|
|
94
|
+
return X402Requirement(
|
|
95
|
+
scheme=_required_string(raw, "scheme"),
|
|
96
|
+
network=_required_string(raw, "network"),
|
|
97
|
+
amount_atomic=amount,
|
|
98
|
+
asset=_required_string(raw, "asset"),
|
|
99
|
+
pay_to=_required_string(raw, "payTo"),
|
|
100
|
+
max_timeout_seconds=timeout,
|
|
101
|
+
extra_json=json.dumps(dict(extra), sort_keys=True, separators=(",", ":")),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def canonical_dict(self) -> dict[str, Any]:
|
|
105
|
+
return {
|
|
106
|
+
"scheme": self.scheme,
|
|
107
|
+
"network": self.network,
|
|
108
|
+
"amount": str(self.amount_atomic),
|
|
109
|
+
"asset": self.asset,
|
|
110
|
+
"payTo": self.pay_to,
|
|
111
|
+
"maxTimeoutSeconds": self.max_timeout_seconds,
|
|
112
|
+
"extra": json.loads(self.extra_json),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def fingerprint(self) -> str:
|
|
116
|
+
raw = json.dumps(self.canonical_dict(), sort_keys=True, separators=(",", ":")).encode()
|
|
117
|
+
return hashlib.sha256(raw).hexdigest()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class X402PaymentRequired:
|
|
122
|
+
version: int
|
|
123
|
+
resource_url: str
|
|
124
|
+
resource_description: str
|
|
125
|
+
accepts: tuple[X402Requirement, ...]
|
|
126
|
+
error: str = ""
|
|
127
|
+
|
|
128
|
+
def selected(self, index: int = 0) -> X402Requirement:
|
|
129
|
+
try:
|
|
130
|
+
return self.accepts[index]
|
|
131
|
+
except IndexError as exc:
|
|
132
|
+
raise X402DecodeError(f"accepts index out of range: {index}") from exc
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class X402PaymentPayload:
|
|
137
|
+
version: int
|
|
138
|
+
resource_url: str
|
|
139
|
+
accepted: X402Requirement
|
|
140
|
+
payload_json: str
|
|
141
|
+
|
|
142
|
+
def payload_fingerprint(self) -> str:
|
|
143
|
+
raw = json.dumps(
|
|
144
|
+
{
|
|
145
|
+
"version": self.version,
|
|
146
|
+
"resource_url": self.resource_url,
|
|
147
|
+
"accepted": self.accepted.canonical_dict(),
|
|
148
|
+
"payload": json.loads(self.payload_json),
|
|
149
|
+
},
|
|
150
|
+
sort_keys=True,
|
|
151
|
+
separators=(",", ":"),
|
|
152
|
+
).encode()
|
|
153
|
+
return hashlib.sha256(raw).hexdigest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class X402SettlementResponse:
|
|
158
|
+
success: bool
|
|
159
|
+
transaction: str
|
|
160
|
+
network: str
|
|
161
|
+
error_reason: str = ""
|
|
162
|
+
payer: str = ""
|
|
163
|
+
amount_atomic: int | None = None
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def reconciliation_required(self) -> bool:
|
|
167
|
+
return (not self.success) and self.error_reason == "settlement_pending"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class QPaymentRequiredEvent:
|
|
172
|
+
kind: str
|
|
173
|
+
resource_url: str
|
|
174
|
+
quote_fingerprint: str
|
|
175
|
+
scheme: str
|
|
176
|
+
network: str
|
|
177
|
+
asset: str
|
|
178
|
+
amount_atomic: int
|
|
179
|
+
pay_to: str
|
|
180
|
+
max_timeout_seconds: int
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_payment_required(value: str | Mapping[str, Any]) -> X402PaymentRequired:
|
|
184
|
+
raw = _object(value)
|
|
185
|
+
version = _required_int(raw, "x402Version")
|
|
186
|
+
if version != 2:
|
|
187
|
+
raise X402DecodeError(f"unsupported x402Version={version}; v0.6 accepts x402 v2 only")
|
|
188
|
+
resource = raw.get("resource")
|
|
189
|
+
if not isinstance(resource, Mapping):
|
|
190
|
+
raise X402DecodeError("resource must be an object")
|
|
191
|
+
resource_url = _required_string(resource, "url")
|
|
192
|
+
accepts_raw = raw.get("accepts")
|
|
193
|
+
if not isinstance(accepts_raw, list) or not accepts_raw:
|
|
194
|
+
raise X402DecodeError("accepts must be a non-empty array")
|
|
195
|
+
accepts = tuple(X402Requirement.from_dict(item) for item in accepts_raw if isinstance(item, Mapping))
|
|
196
|
+
if len(accepts) != len(accepts_raw):
|
|
197
|
+
raise X402DecodeError("each accepts entry must be an object")
|
|
198
|
+
return X402PaymentRequired(
|
|
199
|
+
version=version,
|
|
200
|
+
resource_url=resource_url,
|
|
201
|
+
resource_description=str(resource.get("description", "")),
|
|
202
|
+
accepts=accepts,
|
|
203
|
+
error=str(raw.get("error", "")),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_payment_payload(value: str | Mapping[str, Any]) -> X402PaymentPayload:
|
|
208
|
+
raw = _object(value)
|
|
209
|
+
version = _required_int(raw, "x402Version")
|
|
210
|
+
if version != 2:
|
|
211
|
+
raise X402DecodeError(f"unsupported x402Version={version}; v0.6 accepts x402 v2 only")
|
|
212
|
+
accepted_raw = raw.get("accepted")
|
|
213
|
+
if not isinstance(accepted_raw, Mapping):
|
|
214
|
+
raise X402DecodeError("accepted must be an object")
|
|
215
|
+
payload = raw.get("payload")
|
|
216
|
+
if not isinstance(payload, Mapping):
|
|
217
|
+
raise X402DecodeError("payload must be an object")
|
|
218
|
+
resource = raw.get("resource") or {}
|
|
219
|
+
if not isinstance(resource, Mapping):
|
|
220
|
+
raise X402DecodeError("resource must be an object when present")
|
|
221
|
+
return X402PaymentPayload(
|
|
222
|
+
version=version,
|
|
223
|
+
resource_url=str(resource.get("url", "")),
|
|
224
|
+
accepted=X402Requirement.from_dict(accepted_raw),
|
|
225
|
+
payload_json=json.dumps(dict(payload), sort_keys=True, separators=(",", ":")),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_settlement_response(value: str | Mapping[str, Any]) -> X402SettlementResponse:
|
|
230
|
+
raw = _object(value)
|
|
231
|
+
if "success" not in raw or not isinstance(raw["success"], bool):
|
|
232
|
+
raise X402DecodeError("settlement response requires boolean success")
|
|
233
|
+
transaction = str(raw.get("transaction", ""))
|
|
234
|
+
network = _required_string(raw, "network")
|
|
235
|
+
error_reason = str(raw.get("errorReason", ""))
|
|
236
|
+
if error_reason == "settlement_pending" and not transaction:
|
|
237
|
+
raise X402DecodeError("settlement_pending requires a non-empty transaction hash")
|
|
238
|
+
amount = raw.get("amount")
|
|
239
|
+
amount_atomic = None if amount is None else _required_int(raw, "amount")
|
|
240
|
+
return X402SettlementResponse(
|
|
241
|
+
success=raw["success"],
|
|
242
|
+
transaction=transaction,
|
|
243
|
+
network=network,
|
|
244
|
+
error_reason=error_reason,
|
|
245
|
+
payer=str(raw.get("payer", "")),
|
|
246
|
+
amount_atomic=amount_atomic,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def normalize_payment_required(value: str | Mapping[str, Any], accept_index: int = 0) -> QPaymentRequiredEvent:
|
|
251
|
+
parsed = parse_payment_required(value)
|
|
252
|
+
req = parsed.selected(accept_index)
|
|
253
|
+
return QPaymentRequiredEvent(
|
|
254
|
+
kind="PAYMENT_REQUIRED",
|
|
255
|
+
resource_url=parsed.resource_url,
|
|
256
|
+
quote_fingerprint=req.fingerprint(),
|
|
257
|
+
scheme=req.scheme,
|
|
258
|
+
network=req.network,
|
|
259
|
+
asset=req.asset,
|
|
260
|
+
amount_atomic=req.amount_atomic,
|
|
261
|
+
pay_to=req.pay_to,
|
|
262
|
+
max_timeout_seconds=req.max_timeout_seconds,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def requirement_equal(a: X402Requirement, b: X402Requirement) -> bool:
|
|
267
|
+
return a.canonical_dict() == b.canonical_dict()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def requirement_diff(old: X402Requirement, new: X402Requirement) -> dict[str, tuple[Any, Any]]:
|
|
271
|
+
a = old.canonical_dict()
|
|
272
|
+
b = new.canonical_dict()
|
|
273
|
+
return {key: (a.get(key), b.get(key)) for key in sorted(set(a) | set(b)) if a.get(key) != b.get(key)}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def as_jsonable(obj: Any) -> Any:
|
|
277
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
278
|
+
data = asdict(obj)
|
|
279
|
+
if isinstance(obj, X402Requirement):
|
|
280
|
+
data["extra"] = json.loads(data.pop("extra_json"))
|
|
281
|
+
if isinstance(obj, X402PaymentPayload):
|
|
282
|
+
data["payload"] = json.loads(data.pop("payload_json"))
|
|
283
|
+
return data
|
|
284
|
+
return obj
|