core-runtime-engine 11.5.1__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.
- core_runtime/__init__.py +10 -0
- core_runtime/__version__.py +17 -0
- core_runtime/cli/__init__.py +7 -0
- core_runtime/cli/__main__.py +22 -0
- core_runtime/cli/bump_version.py +176 -0
- core_runtime/cli/contract_preflight.py +69 -0
- core_runtime/cli/create_domain.py +66 -0
- core_runtime/cli/doctor.py +44 -0
- core_runtime/cli/inventory.py +85 -0
- core_runtime/cli/lint.py +8 -0
- core_runtime/cli/main.py +579 -0
- core_runtime/cli/release_check.py +65 -0
- core_runtime/cli/repair_artifact_paths.py +48 -0
- core_runtime/cli/sync_template.py +67 -0
- core_runtime/cli/validate.py +73 -0
- core_runtime/core/__init__.py +34 -0
- core_runtime/core/audit_event.py +760 -0
- core_runtime/core/audit_trail_index.py +100 -0
- core_runtime/core/canonicalization.py +55 -0
- core_runtime/core/contract_evaluator.py +945 -0
- core_runtime/core/contract_executability.py +307 -0
- core_runtime/core/contract_loader.py +57 -0
- core_runtime/core/contract_probes.py +630 -0
- core_runtime/core/contract_program.py +131 -0
- core_runtime/core/contract_program_registry.py +104 -0
- core_runtime/core/contract_program_v2.py +126 -0
- core_runtime/core/dsk_v3.py +142 -0
- core_runtime/core/explainability.py +2115 -0
- core_runtime/core/numeric_normalization.py +120 -0
- core_runtime/core/rule_anchor.py +1388 -0
- core_runtime/core/schema_fingerprint.py +69 -0
- core_runtime/core/sensor_evidence.py +548 -0
- core_runtime/data/contracts/CoreAnchor.sol +109 -0
- core_runtime/data/contracts/CoreRuleAnchor.abi.json +111 -0
- core_runtime/data/contracts/CoreRuleAnchor.bin +1 -0
- core_runtime/data/contracts/CoreRuleAnchor.build.json +20 -0
- core_runtime/data/contracts/CoreRuleAnchor.runtime.bin +1 -0
- core_runtime/data/contracts/CoreRuleAnchor.sol +74 -0
- core_runtime/data/package_data_manifest.v1.json +173 -0
- core_runtime/data/schemas/core/causal_trace.v1.json +141 -0
- core_runtime/data/schemas/core/context_gate.v1.json +38 -0
- core_runtime/data/schemas/core/context_threshold.v1.json +46 -0
- core_runtime/data/schemas/core/contract_program.v1.json +186 -0
- core_runtime/data/schemas/core/contract_program.v2.json +187 -0
- core_runtime/data/schemas/core/control_decision.v1.json +114 -0
- core_runtime/data/schemas/core/dsk.v3.json +105 -0
- core_runtime/data/schemas/core/effect_result.v1.json +39 -0
- core_runtime/data/schemas/core/entropy_signal.v1.json +108 -0
- core_runtime/data/schemas/core/execution_receipt.v1.json +93 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v1.json +87 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v2.json +72 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v3.json +38 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v4.json +72 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v5.json +38 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v6.json +116 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v7.json +37 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v8.json +114 -0
- core_runtime/data/schemas/core/frozen_rule_set.v1.json +282 -0
- core_runtime/data/schemas/core/memory_artifact.v1.json +120 -0
- core_runtime/data/schemas/core/memory_generation_result.v1.json +37 -0
- core_runtime/data/schemas/core/operational_learning_event.v1.json +63 -0
- core_runtime/data/schemas/core/pattern_candidate.v1.json +114 -0
- core_runtime/data/schemas/core/physical_safety_assurance_case.v1.json +676 -0
- core_runtime/data/schemas/core/policy_lifecycle.v1.json +99 -0
- core_runtime/data/schemas/core/retention_manifest.v1.json +53 -0
- core_runtime/data/schemas/core/reversibility_policy.v1.json +107 -0
- core_runtime/data/schemas/core/rule_anchor_batch.v1.json +93 -0
- core_runtime/data/schemas/core/rule_anchor_chain_evidence.v1.json +56 -0
- core_runtime/data/schemas/core/rule_approval.v1.json +49 -0
- core_runtime/data/schemas/core/rule_approval_request.v1.json +42 -0
- core_runtime/data/schemas/core/state_transition.v1.json +115 -0
- core_runtime/data/schemas/core/task_closeout.v1.json +47 -0
- core_runtime/data/schemas/core/template_promotion_candidate.v1.json +83 -0
- core_runtime/data/schemas/core/unsigned_rule_anchor_deployment.v1.json +106 -0
- core_runtime/data/schemas/core/unsigned_rule_anchor_transaction.v1.json +116 -0
- core_runtime/tooling/__init__.py +48 -0
- core_runtime/tooling/bump_version.py +900 -0
- core_runtime/tooling/contract_preflight.py +293 -0
- core_runtime/tooling/create_domain.py +254 -0
- core_runtime/tooling/diagnostics.py +129 -0
- core_runtime/tooling/doctor.py +482 -0
- core_runtime/tooling/file_inventory.py +182 -0
- core_runtime/tooling/json_checks.py +100 -0
- core_runtime/tooling/release_check.py +1017 -0
- core_runtime/tooling/repair_artifact_paths.py +458 -0
- core_runtime/tooling/report_writer.py +176 -0
- core_runtime/tooling/repository_inventory.py +399 -0
- core_runtime/tooling/safety_checks.py +172 -0
- core_runtime/tooling/sync_template.py +303 -0
- core_runtime/tooling/validation.py +507 -0
- core_runtime/tooling/version_inventory.py +256 -0
- core_runtime_engine-11.5.1.dist-info/METADATA +35 -0
- core_runtime_engine-11.5.1.dist-info/RECORD +96 -0
- core_runtime_engine-11.5.1.dist-info/WHEEL +5 -0
- core_runtime_engine-11.5.1.dist-info/entry_points.txt +2 -0
- core_runtime_engine-11.5.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
"""Deterministic primitives for frozen-rule approval and blockchain anchoring.
|
|
2
|
+
|
|
3
|
+
CORE validates rule artifacts and signatures off-chain. The blockchain
|
|
4
|
+
contract only timestamps a Merkle root and its manifest fingerprint. No
|
|
5
|
+
private rule content, blinding nonce, wallet secret, or runtime authority is
|
|
6
|
+
placed on-chain.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from importlib.resources import files
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from jsonschema import Draft7Validator
|
|
20
|
+
|
|
21
|
+
from core_runtime.core.canonicalization import canonical_json_dumps
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SCHEMA_ROOT = files("core_runtime").joinpath("data", "schemas", "core")
|
|
25
|
+
CONTRACT_ROOT = files("core_runtime").joinpath("data", "contracts")
|
|
26
|
+
|
|
27
|
+
FROZEN_RULE_SET_SCHEMA = "core.frozen_rule_set.v1"
|
|
28
|
+
APPROVAL_REQUEST_SCHEMA = "core.rule_approval_request.v1"
|
|
29
|
+
APPROVAL_SCHEMA = "core.rule_approval.v1"
|
|
30
|
+
ANCHOR_BATCH_SCHEMA = "core.rule_anchor_batch.v1"
|
|
31
|
+
UNSIGNED_TRANSACTION_SCHEMA = "core.unsigned_rule_anchor_transaction.v1"
|
|
32
|
+
UNSIGNED_DEPLOYMENT_SCHEMA = "core.unsigned_rule_anchor_deployment.v1"
|
|
33
|
+
CHAIN_EVIDENCE_SCHEMA = "core.rule_anchor_chain_evidence.v1"
|
|
34
|
+
|
|
35
|
+
FINGERPRINT_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
|
|
36
|
+
ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
|
37
|
+
SIGNATURE_RE = re.compile(r"^0x[a-fA-F0-9]{130}$")
|
|
38
|
+
|
|
39
|
+
PRIVATE_COMMITMENT_DOMAIN = b"CORE_PRIVATE_RULE_COMMITMENT_V1\x00"
|
|
40
|
+
MERKLE_LEAF_DOMAIN = b"\x00CORE_RULE_ANCHOR_LEAF_V1\x00"
|
|
41
|
+
MERKLE_NODE_DOMAIN = b"\x01CORE_RULE_ANCHOR_NODE_V1\x00"
|
|
42
|
+
APPROVAL_MESSAGE_HEADER = "CORE FrozenRuleSet Approval v1"
|
|
43
|
+
|
|
44
|
+
# Keccak-256("anchorRuleBatch(bytes32,bytes32,uint32,uint8)")[:4].
|
|
45
|
+
# The full signature and selector are independently checked in tests/CI.
|
|
46
|
+
ANCHOR_RULE_BATCH_SELECTOR = "6919e458"
|
|
47
|
+
|
|
48
|
+
# secp256k1 group order, used to reject malleable high-s signatures.
|
|
49
|
+
SECP256K1_N = int(
|
|
50
|
+
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141",
|
|
51
|
+
16,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
FORBIDDEN_WALLET_SECRET_KEYS = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"private_key",
|
|
57
|
+
"private-key",
|
|
58
|
+
"seed",
|
|
59
|
+
"seed_phrase",
|
|
60
|
+
"mnemonic",
|
|
61
|
+
"password",
|
|
62
|
+
"signing_key",
|
|
63
|
+
"raw_transaction",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
UNSIGNED_DEPLOYMENT_WARNINGS = (
|
|
67
|
+
"Review and sign this deployment only in an external wallet.",
|
|
68
|
+
"After confirmation, verify deployed runtime bytecode before creating approvals.",
|
|
69
|
+
"CORE never requests or stores wallet secrets and never broadcasts.",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def error(code: str, message: str, field: str | None = None, **extra: Any) -> dict[str, Any]:
|
|
74
|
+
"""Return a stable validator error envelope entry."""
|
|
75
|
+
|
|
76
|
+
result: dict[str, Any] = {"code": code, "message": message}
|
|
77
|
+
if field is not None:
|
|
78
|
+
result["field"] = field
|
|
79
|
+
result.update(extra)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def sha256_fingerprint_bytes(payload: bytes) -> str:
|
|
84
|
+
"""Return a prefixed SHA-256 fingerprint."""
|
|
85
|
+
|
|
86
|
+
return f"sha256:{hashlib.sha256(payload).hexdigest()}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def canonical_fingerprint(payload: Any) -> str:
|
|
90
|
+
"""Fingerprint canonical UTF-8 JSON using SHA-256."""
|
|
91
|
+
|
|
92
|
+
return sha256_fingerprint_bytes(canonical_json_dumps(payload).encode("utf-8"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def artifact_fingerprint(payload: Mapping[str, Any], field: str = "fingerprint") -> str:
|
|
96
|
+
"""Fingerprint an artifact while excluding its declared fingerprint."""
|
|
97
|
+
|
|
98
|
+
return canonical_fingerprint({key: value for key, value in payload.items() if key != field})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_verified_rule_anchor_build() -> tuple[dict[str, Any], str]:
|
|
102
|
+
"""Load frozen contract artifacts and verify every declared build digest."""
|
|
103
|
+
|
|
104
|
+
build = json.loads((CONTRACT_ROOT / "CoreRuleAnchor.build.json").read_text(encoding="utf-8"))
|
|
105
|
+
source = (CONTRACT_ROOT / "CoreRuleAnchor.sol").read_bytes()
|
|
106
|
+
abi = json.loads((CONTRACT_ROOT / "CoreRuleAnchor.abi.json").read_text(encoding="utf-8"))
|
|
107
|
+
creation_hex = (CONTRACT_ROOT / "CoreRuleAnchor.bin").read_text(encoding="utf-8").strip()
|
|
108
|
+
runtime_hex = (CONTRACT_ROOT / "CoreRuleAnchor.runtime.bin").read_text(encoding="utf-8").strip()
|
|
109
|
+
checks = {
|
|
110
|
+
"source_sha256": sha256_fingerprint_bytes(source),
|
|
111
|
+
"abi_canonical_sha256": canonical_fingerprint(abi),
|
|
112
|
+
"creation_bytecode_sha256": sha256_fingerprint_bytes(bytes.fromhex(creation_hex)),
|
|
113
|
+
"runtime_bytecode_sha256": sha256_fingerprint_bytes(bytes.fromhex(runtime_hex)),
|
|
114
|
+
}
|
|
115
|
+
for field, computed in checks.items():
|
|
116
|
+
if build.get(field) != computed:
|
|
117
|
+
raise ValueError(f"frozen contract build mismatch: {field}")
|
|
118
|
+
return build, creation_hex
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _wallet_secret_errors(payload: Any, artifact_label: str) -> list[dict[str, Any]]:
|
|
122
|
+
errors: list[dict[str, Any]] = []
|
|
123
|
+
|
|
124
|
+
def scan(value: Any, field: str = "$") -> None:
|
|
125
|
+
if isinstance(value, dict):
|
|
126
|
+
for key, child in value.items():
|
|
127
|
+
child_field = f"{field}.{key}"
|
|
128
|
+
if str(key).lower() in FORBIDDEN_WALLET_SECRET_KEYS:
|
|
129
|
+
errors.append(
|
|
130
|
+
error(
|
|
131
|
+
"wallet_secret_forbidden",
|
|
132
|
+
f"{artifact_label} artifacts cannot contain wallet secrets.",
|
|
133
|
+
child_field,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
scan(child, child_field)
|
|
137
|
+
elif isinstance(value, list):
|
|
138
|
+
for index, child in enumerate(value):
|
|
139
|
+
scan(child, f"{field}[{index}]")
|
|
140
|
+
|
|
141
|
+
scan(payload)
|
|
142
|
+
return errors
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fingerprint_bytes(value: str) -> bytes:
|
|
146
|
+
"""Decode a canonical ``sha256:<hex>`` fingerprint."""
|
|
147
|
+
|
|
148
|
+
if not FINGERPRINT_RE.fullmatch(value):
|
|
149
|
+
raise ValueError("fingerprint must match sha256:<64 lowercase hex characters>")
|
|
150
|
+
return bytes.fromhex(value.removeprefix("sha256:"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def bytes32_hex(value: str) -> str:
|
|
154
|
+
"""Convert a SHA-256 fingerprint to an EVM bytes32 hex value."""
|
|
155
|
+
|
|
156
|
+
return "0x" + fingerprint_bytes(value).hex()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _schema_errors(payload: Any, schema_filename: str) -> list[dict[str, Any]]:
|
|
160
|
+
schema_path = SCHEMA_ROOT / schema_filename
|
|
161
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
162
|
+
validator = Draft7Validator(schema)
|
|
163
|
+
errors: list[dict[str, Any]] = []
|
|
164
|
+
for item in sorted(validator.iter_errors(payload), key=lambda entry: list(entry.absolute_path)):
|
|
165
|
+
field = ".".join(str(part) for part in item.absolute_path) or "$"
|
|
166
|
+
errors.append(error("schema_validation_error", item.message, field))
|
|
167
|
+
return errors
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _valid_timezone_timestamp(value: Any) -> bool:
|
|
171
|
+
if not isinstance(value, str):
|
|
172
|
+
return False
|
|
173
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
174
|
+
try:
|
|
175
|
+
parsed = datetime.fromisoformat(normalized)
|
|
176
|
+
except ValueError:
|
|
177
|
+
return False
|
|
178
|
+
return parsed.tzinfo is not None and parsed.utcoffset() is not None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _normalized_addresses(values: Iterable[str]) -> list[str]:
|
|
182
|
+
return [value.lower() for value in values]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def validate_frozen_rule_set_payload(payload: Any) -> list[dict[str, Any]]:
|
|
186
|
+
"""Validate a public frozen rule set or a private commitment envelope."""
|
|
187
|
+
|
|
188
|
+
errors = _schema_errors(payload, "frozen_rule_set.v1.json")
|
|
189
|
+
if not isinstance(payload, dict):
|
|
190
|
+
return errors
|
|
191
|
+
|
|
192
|
+
frozen_at = payload.get("frozen_at")
|
|
193
|
+
if frozen_at is not None and not _valid_timezone_timestamp(frozen_at):
|
|
194
|
+
errors.append(
|
|
195
|
+
error(
|
|
196
|
+
"invalid_frozen_at",
|
|
197
|
+
"frozen_at must be an ISO 8601 timestamp with an explicit timezone.",
|
|
198
|
+
"frozen_at",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
governance = payload.get("governance")
|
|
203
|
+
if isinstance(governance, dict):
|
|
204
|
+
signers = governance.get("authorized_signers")
|
|
205
|
+
threshold = governance.get("approval_threshold")
|
|
206
|
+
if isinstance(signers, list) and all(isinstance(item, str) for item in signers):
|
|
207
|
+
normalized = _normalized_addresses(signers)
|
|
208
|
+
if len(set(normalized)) != len(normalized):
|
|
209
|
+
errors.append(
|
|
210
|
+
error(
|
|
211
|
+
"duplicate_authorized_signer",
|
|
212
|
+
"authorized_signers must be unique ignoring address case.",
|
|
213
|
+
"governance.authorized_signers",
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
if isinstance(threshold, int) and not isinstance(threshold, bool) and threshold > len(signers):
|
|
217
|
+
errors.append(
|
|
218
|
+
error(
|
|
219
|
+
"approval_threshold_unreachable",
|
|
220
|
+
"approval_threshold cannot exceed the number of authorized signers.",
|
|
221
|
+
"governance.approval_threshold",
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
content = payload.get("content")
|
|
226
|
+
rule_class = payload.get("rule_class")
|
|
227
|
+
visibility = payload.get("visibility")
|
|
228
|
+
if isinstance(content, dict):
|
|
229
|
+
mode = content.get("mode")
|
|
230
|
+
if rule_class == "general" and (visibility != "public" or mode != "public"):
|
|
231
|
+
errors.append(
|
|
232
|
+
error(
|
|
233
|
+
"general_rule_must_be_public",
|
|
234
|
+
"General rules must publish their complete frozen content.",
|
|
235
|
+
"visibility",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
if rule_class == "personal" and (
|
|
239
|
+
visibility != "private_commitment" or mode != "private_commitment"
|
|
240
|
+
):
|
|
241
|
+
errors.append(
|
|
242
|
+
error(
|
|
243
|
+
"personal_rule_must_be_private_commitment",
|
|
244
|
+
"Personal rules must expose only a blinded commitment.",
|
|
245
|
+
"visibility",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if mode == "public":
|
|
250
|
+
rules = content.get("rules")
|
|
251
|
+
if isinstance(rules, list):
|
|
252
|
+
rule_ids = [item.get("rule_id") for item in rules if isinstance(item, dict)]
|
|
253
|
+
if len(rule_ids) != len(set(rule_ids)):
|
|
254
|
+
errors.append(
|
|
255
|
+
error(
|
|
256
|
+
"duplicate_rule_id",
|
|
257
|
+
"Public rule_id values must be unique within a frozen rule set.",
|
|
258
|
+
"content.rules",
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
for index, rule in enumerate(rules):
|
|
262
|
+
if not isinstance(rule, dict):
|
|
263
|
+
continue
|
|
264
|
+
if rule.get("domain") != payload.get("domain"):
|
|
265
|
+
errors.append(
|
|
266
|
+
error(
|
|
267
|
+
"rule_domain_mismatch",
|
|
268
|
+
"Every public rule domain must equal the rule-set domain.",
|
|
269
|
+
f"content.rules.{index}.domain",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
steps = rule.get("steps")
|
|
273
|
+
if isinstance(steps, list):
|
|
274
|
+
step_ids = [step.get("step_id") for step in steps if isinstance(step, dict)]
|
|
275
|
+
if len(step_ids) != len(set(step_ids)):
|
|
276
|
+
errors.append(
|
|
277
|
+
error(
|
|
278
|
+
"duplicate_step_id",
|
|
279
|
+
"step_id values must be unique within each rule.",
|
|
280
|
+
f"content.rules.{index}.steps",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if mode == "private_commitment":
|
|
285
|
+
commitment = content.get("commitment")
|
|
286
|
+
if commitment == "sha256:" + ("0" * 64):
|
|
287
|
+
errors.append(
|
|
288
|
+
error(
|
|
289
|
+
"zero_private_commitment",
|
|
290
|
+
"A private commitment cannot be the all-zero digest.",
|
|
291
|
+
"content.commitment",
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
declared = payload.get("fingerprint")
|
|
296
|
+
if isinstance(declared, str) and FINGERPRINT_RE.fullmatch(declared):
|
|
297
|
+
expected = artifact_fingerprint(payload)
|
|
298
|
+
if declared != expected:
|
|
299
|
+
errors.append(
|
|
300
|
+
error(
|
|
301
|
+
"fingerprint_mismatch",
|
|
302
|
+
"Frozen rule-set fingerprint does not match canonical content.",
|
|
303
|
+
"fingerprint",
|
|
304
|
+
declared=declared,
|
|
305
|
+
computed=expected,
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return errors
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def private_content_fingerprint(private_payload: Any) -> str:
|
|
313
|
+
"""Fingerprint private content without publishing that digest."""
|
|
314
|
+
|
|
315
|
+
return canonical_fingerprint(private_payload)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def private_rule_commitment(private_payload: Any, blinding_nonce: bytes) -> str:
|
|
319
|
+
"""Create a domain-separated commitment hiding low-entropy private rules."""
|
|
320
|
+
|
|
321
|
+
if len(blinding_nonce) != 32:
|
|
322
|
+
raise ValueError("blinding_nonce must contain exactly 32 bytes")
|
|
323
|
+
content_digest = fingerprint_bytes(private_content_fingerprint(private_payload))
|
|
324
|
+
return sha256_fingerprint_bytes(PRIVATE_COMMITMENT_DOMAIN + content_digest + blinding_nonce)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def verify_private_rule_opening(
|
|
328
|
+
private_payload: Any,
|
|
329
|
+
blinding_nonce_hex: str,
|
|
330
|
+
expected_commitment: str,
|
|
331
|
+
) -> bool:
|
|
332
|
+
"""Verify a private commitment opening locally."""
|
|
333
|
+
|
|
334
|
+
if not re.fullmatch(r"[a-f0-9]{64}", blinding_nonce_hex):
|
|
335
|
+
return False
|
|
336
|
+
if not FINGERPRINT_RE.fullmatch(expected_commitment):
|
|
337
|
+
return False
|
|
338
|
+
return private_rule_commitment(private_payload, bytes.fromhex(blinding_nonce_hex)) == expected_commitment
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def approval_message(rule_set_fingerprint: str, chain_id: int, verifying_contract: str) -> str:
|
|
342
|
+
"""Build the exact human-readable EIP-191 approval message."""
|
|
343
|
+
|
|
344
|
+
fingerprint_bytes(rule_set_fingerprint)
|
|
345
|
+
if not isinstance(chain_id, int) or isinstance(chain_id, bool) or chain_id <= 0:
|
|
346
|
+
raise ValueError("chain_id must be a positive integer")
|
|
347
|
+
if not ADDRESS_RE.fullmatch(verifying_contract):
|
|
348
|
+
raise ValueError("verifying_contract must be an EVM address")
|
|
349
|
+
return "\n".join(
|
|
350
|
+
(
|
|
351
|
+
APPROVAL_MESSAGE_HEADER,
|
|
352
|
+
f"rule_set_fingerprint: {rule_set_fingerprint}",
|
|
353
|
+
f"chain_id: {chain_id}",
|
|
354
|
+
f"verifying_contract: {verifying_contract.lower()}",
|
|
355
|
+
"decision: approve_frozen_rule_set",
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def build_approval_request(
|
|
361
|
+
rule_set_fingerprint: str,
|
|
362
|
+
chain_id: int,
|
|
363
|
+
verifying_contract: str,
|
|
364
|
+
signer: str,
|
|
365
|
+
) -> dict[str, Any]:
|
|
366
|
+
"""Build an externally signable, domain-bound approval request."""
|
|
367
|
+
|
|
368
|
+
if not ADDRESS_RE.fullmatch(signer):
|
|
369
|
+
raise ValueError("signer must be an EVM address")
|
|
370
|
+
request: dict[str, Any] = {
|
|
371
|
+
"schema_version": APPROVAL_REQUEST_SCHEMA,
|
|
372
|
+
"type": "rule_approval_request",
|
|
373
|
+
"rule_set_fingerprint": rule_set_fingerprint,
|
|
374
|
+
"chain_id": chain_id,
|
|
375
|
+
"verifying_contract": verifying_contract.lower(),
|
|
376
|
+
"signer": signer.lower(),
|
|
377
|
+
"signature_scheme": "eip191_secp256k1",
|
|
378
|
+
"decision": "approve_frozen_rule_set",
|
|
379
|
+
"message": approval_message(rule_set_fingerprint, chain_id, verifying_contract),
|
|
380
|
+
}
|
|
381
|
+
request["fingerprint"] = artifact_fingerprint(request)
|
|
382
|
+
return request
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def validate_approval_request_payload(payload: Any) -> list[dict[str, Any]]:
|
|
386
|
+
"""Validate an unsigned approval request."""
|
|
387
|
+
|
|
388
|
+
errors = _schema_errors(payload, "rule_approval_request.v1.json")
|
|
389
|
+
if not isinstance(payload, dict):
|
|
390
|
+
return errors
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
expected_message = approval_message(
|
|
394
|
+
payload.get("rule_set_fingerprint", ""),
|
|
395
|
+
payload.get("chain_id", 0),
|
|
396
|
+
payload.get("verifying_contract", ""),
|
|
397
|
+
)
|
|
398
|
+
except (TypeError, ValueError):
|
|
399
|
+
expected_message = None
|
|
400
|
+
|
|
401
|
+
if expected_message is not None and payload.get("message") != expected_message:
|
|
402
|
+
errors.append(
|
|
403
|
+
error(
|
|
404
|
+
"approval_message_mismatch",
|
|
405
|
+
"Approval message does not match its rule, chain, and contract binding.",
|
|
406
|
+
"message",
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
declared = payload.get("fingerprint")
|
|
411
|
+
if isinstance(declared, str) and FINGERPRINT_RE.fullmatch(declared):
|
|
412
|
+
expected = artifact_fingerprint(payload)
|
|
413
|
+
if declared != expected:
|
|
414
|
+
errors.append(
|
|
415
|
+
error(
|
|
416
|
+
"fingerprint_mismatch",
|
|
417
|
+
"Approval-request fingerprint does not match canonical content.",
|
|
418
|
+
"fingerprint",
|
|
419
|
+
declared=declared,
|
|
420
|
+
computed=expected,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
return errors
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _validate_canonical_signature(signature: str) -> list[dict[str, Any]]:
|
|
427
|
+
if not SIGNATURE_RE.fullmatch(signature):
|
|
428
|
+
return [
|
|
429
|
+
error(
|
|
430
|
+
"invalid_signature_format",
|
|
431
|
+
"signature must be a 65-byte 0x-prefixed hexadecimal ECDSA signature.",
|
|
432
|
+
"signature",
|
|
433
|
+
)
|
|
434
|
+
]
|
|
435
|
+
raw = bytes.fromhex(signature[2:])
|
|
436
|
+
r_value = int.from_bytes(raw[0:32], "big")
|
|
437
|
+
s_value = int.from_bytes(raw[32:64], "big")
|
|
438
|
+
recovery_id = raw[64]
|
|
439
|
+
errors: list[dict[str, Any]] = []
|
|
440
|
+
if r_value <= 0 or r_value >= SECP256K1_N:
|
|
441
|
+
errors.append(error("invalid_signature_r", "signature r is outside secp256k1 range.", "signature"))
|
|
442
|
+
if s_value <= 0 or s_value > SECP256K1_N // 2:
|
|
443
|
+
errors.append(
|
|
444
|
+
error(
|
|
445
|
+
"noncanonical_signature_s",
|
|
446
|
+
"signature must use canonical low-s form.",
|
|
447
|
+
"signature",
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
if recovery_id not in {0, 1, 27, 28}:
|
|
451
|
+
errors.append(
|
|
452
|
+
error(
|
|
453
|
+
"invalid_signature_recovery_id",
|
|
454
|
+
"signature recovery id must be 0, 1, 27, or 28.",
|
|
455
|
+
"signature",
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
return errors
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def recover_eip191_signer(message: str, signature: str) -> str:
|
|
462
|
+
"""Recover the signer address; fail closed when the crypto extra is absent."""
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
from eth_account import Account
|
|
466
|
+
from eth_account.messages import encode_defunct
|
|
467
|
+
except ImportError as exc: # pragma: no cover - exercised in dependency-minimal installs
|
|
468
|
+
raise RuntimeError("anchoring crypto backend unavailable; install CORE's anchoring extra") from exc
|
|
469
|
+
|
|
470
|
+
recovered = Account.recover_message(encode_defunct(text=message), signature=signature)
|
|
471
|
+
return str(recovered).lower()
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def validate_rule_approval_payload(payload: Any) -> list[dict[str, Any]]:
|
|
475
|
+
"""Validate and cryptographically recover a frozen-rule approval."""
|
|
476
|
+
|
|
477
|
+
errors = _schema_errors(payload, "rule_approval.v1.json")
|
|
478
|
+
if not isinstance(payload, dict):
|
|
479
|
+
return errors
|
|
480
|
+
|
|
481
|
+
signature = payload.get("signature")
|
|
482
|
+
if isinstance(signature, str):
|
|
483
|
+
errors.extend(_validate_canonical_signature(signature))
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
expected_message = approval_message(
|
|
487
|
+
payload.get("rule_set_fingerprint", ""),
|
|
488
|
+
payload.get("chain_id", 0),
|
|
489
|
+
payload.get("verifying_contract", ""),
|
|
490
|
+
)
|
|
491
|
+
except (TypeError, ValueError):
|
|
492
|
+
expected_message = None
|
|
493
|
+
|
|
494
|
+
if expected_message is not None and payload.get("message") != expected_message:
|
|
495
|
+
errors.append(
|
|
496
|
+
error(
|
|
497
|
+
"approval_message_mismatch",
|
|
498
|
+
"Approval message does not match its rule, chain, and contract binding.",
|
|
499
|
+
"message",
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if expected_message is not None and isinstance(signature, str) and not _validate_canonical_signature(signature):
|
|
504
|
+
try:
|
|
505
|
+
recovered = recover_eip191_signer(expected_message, signature)
|
|
506
|
+
except RuntimeError as exc:
|
|
507
|
+
errors.append(error("signature_verification_failed", str(exc), "signature"))
|
|
508
|
+
except Exception:
|
|
509
|
+
errors.append(
|
|
510
|
+
error(
|
|
511
|
+
"signature_verification_failed",
|
|
512
|
+
"ECDSA recovery rejected the supplied signature.",
|
|
513
|
+
"signature",
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
else:
|
|
517
|
+
declared_signer = payload.get("signer")
|
|
518
|
+
if isinstance(declared_signer, str) and recovered != declared_signer.lower():
|
|
519
|
+
errors.append(
|
|
520
|
+
error(
|
|
521
|
+
"signature_signer_mismatch",
|
|
522
|
+
"Recovered signature address does not match signer.",
|
|
523
|
+
"signer",
|
|
524
|
+
recovered=recovered,
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
request_fingerprint = payload.get("approval_request_fingerprint")
|
|
529
|
+
if isinstance(request_fingerprint, str) and FINGERPRINT_RE.fullmatch(request_fingerprint):
|
|
530
|
+
request = {
|
|
531
|
+
"schema_version": APPROVAL_REQUEST_SCHEMA,
|
|
532
|
+
"type": "rule_approval_request",
|
|
533
|
+
"rule_set_fingerprint": payload.get("rule_set_fingerprint"),
|
|
534
|
+
"chain_id": payload.get("chain_id"),
|
|
535
|
+
"verifying_contract": payload.get("verifying_contract"),
|
|
536
|
+
"signer": payload.get("signer"),
|
|
537
|
+
"signature_scheme": payload.get("signature_scheme"),
|
|
538
|
+
"decision": payload.get("decision"),
|
|
539
|
+
"message": payload.get("message"),
|
|
540
|
+
}
|
|
541
|
+
expected_request_fingerprint = artifact_fingerprint(request)
|
|
542
|
+
if request_fingerprint != expected_request_fingerprint:
|
|
543
|
+
errors.append(
|
|
544
|
+
error(
|
|
545
|
+
"approval_request_fingerprint_mismatch",
|
|
546
|
+
"approval_request_fingerprint does not match the signed request.",
|
|
547
|
+
"approval_request_fingerprint",
|
|
548
|
+
declared=request_fingerprint,
|
|
549
|
+
computed=expected_request_fingerprint,
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
declared = payload.get("fingerprint")
|
|
554
|
+
if isinstance(declared, str) and FINGERPRINT_RE.fullmatch(declared):
|
|
555
|
+
expected = artifact_fingerprint(payload)
|
|
556
|
+
if declared != expected:
|
|
557
|
+
errors.append(
|
|
558
|
+
error(
|
|
559
|
+
"fingerprint_mismatch",
|
|
560
|
+
"Rule-approval fingerprint does not match canonical content.",
|
|
561
|
+
"fingerprint",
|
|
562
|
+
declared=declared,
|
|
563
|
+
computed=expected,
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
return errors
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def finalize_approval_request(request: Mapping[str, Any], signature: str) -> dict[str, Any]:
|
|
570
|
+
"""Attach an externally produced signature to a validated request."""
|
|
571
|
+
|
|
572
|
+
request_errors = validate_approval_request_payload(dict(request))
|
|
573
|
+
if request_errors:
|
|
574
|
+
raise ValueError(f"invalid approval request: {request_errors[0]['code']}")
|
|
575
|
+
approval: dict[str, Any] = {
|
|
576
|
+
"schema_version": APPROVAL_SCHEMA,
|
|
577
|
+
"type": "rule_approval",
|
|
578
|
+
"approval_request_fingerprint": request["fingerprint"],
|
|
579
|
+
"rule_set_fingerprint": request["rule_set_fingerprint"],
|
|
580
|
+
"chain_id": request["chain_id"],
|
|
581
|
+
"verifying_contract": request["verifying_contract"],
|
|
582
|
+
"signer": request["signer"],
|
|
583
|
+
"signature_scheme": request["signature_scheme"],
|
|
584
|
+
"decision": request["decision"],
|
|
585
|
+
"message": request["message"],
|
|
586
|
+
"signature": signature,
|
|
587
|
+
}
|
|
588
|
+
approval["fingerprint"] = artifact_fingerprint(approval)
|
|
589
|
+
approval_errors = validate_rule_approval_payload(approval)
|
|
590
|
+
if approval_errors:
|
|
591
|
+
raise ValueError(f"invalid approval signature: {approval_errors[0]['code']}")
|
|
592
|
+
return approval
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _leaf_payload(entry: Mapping[str, Any]) -> dict[str, Any]:
|
|
596
|
+
return {
|
|
597
|
+
"rule_set_fingerprint": entry["rule_set_fingerprint"],
|
|
598
|
+
"rule_class": entry["rule_class"],
|
|
599
|
+
"visibility": entry["visibility"],
|
|
600
|
+
"approval_fingerprints": entry["approval_fingerprints"],
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def rule_anchor_leaf_hash(entry: Mapping[str, Any]) -> str:
|
|
605
|
+
"""Hash one rule-batch entry with explicit leaf domain separation."""
|
|
606
|
+
|
|
607
|
+
canonical = canonical_json_dumps(_leaf_payload(entry)).encode("utf-8")
|
|
608
|
+
return sha256_fingerprint_bytes(MERKLE_LEAF_DOMAIN + canonical)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def rule_anchor_parent_hash(left: str, right: str) -> str:
|
|
612
|
+
"""Hash two Merkle children with explicit internal-node separation."""
|
|
613
|
+
|
|
614
|
+
return sha256_fingerprint_bytes(
|
|
615
|
+
MERKLE_NODE_DOMAIN + fingerprint_bytes(left) + fingerprint_bytes(right)
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _merkle_root_and_proofs(leaf_hashes: Sequence[str]) -> tuple[str, list[list[dict[str, str]]]]:
|
|
620
|
+
if not leaf_hashes:
|
|
621
|
+
raise ValueError("at least one Merkle leaf is required")
|
|
622
|
+
|
|
623
|
+
proofs: list[list[dict[str, str]]] = [[] for _ in leaf_hashes]
|
|
624
|
+
level: list[tuple[str, list[int]]] = [
|
|
625
|
+
(leaf_hash, [index]) for index, leaf_hash in enumerate(leaf_hashes)
|
|
626
|
+
]
|
|
627
|
+
|
|
628
|
+
while len(level) > 1:
|
|
629
|
+
next_level: list[tuple[str, list[int]]] = []
|
|
630
|
+
for index in range(0, len(level), 2):
|
|
631
|
+
left_hash, left_indices = level[index]
|
|
632
|
+
if index + 1 < len(level):
|
|
633
|
+
right_hash, right_indices = level[index + 1]
|
|
634
|
+
for leaf_index in left_indices:
|
|
635
|
+
proofs[leaf_index].append({"position": "right", "hash": right_hash})
|
|
636
|
+
for leaf_index in right_indices:
|
|
637
|
+
proofs[leaf_index].append({"position": "left", "hash": left_hash})
|
|
638
|
+
combined_indices = left_indices + right_indices
|
|
639
|
+
else:
|
|
640
|
+
right_hash = left_hash
|
|
641
|
+
for leaf_index in left_indices:
|
|
642
|
+
proofs[leaf_index].append({"position": "right", "hash": right_hash})
|
|
643
|
+
combined_indices = left_indices
|
|
644
|
+
next_level.append(
|
|
645
|
+
(rule_anchor_parent_hash(left_hash, right_hash), combined_indices)
|
|
646
|
+
)
|
|
647
|
+
level = next_level
|
|
648
|
+
|
|
649
|
+
return level[0][0], proofs
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def verify_rule_anchor_proof(entry: Mapping[str, Any], merkle_root: str) -> bool:
|
|
653
|
+
"""Verify one manifest entry against its declared Merkle root."""
|
|
654
|
+
|
|
655
|
+
current = rule_anchor_leaf_hash(entry)
|
|
656
|
+
if current != entry.get("leaf_hash"):
|
|
657
|
+
return False
|
|
658
|
+
proof = entry.get("proof")
|
|
659
|
+
if not isinstance(proof, list):
|
|
660
|
+
return False
|
|
661
|
+
for node in proof:
|
|
662
|
+
if not isinstance(node, dict):
|
|
663
|
+
return False
|
|
664
|
+
sibling = node.get("hash")
|
|
665
|
+
position = node.get("position")
|
|
666
|
+
if not isinstance(sibling, str) or not FINGERPRINT_RE.fullmatch(sibling):
|
|
667
|
+
return False
|
|
668
|
+
if position == "left":
|
|
669
|
+
current = rule_anchor_parent_hash(sibling, current)
|
|
670
|
+
elif position == "right":
|
|
671
|
+
current = rule_anchor_parent_hash(current, sibling)
|
|
672
|
+
else:
|
|
673
|
+
return False
|
|
674
|
+
return current == merkle_root
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def build_rule_anchor_batch(
|
|
678
|
+
rule_sets: Sequence[Mapping[str, Any]],
|
|
679
|
+
approvals: Sequence[Mapping[str, Any]],
|
|
680
|
+
) -> dict[str, Any]:
|
|
681
|
+
"""Build a deterministic Merkle batch after validating approvals."""
|
|
682
|
+
|
|
683
|
+
if not rule_sets:
|
|
684
|
+
raise ValueError("at least one frozen rule set is required")
|
|
685
|
+
|
|
686
|
+
indexed_rules: dict[str, Mapping[str, Any]] = {}
|
|
687
|
+
for rule_set in rule_sets:
|
|
688
|
+
validation_errors = validate_frozen_rule_set_payload(dict(rule_set))
|
|
689
|
+
if validation_errors:
|
|
690
|
+
raise ValueError(f"invalid frozen rule set: {validation_errors[0]['code']}")
|
|
691
|
+
fingerprint = str(rule_set["fingerprint"])
|
|
692
|
+
if fingerprint in indexed_rules:
|
|
693
|
+
raise ValueError("duplicate frozen rule-set fingerprint")
|
|
694
|
+
indexed_rules[fingerprint] = rule_set
|
|
695
|
+
|
|
696
|
+
indexed_approvals: dict[str, list[Mapping[str, Any]]] = {}
|
|
697
|
+
seen_approval_fingerprints: set[str] = set()
|
|
698
|
+
for approval in approvals:
|
|
699
|
+
validation_errors = validate_rule_approval_payload(dict(approval))
|
|
700
|
+
if validation_errors:
|
|
701
|
+
raise ValueError(f"invalid rule approval: {validation_errors[0]['code']}")
|
|
702
|
+
approval_fingerprint = str(approval["fingerprint"])
|
|
703
|
+
if approval_fingerprint in seen_approval_fingerprints:
|
|
704
|
+
raise ValueError("duplicate rule-approval fingerprint")
|
|
705
|
+
seen_approval_fingerprints.add(approval_fingerprint)
|
|
706
|
+
rule_fingerprint = str(approval["rule_set_fingerprint"])
|
|
707
|
+
if rule_fingerprint not in indexed_rules:
|
|
708
|
+
raise ValueError("approval references a rule set outside this batch")
|
|
709
|
+
indexed_approvals.setdefault(rule_fingerprint, []).append(approval)
|
|
710
|
+
|
|
711
|
+
entries: list[dict[str, Any]] = []
|
|
712
|
+
chain_contract_pairs: set[tuple[int, str]] = set()
|
|
713
|
+
for rule_fingerprint in sorted(indexed_rules):
|
|
714
|
+
rule_set = indexed_rules[rule_fingerprint]
|
|
715
|
+
governance = rule_set["governance"]
|
|
716
|
+
authorized = {
|
|
717
|
+
str(address).lower() for address in governance["authorized_signers"]
|
|
718
|
+
}
|
|
719
|
+
rule_approvals = indexed_approvals.get(rule_fingerprint, [])
|
|
720
|
+
unique_approved_signers: set[str] = set()
|
|
721
|
+
for approval in rule_approvals:
|
|
722
|
+
signer = str(approval["signer"]).lower()
|
|
723
|
+
if signer not in authorized:
|
|
724
|
+
raise ValueError("approval signer is not authorized by the frozen rule set")
|
|
725
|
+
if signer in unique_approved_signers:
|
|
726
|
+
raise ValueError("multiple approvals from the same signer are not allowed")
|
|
727
|
+
unique_approved_signers.add(signer)
|
|
728
|
+
chain_contract_pairs.add(
|
|
729
|
+
(int(approval["chain_id"]), str(approval["verifying_contract"]).lower())
|
|
730
|
+
)
|
|
731
|
+
if len(unique_approved_signers) < int(governance["approval_threshold"]):
|
|
732
|
+
raise ValueError("frozen rule set does not meet its approval threshold")
|
|
733
|
+
|
|
734
|
+
entry = {
|
|
735
|
+
"rule_set_fingerprint": rule_fingerprint,
|
|
736
|
+
"rule_class": rule_set["rule_class"],
|
|
737
|
+
"visibility": rule_set["visibility"],
|
|
738
|
+
"approval_fingerprints": sorted(
|
|
739
|
+
str(approval["fingerprint"]) for approval in rule_approvals
|
|
740
|
+
),
|
|
741
|
+
}
|
|
742
|
+
entry["leaf_hash"] = rule_anchor_leaf_hash(entry)
|
|
743
|
+
entries.append(entry)
|
|
744
|
+
|
|
745
|
+
if len(chain_contract_pairs) != 1:
|
|
746
|
+
raise ValueError("all approvals in a batch must bind to one chain and contract")
|
|
747
|
+
chain_id, verifying_contract = next(iter(chain_contract_pairs))
|
|
748
|
+
|
|
749
|
+
merkle_root, proofs = _merkle_root_and_proofs(
|
|
750
|
+
[str(entry["leaf_hash"]) for entry in entries]
|
|
751
|
+
)
|
|
752
|
+
for entry, proof in zip(entries, proofs, strict=True):
|
|
753
|
+
entry["proof"] = proof
|
|
754
|
+
|
|
755
|
+
visibility_mask = 0
|
|
756
|
+
if any(entry["visibility"] == "public" for entry in entries):
|
|
757
|
+
visibility_mask |= 1
|
|
758
|
+
if any(entry["visibility"] == "private_commitment" for entry in entries):
|
|
759
|
+
visibility_mask |= 2
|
|
760
|
+
|
|
761
|
+
batch: dict[str, Any] = {
|
|
762
|
+
"schema_version": ANCHOR_BATCH_SCHEMA,
|
|
763
|
+
"type": "rule_anchor_batch",
|
|
764
|
+
"batch_id": f"rule-anchor-batch:{merkle_root[7:31]}",
|
|
765
|
+
"hash_algorithm": "sha256",
|
|
766
|
+
"merkle_scheme": "core.sha256_merkle.v1",
|
|
767
|
+
"chain_id": chain_id,
|
|
768
|
+
"verifying_contract": verifying_contract,
|
|
769
|
+
"rule_set_count": len(entries),
|
|
770
|
+
"visibility_mask": visibility_mask,
|
|
771
|
+
"entries": entries,
|
|
772
|
+
"merkle_root": merkle_root,
|
|
773
|
+
}
|
|
774
|
+
batch["manifest_fingerprint"] = artifact_fingerprint(batch, "manifest_fingerprint")
|
|
775
|
+
return batch
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def validate_rule_anchor_batch_payload(payload: Any) -> list[dict[str, Any]]:
|
|
779
|
+
"""Validate a batch manifest, every leaf, and every Merkle proof."""
|
|
780
|
+
|
|
781
|
+
errors = _schema_errors(payload, "rule_anchor_batch.v1.json")
|
|
782
|
+
if not isinstance(payload, dict):
|
|
783
|
+
return errors
|
|
784
|
+
|
|
785
|
+
entries = payload.get("entries")
|
|
786
|
+
root = payload.get("merkle_root")
|
|
787
|
+
if isinstance(entries, list):
|
|
788
|
+
fingerprints = [
|
|
789
|
+
entry.get("rule_set_fingerprint")
|
|
790
|
+
for entry in entries
|
|
791
|
+
if isinstance(entry, dict)
|
|
792
|
+
]
|
|
793
|
+
if len(fingerprints) != len(set(fingerprints)):
|
|
794
|
+
errors.append(
|
|
795
|
+
error(
|
|
796
|
+
"duplicate_rule_set_fingerprint",
|
|
797
|
+
"Batch entries must reference unique rule sets.",
|
|
798
|
+
"entries",
|
|
799
|
+
)
|
|
800
|
+
)
|
|
801
|
+
if fingerprints != sorted(fingerprints):
|
|
802
|
+
errors.append(
|
|
803
|
+
error(
|
|
804
|
+
"noncanonical_entry_order",
|
|
805
|
+
"Batch entries must be sorted by rule_set_fingerprint.",
|
|
806
|
+
"entries",
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
if payload.get("rule_set_count") != len(entries):
|
|
810
|
+
errors.append(
|
|
811
|
+
error(
|
|
812
|
+
"rule_set_count_mismatch",
|
|
813
|
+
"rule_set_count must equal the number of entries.",
|
|
814
|
+
"rule_set_count",
|
|
815
|
+
)
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
expected_mask = 0
|
|
819
|
+
for index, entry in enumerate(entries):
|
|
820
|
+
if not isinstance(entry, dict):
|
|
821
|
+
continue
|
|
822
|
+
approval_fingerprints = entry.get("approval_fingerprints")
|
|
823
|
+
if isinstance(approval_fingerprints, list):
|
|
824
|
+
if approval_fingerprints != sorted(approval_fingerprints):
|
|
825
|
+
errors.append(
|
|
826
|
+
error(
|
|
827
|
+
"noncanonical_approval_order",
|
|
828
|
+
"approval_fingerprints must be sorted.",
|
|
829
|
+
f"entries.{index}.approval_fingerprints",
|
|
830
|
+
)
|
|
831
|
+
)
|
|
832
|
+
if len(approval_fingerprints) != len(set(approval_fingerprints)):
|
|
833
|
+
errors.append(
|
|
834
|
+
error(
|
|
835
|
+
"duplicate_approval_fingerprint",
|
|
836
|
+
"approval_fingerprints must be unique per entry.",
|
|
837
|
+
f"entries.{index}.approval_fingerprints",
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
if entry.get("visibility") == "public":
|
|
841
|
+
expected_mask |= 1
|
|
842
|
+
if entry.get("visibility") == "private_commitment":
|
|
843
|
+
expected_mask |= 2
|
|
844
|
+
if isinstance(root, str) and FINGERPRINT_RE.fullmatch(root):
|
|
845
|
+
try:
|
|
846
|
+
proof_valid = verify_rule_anchor_proof(entry, root)
|
|
847
|
+
except (KeyError, TypeError, ValueError):
|
|
848
|
+
proof_valid = False
|
|
849
|
+
if not proof_valid:
|
|
850
|
+
errors.append(
|
|
851
|
+
error(
|
|
852
|
+
"invalid_merkle_proof",
|
|
853
|
+
"Entry leaf or proof does not resolve to merkle_root.",
|
|
854
|
+
f"entries.{index}.proof",
|
|
855
|
+
)
|
|
856
|
+
)
|
|
857
|
+
if payload.get("visibility_mask") != expected_mask:
|
|
858
|
+
errors.append(
|
|
859
|
+
error(
|
|
860
|
+
"visibility_mask_mismatch",
|
|
861
|
+
"visibility_mask does not match batch entry visibility.",
|
|
862
|
+
"visibility_mask",
|
|
863
|
+
)
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
if isinstance(root, str) and FINGERPRINT_RE.fullmatch(root):
|
|
867
|
+
expected_batch_id = f"rule-anchor-batch:{root[7:31]}"
|
|
868
|
+
if payload.get("batch_id") != expected_batch_id:
|
|
869
|
+
errors.append(
|
|
870
|
+
error(
|
|
871
|
+
"batch_id_mismatch",
|
|
872
|
+
"batch_id must be derived from the Merkle root.",
|
|
873
|
+
"batch_id",
|
|
874
|
+
)
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
declared = payload.get("manifest_fingerprint")
|
|
878
|
+
if isinstance(declared, str) and FINGERPRINT_RE.fullmatch(declared):
|
|
879
|
+
expected = artifact_fingerprint(payload, "manifest_fingerprint")
|
|
880
|
+
if declared != expected:
|
|
881
|
+
errors.append(
|
|
882
|
+
error(
|
|
883
|
+
"manifest_fingerprint_mismatch",
|
|
884
|
+
"Batch manifest fingerprint does not match canonical content.",
|
|
885
|
+
"manifest_fingerprint",
|
|
886
|
+
declared=declared,
|
|
887
|
+
computed=expected,
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
return errors
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def encode_anchor_rule_batch_calldata(batch: Mapping[str, Any]) -> str:
|
|
894
|
+
"""ABI-encode ``anchorRuleBatch`` without requiring a wallet library."""
|
|
895
|
+
|
|
896
|
+
errors = validate_rule_anchor_batch_payload(dict(batch))
|
|
897
|
+
if errors:
|
|
898
|
+
raise ValueError(f"invalid rule anchor batch: {errors[0]['code']}")
|
|
899
|
+
|
|
900
|
+
count = int(batch["rule_set_count"])
|
|
901
|
+
mask = int(batch["visibility_mask"])
|
|
902
|
+
if count > (2**32 - 1):
|
|
903
|
+
raise ValueError("rule_set_count exceeds uint32")
|
|
904
|
+
if mask > 255:
|
|
905
|
+
raise ValueError("visibility_mask exceeds uint8")
|
|
906
|
+
encoded = "".join(
|
|
907
|
+
(
|
|
908
|
+
ANCHOR_RULE_BATCH_SELECTOR,
|
|
909
|
+
fingerprint_bytes(str(batch["merkle_root"])).hex(),
|
|
910
|
+
fingerprint_bytes(str(batch["manifest_fingerprint"])).hex(),
|
|
911
|
+
count.to_bytes(32, "big").hex(),
|
|
912
|
+
mask.to_bytes(32, "big").hex(),
|
|
913
|
+
)
|
|
914
|
+
)
|
|
915
|
+
return "0x" + encoded
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def build_unsigned_rule_anchor_request(
|
|
919
|
+
batch: Mapping[str, Any],
|
|
920
|
+
submitter: str,
|
|
921
|
+
*,
|
|
922
|
+
nonce: int | None = None,
|
|
923
|
+
gas_limit: int | None = None,
|
|
924
|
+
max_fee_per_gas_wei: int | None = None,
|
|
925
|
+
max_priority_fee_per_gas_wei: int | None = None,
|
|
926
|
+
gas_price_wei: int | None = None,
|
|
927
|
+
reserve_batches: int = 4,
|
|
928
|
+
safety_multiplier_bps: int = 12_500,
|
|
929
|
+
observed_balance_wei: int | None = None,
|
|
930
|
+
contract_code_verified: bool | None = None,
|
|
931
|
+
) -> dict[str, Any]:
|
|
932
|
+
"""Build an unsigned transaction and native-gas reserve calculation."""
|
|
933
|
+
|
|
934
|
+
if not ADDRESS_RE.fullmatch(submitter):
|
|
935
|
+
raise ValueError("submitter must be an EVM address")
|
|
936
|
+
if reserve_batches < 1:
|
|
937
|
+
raise ValueError("reserve_batches must be at least 1")
|
|
938
|
+
if safety_multiplier_bps < 10_000:
|
|
939
|
+
raise ValueError("safety_multiplier_bps cannot be below 10000")
|
|
940
|
+
if gas_price_wei is not None and max_fee_per_gas_wei is not None:
|
|
941
|
+
raise ValueError("legacy gas_price_wei and EIP-1559 max fee are mutually exclusive")
|
|
942
|
+
|
|
943
|
+
errors = validate_rule_anchor_batch_payload(dict(batch))
|
|
944
|
+
if errors:
|
|
945
|
+
raise ValueError(f"invalid rule anchor batch: {errors[0]['code']}")
|
|
946
|
+
|
|
947
|
+
fee_per_gas = max_fee_per_gas_wei if max_fee_per_gas_wei is not None else gas_price_wei
|
|
948
|
+
per_batch_max_cost: int | None = None
|
|
949
|
+
required_balance: int | None = None
|
|
950
|
+
shortfall: int | None = None
|
|
951
|
+
sufficient: bool | None = None
|
|
952
|
+
max_cost_per_rule: int | None = None
|
|
953
|
+
if gas_limit is not None and fee_per_gas is not None:
|
|
954
|
+
raw_batch_cost = gas_limit * fee_per_gas
|
|
955
|
+
per_batch_max_cost = (raw_batch_cost * safety_multiplier_bps + 9_999) // 10_000
|
|
956
|
+
required_balance = per_batch_max_cost * reserve_batches
|
|
957
|
+
count = int(batch["rule_set_count"])
|
|
958
|
+
max_cost_per_rule = (per_batch_max_cost + count - 1) // count
|
|
959
|
+
if observed_balance_wei is not None:
|
|
960
|
+
sufficient = observed_balance_wei >= required_balance
|
|
961
|
+
shortfall = max(0, required_balance - observed_balance_wei)
|
|
962
|
+
|
|
963
|
+
if sufficient is False:
|
|
964
|
+
readiness = "insufficient_balance"
|
|
965
|
+
elif contract_code_verified is not True:
|
|
966
|
+
readiness = "contract_unverified"
|
|
967
|
+
elif sufficient is True:
|
|
968
|
+
readiness = "ready"
|
|
969
|
+
elif gas_limit is None or fee_per_gas is None:
|
|
970
|
+
readiness = "offline_unpriced"
|
|
971
|
+
else:
|
|
972
|
+
readiness = "balance_unobserved"
|
|
973
|
+
|
|
974
|
+
request: dict[str, Any] = {
|
|
975
|
+
"schema_version": UNSIGNED_TRANSACTION_SCHEMA,
|
|
976
|
+
"type": "unsigned_rule_anchor_transaction",
|
|
977
|
+
"signing_mode": "external_wallet_only",
|
|
978
|
+
"broadcast": False,
|
|
979
|
+
"batch_manifest_fingerprint": batch["manifest_fingerprint"],
|
|
980
|
+
"rule_set_count": batch["rule_set_count"],
|
|
981
|
+
"transaction": {
|
|
982
|
+
"from": submitter.lower(),
|
|
983
|
+
"to": str(batch["verifying_contract"]).lower(),
|
|
984
|
+
"chain_id": batch["chain_id"],
|
|
985
|
+
"value_wei": 0,
|
|
986
|
+
"data": encode_anchor_rule_batch_calldata(batch),
|
|
987
|
+
"nonce": nonce,
|
|
988
|
+
"gas_limit": gas_limit,
|
|
989
|
+
"max_fee_per_gas_wei": max_fee_per_gas_wei,
|
|
990
|
+
"max_priority_fee_per_gas_wei": max_priority_fee_per_gas_wei,
|
|
991
|
+
"gas_price_wei": gas_price_wei,
|
|
992
|
+
},
|
|
993
|
+
"gas_reserve": {
|
|
994
|
+
"unit": "native_wei",
|
|
995
|
+
"reserve_batches": reserve_batches,
|
|
996
|
+
"safety_multiplier_bps": safety_multiplier_bps,
|
|
997
|
+
"per_batch_max_cost_wei": per_batch_max_cost,
|
|
998
|
+
"max_cost_per_rule_wei": max_cost_per_rule,
|
|
999
|
+
"required_balance_wei": required_balance,
|
|
1000
|
+
"observed_balance_wei": observed_balance_wei,
|
|
1001
|
+
"shortfall_wei": shortfall,
|
|
1002
|
+
"sufficient": sufficient,
|
|
1003
|
+
},
|
|
1004
|
+
"contract_code_verified": contract_code_verified,
|
|
1005
|
+
"readiness": readiness,
|
|
1006
|
+
"warnings": [
|
|
1007
|
+
"This artifact is unsigned and must be reviewed and signed by an external wallet.",
|
|
1008
|
+
"CORE never requests, receives, stores, or transmits wallet secrets.",
|
|
1009
|
+
"Gas reserve is advisory and denominated only in the network native asset.",
|
|
1010
|
+
],
|
|
1011
|
+
}
|
|
1012
|
+
request["fingerprint"] = artifact_fingerprint(request)
|
|
1013
|
+
schema_errors = _schema_errors(request, "unsigned_rule_anchor_transaction.v1.json")
|
|
1014
|
+
if schema_errors:
|
|
1015
|
+
raise ValueError(f"unsigned transaction schema error: {schema_errors[0]['message']}")
|
|
1016
|
+
return request
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def validate_unsigned_rule_anchor_request_payload(payload: Any) -> list[dict[str, Any]]:
|
|
1020
|
+
"""Validate an unsigned transaction and recompute every derived value."""
|
|
1021
|
+
|
|
1022
|
+
errors = _schema_errors(payload, "unsigned_rule_anchor_transaction.v1.json")
|
|
1023
|
+
if not isinstance(payload, dict):
|
|
1024
|
+
return errors
|
|
1025
|
+
|
|
1026
|
+
transaction = payload.get("transaction")
|
|
1027
|
+
reserve = payload.get("gas_reserve")
|
|
1028
|
+
if not isinstance(transaction, dict) or not isinstance(reserve, dict):
|
|
1029
|
+
return errors
|
|
1030
|
+
|
|
1031
|
+
errors.extend(_wallet_secret_errors(payload, "Unsigned transaction"))
|
|
1032
|
+
|
|
1033
|
+
max_fee = transaction.get("max_fee_per_gas_wei")
|
|
1034
|
+
priority_fee = transaction.get("max_priority_fee_per_gas_wei")
|
|
1035
|
+
gas_price = transaction.get("gas_price_wei")
|
|
1036
|
+
if max_fee is not None and gas_price is not None:
|
|
1037
|
+
errors.append(
|
|
1038
|
+
error(
|
|
1039
|
+
"mutually_exclusive_fee_modes",
|
|
1040
|
+
"EIP-1559 max fee and legacy gas price cannot both be set.",
|
|
1041
|
+
"transaction",
|
|
1042
|
+
)
|
|
1043
|
+
)
|
|
1044
|
+
if (
|
|
1045
|
+
isinstance(max_fee, int)
|
|
1046
|
+
and not isinstance(max_fee, bool)
|
|
1047
|
+
and isinstance(priority_fee, int)
|
|
1048
|
+
and not isinstance(priority_fee, bool)
|
|
1049
|
+
and priority_fee > max_fee
|
|
1050
|
+
):
|
|
1051
|
+
errors.append(
|
|
1052
|
+
error(
|
|
1053
|
+
"priority_fee_exceeds_max_fee",
|
|
1054
|
+
"max_priority_fee_per_gas_wei cannot exceed max_fee_per_gas_wei.",
|
|
1055
|
+
"transaction.max_priority_fee_per_gas_wei",
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
data = transaction.get("data")
|
|
1060
|
+
manifest = payload.get("batch_manifest_fingerprint")
|
|
1061
|
+
rule_count = payload.get("rule_set_count")
|
|
1062
|
+
if isinstance(data, str) and re.fullmatch(r"0x[a-f0-9]{264}", data):
|
|
1063
|
+
selector = data[2:10]
|
|
1064
|
+
encoded_manifest = data[74:138]
|
|
1065
|
+
encoded_count = int(data[138:202], 16)
|
|
1066
|
+
if selector != ANCHOR_RULE_BATCH_SELECTOR:
|
|
1067
|
+
errors.append(
|
|
1068
|
+
error(
|
|
1069
|
+
"calldata_selector_mismatch",
|
|
1070
|
+
"Transaction data does not call anchorRuleBatch.",
|
|
1071
|
+
"transaction.data",
|
|
1072
|
+
)
|
|
1073
|
+
)
|
|
1074
|
+
if isinstance(manifest, str) and encoded_manifest != manifest.removeprefix("sha256:"):
|
|
1075
|
+
errors.append(
|
|
1076
|
+
error(
|
|
1077
|
+
"calldata_manifest_mismatch",
|
|
1078
|
+
"Calldata manifest hash does not match batch_manifest_fingerprint.",
|
|
1079
|
+
"transaction.data",
|
|
1080
|
+
)
|
|
1081
|
+
)
|
|
1082
|
+
if isinstance(rule_count, int) and not isinstance(rule_count, bool) and encoded_count != rule_count:
|
|
1083
|
+
errors.append(
|
|
1084
|
+
error(
|
|
1085
|
+
"calldata_rule_count_mismatch",
|
|
1086
|
+
"Calldata rule count does not match rule_set_count.",
|
|
1087
|
+
"transaction.data",
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
gas_limit = transaction.get("gas_limit")
|
|
1092
|
+
fee_per_gas = max_fee if max_fee is not None else gas_price
|
|
1093
|
+
reserve_batches = reserve.get("reserve_batches")
|
|
1094
|
+
multiplier = reserve.get("safety_multiplier_bps")
|
|
1095
|
+
observed_balance = reserve.get("observed_balance_wei")
|
|
1096
|
+
expected_per_batch: int | None = None
|
|
1097
|
+
expected_per_rule: int | None = None
|
|
1098
|
+
expected_required: int | None = None
|
|
1099
|
+
expected_shortfall: int | None = None
|
|
1100
|
+
expected_sufficient: bool | None = None
|
|
1101
|
+
if all(
|
|
1102
|
+
isinstance(value, int) and not isinstance(value, bool)
|
|
1103
|
+
for value in (gas_limit, fee_per_gas, reserve_batches, multiplier, rule_count)
|
|
1104
|
+
):
|
|
1105
|
+
raw_batch_cost = gas_limit * fee_per_gas
|
|
1106
|
+
expected_per_batch = (raw_batch_cost * multiplier + 9_999) // 10_000
|
|
1107
|
+
expected_per_rule = (expected_per_batch + rule_count - 1) // rule_count
|
|
1108
|
+
expected_required = expected_per_batch * reserve_batches
|
|
1109
|
+
if isinstance(observed_balance, int) and not isinstance(observed_balance, bool):
|
|
1110
|
+
expected_sufficient = observed_balance >= expected_required
|
|
1111
|
+
expected_shortfall = max(0, expected_required - observed_balance)
|
|
1112
|
+
|
|
1113
|
+
derived = {
|
|
1114
|
+
"per_batch_max_cost_wei": expected_per_batch,
|
|
1115
|
+
"max_cost_per_rule_wei": expected_per_rule,
|
|
1116
|
+
"required_balance_wei": expected_required,
|
|
1117
|
+
"shortfall_wei": expected_shortfall,
|
|
1118
|
+
"sufficient": expected_sufficient,
|
|
1119
|
+
}
|
|
1120
|
+
for key, expected in derived.items():
|
|
1121
|
+
if reserve.get(key) != expected:
|
|
1122
|
+
errors.append(
|
|
1123
|
+
error(
|
|
1124
|
+
"gas_reserve_mismatch",
|
|
1125
|
+
f"{key} does not match deterministic gas-reserve calculation.",
|
|
1126
|
+
f"gas_reserve.{key}",
|
|
1127
|
+
declared=reserve.get(key),
|
|
1128
|
+
computed=expected,
|
|
1129
|
+
)
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
if expected_sufficient is False:
|
|
1133
|
+
expected_readiness = "insufficient_balance"
|
|
1134
|
+
elif payload.get("contract_code_verified") is not True:
|
|
1135
|
+
expected_readiness = "contract_unverified"
|
|
1136
|
+
elif expected_sufficient is True:
|
|
1137
|
+
expected_readiness = "ready"
|
|
1138
|
+
elif gas_limit is None or fee_per_gas is None:
|
|
1139
|
+
expected_readiness = "offline_unpriced"
|
|
1140
|
+
else:
|
|
1141
|
+
expected_readiness = "balance_unobserved"
|
|
1142
|
+
if payload.get("readiness") != expected_readiness:
|
|
1143
|
+
errors.append(
|
|
1144
|
+
error(
|
|
1145
|
+
"readiness_mismatch",
|
|
1146
|
+
"readiness does not match verified contract code, price, and reserve evidence.",
|
|
1147
|
+
"readiness",
|
|
1148
|
+
declared=payload.get("readiness"),
|
|
1149
|
+
computed=expected_readiness,
|
|
1150
|
+
)
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
declared_fingerprint = payload.get("fingerprint")
|
|
1154
|
+
if isinstance(declared_fingerprint, str) and FINGERPRINT_RE.fullmatch(declared_fingerprint):
|
|
1155
|
+
computed_fingerprint = artifact_fingerprint(payload)
|
|
1156
|
+
if declared_fingerprint != computed_fingerprint:
|
|
1157
|
+
errors.append(
|
|
1158
|
+
error(
|
|
1159
|
+
"fingerprint_mismatch",
|
|
1160
|
+
"Unsigned transaction fingerprint does not match canonical content.",
|
|
1161
|
+
"fingerprint",
|
|
1162
|
+
declared=declared_fingerprint,
|
|
1163
|
+
computed=computed_fingerprint,
|
|
1164
|
+
)
|
|
1165
|
+
)
|
|
1166
|
+
return errors
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def validate_unsigned_rule_anchor_deployment_payload(payload: Any) -> list[dict[str, Any]]:
|
|
1170
|
+
"""Validate an unsigned deployment and recompute all locally provable fields."""
|
|
1171
|
+
|
|
1172
|
+
errors = _schema_errors(payload, "unsigned_rule_anchor_deployment.v1.json")
|
|
1173
|
+
if not isinstance(payload, dict):
|
|
1174
|
+
return errors
|
|
1175
|
+
|
|
1176
|
+
errors.extend(_wallet_secret_errors(payload, "Unsigned deployment"))
|
|
1177
|
+
transaction = payload.get("transaction")
|
|
1178
|
+
reserve = payload.get("gas_reserve")
|
|
1179
|
+
if not isinstance(transaction, dict) or not isinstance(reserve, dict):
|
|
1180
|
+
return errors
|
|
1181
|
+
|
|
1182
|
+
deployer = transaction.get("from")
|
|
1183
|
+
if isinstance(deployer, str) and ADDRESS_RE.fullmatch(deployer) and deployer != deployer.lower():
|
|
1184
|
+
errors.append(
|
|
1185
|
+
error(
|
|
1186
|
+
"noncanonical_deployer_address",
|
|
1187
|
+
"transaction.from must use canonical lowercase hexadecimal.",
|
|
1188
|
+
"transaction.from",
|
|
1189
|
+
)
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
try:
|
|
1193
|
+
build, creation_hex = load_verified_rule_anchor_build()
|
|
1194
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1195
|
+
errors.append(
|
|
1196
|
+
error(
|
|
1197
|
+
"frozen_contract_build_unverified",
|
|
1198
|
+
"Frozen CoreRuleAnchor build artifacts could not be verified.",
|
|
1199
|
+
"contract_build_fingerprint",
|
|
1200
|
+
reason=exc.__class__.__name__,
|
|
1201
|
+
)
|
|
1202
|
+
)
|
|
1203
|
+
else:
|
|
1204
|
+
expected_build_fingerprint = canonical_fingerprint(build)
|
|
1205
|
+
if payload.get("contract_build_fingerprint") != expected_build_fingerprint:
|
|
1206
|
+
errors.append(
|
|
1207
|
+
error(
|
|
1208
|
+
"contract_build_fingerprint_mismatch",
|
|
1209
|
+
"contract_build_fingerprint does not identify the verified frozen build.",
|
|
1210
|
+
"contract_build_fingerprint",
|
|
1211
|
+
declared=payload.get("contract_build_fingerprint"),
|
|
1212
|
+
computed=expected_build_fingerprint,
|
|
1213
|
+
)
|
|
1214
|
+
)
|
|
1215
|
+
expected_runtime = build.get("runtime_bytecode_sha256")
|
|
1216
|
+
if payload.get("expected_runtime_bytecode_sha256") != expected_runtime:
|
|
1217
|
+
errors.append(
|
|
1218
|
+
error(
|
|
1219
|
+
"runtime_bytecode_fingerprint_mismatch",
|
|
1220
|
+
"Expected runtime bytecode does not match the verified frozen build.",
|
|
1221
|
+
"expected_runtime_bytecode_sha256",
|
|
1222
|
+
declared=payload.get("expected_runtime_bytecode_sha256"),
|
|
1223
|
+
computed=expected_runtime,
|
|
1224
|
+
)
|
|
1225
|
+
)
|
|
1226
|
+
if transaction.get("data") != "0x" + creation_hex:
|
|
1227
|
+
errors.append(
|
|
1228
|
+
error(
|
|
1229
|
+
"deployment_bytecode_mismatch",
|
|
1230
|
+
"transaction.data does not contain the verified creation bytecode.",
|
|
1231
|
+
"transaction.data",
|
|
1232
|
+
)
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
max_fee = transaction.get("max_fee_per_gas_wei")
|
|
1236
|
+
priority_fee = transaction.get("max_priority_fee_per_gas_wei")
|
|
1237
|
+
gas_price = transaction.get("gas_price_wei")
|
|
1238
|
+
if max_fee is not None and gas_price is not None:
|
|
1239
|
+
errors.append(
|
|
1240
|
+
error(
|
|
1241
|
+
"mutually_exclusive_fee_modes",
|
|
1242
|
+
"EIP-1559 max fee and legacy gas price cannot both be set.",
|
|
1243
|
+
"transaction",
|
|
1244
|
+
)
|
|
1245
|
+
)
|
|
1246
|
+
if priority_fee is not None and max_fee is None:
|
|
1247
|
+
errors.append(
|
|
1248
|
+
error(
|
|
1249
|
+
"priority_fee_without_max_fee",
|
|
1250
|
+
"max_priority_fee_per_gas_wei requires max_fee_per_gas_wei.",
|
|
1251
|
+
"transaction.max_priority_fee_per_gas_wei",
|
|
1252
|
+
)
|
|
1253
|
+
)
|
|
1254
|
+
if (
|
|
1255
|
+
isinstance(max_fee, int)
|
|
1256
|
+
and not isinstance(max_fee, bool)
|
|
1257
|
+
and isinstance(priority_fee, int)
|
|
1258
|
+
and not isinstance(priority_fee, bool)
|
|
1259
|
+
and priority_fee > max_fee
|
|
1260
|
+
):
|
|
1261
|
+
errors.append(
|
|
1262
|
+
error(
|
|
1263
|
+
"priority_fee_exceeds_max_fee",
|
|
1264
|
+
"max_priority_fee_per_gas_wei cannot exceed max_fee_per_gas_wei.",
|
|
1265
|
+
"transaction.max_priority_fee_per_gas_wei",
|
|
1266
|
+
)
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
gas_limit = transaction.get("gas_limit")
|
|
1270
|
+
fee_per_gas = max_fee if max_fee is not None else gas_price
|
|
1271
|
+
post_reserve = reserve.get("post_deployment_reserve_wei")
|
|
1272
|
+
observed_balance = reserve.get("observed_balance_wei")
|
|
1273
|
+
expected_cost: int | None = None
|
|
1274
|
+
expected_required: int | None = None
|
|
1275
|
+
expected_shortfall: int | None = None
|
|
1276
|
+
expected_sufficient: bool | None = None
|
|
1277
|
+
if all(
|
|
1278
|
+
isinstance(value, int) and not isinstance(value, bool)
|
|
1279
|
+
for value in (gas_limit, fee_per_gas, post_reserve)
|
|
1280
|
+
):
|
|
1281
|
+
expected_cost = gas_limit * fee_per_gas
|
|
1282
|
+
expected_required = expected_cost + post_reserve
|
|
1283
|
+
if isinstance(observed_balance, int) and not isinstance(observed_balance, bool):
|
|
1284
|
+
expected_sufficient = observed_balance >= expected_required
|
|
1285
|
+
expected_shortfall = max(0, expected_required - observed_balance)
|
|
1286
|
+
|
|
1287
|
+
derived = {
|
|
1288
|
+
"deployment_max_cost_wei": expected_cost,
|
|
1289
|
+
"required_balance_wei": expected_required,
|
|
1290
|
+
"shortfall_wei": expected_shortfall,
|
|
1291
|
+
"sufficient": expected_sufficient,
|
|
1292
|
+
}
|
|
1293
|
+
for key, expected in derived.items():
|
|
1294
|
+
if reserve.get(key) != expected:
|
|
1295
|
+
errors.append(
|
|
1296
|
+
error(
|
|
1297
|
+
"gas_reserve_mismatch",
|
|
1298
|
+
f"{key} does not match deterministic deployment-reserve calculation.",
|
|
1299
|
+
f"gas_reserve.{key}",
|
|
1300
|
+
declared=reserve.get(key),
|
|
1301
|
+
computed=expected,
|
|
1302
|
+
)
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
if expected_sufficient is True:
|
|
1306
|
+
expected_readiness = "ready"
|
|
1307
|
+
elif expected_sufficient is False:
|
|
1308
|
+
expected_readiness = "insufficient_balance"
|
|
1309
|
+
elif expected_cost is None:
|
|
1310
|
+
expected_readiness = "offline_unpriced"
|
|
1311
|
+
else:
|
|
1312
|
+
expected_readiness = "balance_unobserved"
|
|
1313
|
+
if payload.get("readiness") != expected_readiness:
|
|
1314
|
+
errors.append(
|
|
1315
|
+
error(
|
|
1316
|
+
"readiness_mismatch",
|
|
1317
|
+
"readiness does not match the recomputed price and balance evidence.",
|
|
1318
|
+
"readiness",
|
|
1319
|
+
declared=payload.get("readiness"),
|
|
1320
|
+
computed=expected_readiness,
|
|
1321
|
+
)
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
warnings = payload.get("warnings")
|
|
1325
|
+
if isinstance(warnings, list):
|
|
1326
|
+
missing_warnings = sorted(set(UNSIGNED_DEPLOYMENT_WARNINGS) - set(warnings))
|
|
1327
|
+
if missing_warnings:
|
|
1328
|
+
errors.append(
|
|
1329
|
+
error(
|
|
1330
|
+
"required_safety_warning_missing",
|
|
1331
|
+
"Unsigned deployment omits a required external-wallet safety warning.",
|
|
1332
|
+
"warnings",
|
|
1333
|
+
missing=missing_warnings,
|
|
1334
|
+
)
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
declared_fingerprint = payload.get("fingerprint")
|
|
1338
|
+
if isinstance(declared_fingerprint, str) and FINGERPRINT_RE.fullmatch(declared_fingerprint):
|
|
1339
|
+
computed_fingerprint = artifact_fingerprint(payload)
|
|
1340
|
+
if declared_fingerprint != computed_fingerprint:
|
|
1341
|
+
errors.append(
|
|
1342
|
+
error(
|
|
1343
|
+
"fingerprint_mismatch",
|
|
1344
|
+
"Unsigned deployment fingerprint does not match canonical content.",
|
|
1345
|
+
"fingerprint",
|
|
1346
|
+
declared=declared_fingerprint,
|
|
1347
|
+
computed=computed_fingerprint,
|
|
1348
|
+
)
|
|
1349
|
+
)
|
|
1350
|
+
return errors
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def validate_rule_anchor_chain_evidence_payload(payload: Any) -> list[dict[str, Any]]:
|
|
1354
|
+
"""Validate persisted evidence from read-only on-chain verification."""
|
|
1355
|
+
|
|
1356
|
+
errors = _schema_errors(payload, "rule_anchor_chain_evidence.v1.json")
|
|
1357
|
+
if not isinstance(payload, dict):
|
|
1358
|
+
return errors
|
|
1359
|
+
confirmations = payload.get("confirmations")
|
|
1360
|
+
required = payload.get("required_confirmations")
|
|
1361
|
+
if (
|
|
1362
|
+
isinstance(confirmations, int)
|
|
1363
|
+
and not isinstance(confirmations, bool)
|
|
1364
|
+
and isinstance(required, int)
|
|
1365
|
+
and not isinstance(required, bool)
|
|
1366
|
+
and confirmations < required
|
|
1367
|
+
):
|
|
1368
|
+
errors.append(
|
|
1369
|
+
error(
|
|
1370
|
+
"insufficient_confirmations",
|
|
1371
|
+
"Persisted chain evidence must meet required_confirmations.",
|
|
1372
|
+
"confirmations",
|
|
1373
|
+
)
|
|
1374
|
+
)
|
|
1375
|
+
declared = payload.get("fingerprint")
|
|
1376
|
+
if isinstance(declared, str) and FINGERPRINT_RE.fullmatch(declared):
|
|
1377
|
+
expected = artifact_fingerprint(payload)
|
|
1378
|
+
if declared != expected:
|
|
1379
|
+
errors.append(
|
|
1380
|
+
error(
|
|
1381
|
+
"fingerprint_mismatch",
|
|
1382
|
+
"Chain-evidence fingerprint does not match canonical content.",
|
|
1383
|
+
"fingerprint",
|
|
1384
|
+
declared=declared,
|
|
1385
|
+
computed=expected,
|
|
1386
|
+
)
|
|
1387
|
+
)
|
|
1388
|
+
return errors
|