Skip to content

Commit 719da58

Browse files
committed
fix(blockchain): correct EAS schema UID derivation and harden event decoding
The EAS SchemaRegistry derives schema UIDs as keccak256(abi.encodePacked(schema, resolver, revocable)). The previous fallback in _ensure_schema_registered hashed only the schema string, which produces a completely different UID than the one the on-chain contract assigns. Any attestation using that fallback UID would fail verification against the real schema, breaking the entire anchoring flow whenever log parsing failed (for example, if a node reorders receipt logs or the first log turns out to belong to a different contract). This commit: 1. Adds BlockchainService.compute_schema_uid(schema, resolver, revocable), a static method that mirrors SchemaRegistry._getUID exactly. The method uses eth_abi.packed.encode_packed and Web3.keccak to produce the same 32-byte UID the on-chain registry would emit. 2. Rewrites _ensure_schema_registered to: - Derive the canonical UID offline before spending any gas. - Short-circuit via SchemaRegistry.getSchema(uid) when the schema is already registered, avoiding redundant transactions. - Scan every log (not just receipt.logs[0]) for a non-zero indexed uid in topics[1] after registration. - Fall back to the canonical UID (never keccak(schema) alone) if log parsing returns nothing, since the canonical UID is provably equal to the on-chain value by construction. 3. Replaces the brittle byte-slicing Attested event parser in anchor_artifact with _extract_attestation_uid, which uses contract.events.Attested().process_log(log) for ABI-correct decoding. The helper also has a topic-signature fallback so it still works if other contracts emit logs in the same transaction. Previously the code assumed the first log was always Attested and that its data started with the uid, which is not guaranteed by the EAS interface. 4. Adds Attested / Revoked / Registered event fragments to blockchain/abi.py so web3.py can decode them, and pins canonical schema-UID placeholder constants (ATTESTIX_SCHEMA_UID_BASE_SEPOLIA / _MAINNET) for the follow-up registration SOP to populate. Tests (tests/unit/test_blockchain.py) verify: - compute_schema_uid matches keccak(encode_packed(...)) for the Attestix schema and for a known EAS sample schema (vote). - UID changes when resolver or revocable flag changes. - Canonical UID differs from keccak(schema) alone (guards the regression). - _extract_attestation_uid decodes a synthesized Attested log and ignores unrelated logs in the same receipt. 20 tests pass (9 new + 11 existing); full unit suite 193 pass / 1 skip. Refs: https://github.com/ethereum-attestation-service/eas-contracts/blob/master/contracts/SchemaRegistry.sol
1 parent 691533d commit 719da58

3 files changed

Lines changed: 448 additions & 32 deletions

File tree

blockchain/abi.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,33 @@
44
https://github.com/ethereum-attestation-service/eas-contracts
55
"""
66

7-
# EAS contract functions: attest, getAttestation, isAttestationValid, timestamp
7+
# EAS contract functions (attest, getAttestation, isAttestationValid,
8+
# timestamp) plus the Attested and Revoked event fragments. The event
9+
# fragments are required for ``contract.events.Attested().process_log(log)``
10+
# ABI decoding.
811
EAS_ABI = [
12+
{
13+
"anonymous": False,
14+
"inputs": [
15+
{"indexed": True, "internalType": "address", "name": "recipient", "type": "address"},
16+
{"indexed": True, "internalType": "address", "name": "attester", "type": "address"},
17+
{"indexed": False, "internalType": "bytes32", "name": "uid", "type": "bytes32"},
18+
{"indexed": True, "internalType": "bytes32", "name": "schemaUID", "type": "bytes32"},
19+
],
20+
"name": "Attested",
21+
"type": "event",
22+
},
23+
{
24+
"anonymous": False,
25+
"inputs": [
26+
{"indexed": True, "internalType": "address", "name": "recipient", "type": "address"},
27+
{"indexed": True, "internalType": "address", "name": "attester", "type": "address"},
28+
{"indexed": False, "internalType": "bytes32", "name": "uid", "type": "bytes32"},
29+
{"indexed": True, "internalType": "bytes32", "name": "schemaUID", "type": "bytes32"},
30+
],
31+
"name": "Revoked",
32+
"type": "event",
33+
},
934
{
1035
"inputs": [{
1136
"components": [
@@ -61,8 +86,30 @@
6186
},
6287
]
6388

64-
# SchemaRegistry contract functions: register, getSchema
89+
# SchemaRegistry contract functions (register, getSchema) plus the Registered
90+
# event fragment used for decoding SchemaRegistry receipts.
6591
SCHEMA_REGISTRY_ABI = [
92+
{
93+
"anonymous": False,
94+
"inputs": [
95+
{"indexed": True, "internalType": "bytes32", "name": "uid", "type": "bytes32"},
96+
{"indexed": True, "internalType": "address", "name": "registerer", "type": "address"},
97+
{
98+
"indexed": False,
99+
"components": [
100+
{"internalType": "bytes32", "name": "uid", "type": "bytes32"},
101+
{"internalType": "address", "name": "resolver", "type": "address"},
102+
{"internalType": "bool", "name": "revocable", "type": "bool"},
103+
{"internalType": "string", "name": "schema", "type": "string"},
104+
],
105+
"internalType": "struct SchemaRecord",
106+
"name": "schema",
107+
"type": "tuple",
108+
},
109+
],
110+
"name": "Registered",
111+
"type": "event",
112+
},
66113
{
67114
"inputs": [
68115
{"internalType": "string", "name": "schema", "type": "string"},
@@ -94,5 +141,19 @@
94141
EAS_CONTRACT_ADDRESS = "0x4200000000000000000000000000000000000021"
95142
SCHEMA_REGISTRY_ADDRESS = "0x4200000000000000000000000000000000000020"
96143

97-
# The Attestix EAS schema definition
144+
# The Attestix EAS schema definition. Keep this string STABLE: the on-chain
145+
# schema UID is derived from keccak256(abi.encodePacked(schema, resolver,
146+
# revocable)) so any change here produces a different UID and breaks
147+
# verification of previously-anchored attestations.
98148
ATTESTIX_SCHEMA = "bytes32 artifactHash, string artifactType, string artifactId, string issuerDid"
149+
150+
# Canonical schema parameters used by BlockchainService._ensure_schema_registered.
151+
# Resolver is the zero address (no custom resolver); attestations are revocable.
152+
ATTESTIX_SCHEMA_RESOLVER = "0x0000000000000000000000000000000000000000"
153+
ATTESTIX_SCHEMA_REVOCABLE = True
154+
155+
# Pin the known-good schema UIDs here after running the registration SOP
156+
# (see paper/internal/runbooks/eas-schema-registration-sop.md). Until then
157+
# these remain None and the service derives the UID canonically at runtime.
158+
ATTESTIX_SCHEMA_UID_BASE_SEPOLIA: str | None = None
159+
ATTESTIX_SCHEMA_UID_BASE_MAINNET: str | None = None

services/blockchain_service.py

Lines changed: 172 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,35 @@ def _require_configured(self) -> Optional[str]:
132132

133133
# --- Schema Management ---
134134

135+
@staticmethod
136+
def compute_schema_uid(
137+
schema: str,
138+
resolver: str = "0x0000000000000000000000000000000000000000",
139+
revocable: bool = True,
140+
) -> str:
141+
"""Compute the canonical EAS schema UID.
142+
143+
Mirrors SchemaRegistry._getUID in the EAS contracts:
144+
keccak256(abi.encodePacked(schema, resolver, revocable))
145+
146+
Reference:
147+
https://github.com/ethereum-attestation-service/eas-contracts/blob/master/contracts/SchemaRegistry.sol
148+
149+
Returns a 0x-prefixed 66-character hex string.
150+
"""
151+
from web3 import Web3
152+
from eth_abi.packed import encode_packed
153+
154+
resolver_checksum = Web3.to_checksum_address(resolver)
155+
packed = encode_packed(
156+
["string", "address", "bool"],
157+
[schema, resolver_checksum, bool(revocable)],
158+
)
159+
digest_hex = Web3.keccak(packed).hex()
160+
if not digest_hex.startswith("0x"):
161+
digest_hex = "0x" + digest_hex
162+
return digest_hex
163+
135164
def _load_schema_uid(self) -> Optional[str]:
136165
"""Load cached schema UID from blockchain config file."""
137166
try:
@@ -171,12 +200,40 @@ def _ensure_schema_registered(self) -> Tuple[bool, str]:
171200
zero_addr = Web3.to_checksum_address(
172201
"0x0000000000000000000000000000000000000000"
173202
)
203+
revocable = True
204+
205+
# EAS schema UIDs are deterministic:
206+
# keccak256(abi.encodePacked(schema, resolver, revocable))
207+
# Derive the UID offline so we can (a) skip registration if the
208+
# schema already exists on-chain and (b) use it as a safe fallback
209+
# if log parsing ever fails. Hashing the schema string alone
210+
# produces the WRONG UID and breaks on-chain verification.
211+
canonical_uid = self.compute_schema_uid(
212+
ATTESTIX_SCHEMA, zero_addr, revocable
213+
)
214+
215+
# If already registered on-chain, reuse without spending gas.
216+
try:
217+
uid_bytes = bytes.fromhex(canonical_uid[2:])
218+
existing = self._schema_registry.functions.getSchema(
219+
uid_bytes
220+
).call()
221+
existing_uid = existing[0] if existing else b"\x00" * 32
222+
if isinstance(existing_uid, (bytes, bytearray)) and any(
223+
existing_uid
224+
):
225+
self._schema_uid = canonical_uid
226+
self._save_schema_uid(canonical_uid)
227+
return True, canonical_uid
228+
except Exception:
229+
# getSchema lookup failed; fall through and register.
230+
pass
174231

175232
with self._tx_lock:
176233
tx = self._schema_registry.functions.register(
177234
ATTESTIX_SCHEMA,
178235
zero_addr,
179-
True,
236+
revocable,
180237
).build_transaction({
181238
"from": self._account.address,
182239
"nonce": self._w3.eth.get_transaction_count(
@@ -189,27 +246,38 @@ def _ensure_schema_registered(self) -> Tuple[bool, str]:
189246
})
190247

191248
signed = self._account.sign_transaction(tx)
192-
tx_hash = self._w3.eth.send_raw_transaction(signed.raw_transaction)
193-
receipt = self._w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
249+
tx_hash = self._w3.eth.send_raw_transaction(
250+
signed.raw_transaction
251+
)
252+
receipt = self._w3.eth.wait_for_transaction_receipt(
253+
tx_hash, timeout=60
254+
)
194255

195256
if receipt["status"] != 1:
196257
return False, "Schema registration transaction reverted"
197258

198-
# Extract schema UID from the Registered event log
259+
# Extract schema UID from Registered(bytes32 indexed uid, ...):
260+
# topics[0] is the event signature, topics[1] is the indexed uid.
199261
schema_uid_hex = None
200-
if receipt["logs"]:
201-
# The first topic after the event signature is the schema UID
202-
log = receipt["logs"][0]
203-
if len(log.get("topics", [])) > 1:
204-
schema_uid_hex = "0x" + log["topics"][1].hex()
205-
elif log.get("data"):
206-
schema_uid_hex = "0x" + log["data"].hex()[:64]
262+
for log in receipt.get("logs", []):
263+
topics = log.get("topics", [])
264+
if len(topics) > 1:
265+
topic = topics[1]
266+
if isinstance(topic, (bytes, bytearray)):
267+
topic_hex = bytes(topic).hex()
268+
else:
269+
topic_hex = str(topic).replace("0x", "")
270+
if topic_hex and int(topic_hex, 16) != 0:
271+
schema_uid_hex = (
272+
topic_hex if topic_hex.startswith("0x")
273+
else "0x" + topic_hex
274+
)
275+
break
207276

208277
if not schema_uid_hex:
209-
# Fallback: hash the schema deterministically
210-
schema_uid_hex = "0x" + Web3.keccak(
211-
text=ATTESTIX_SCHEMA
212-
).hex()
278+
# Safe fallback: canonical UID matches the on-chain UID by
279+
# construction. Never hash the schema text alone.
280+
schema_uid_hex = canonical_uid
213281

214282
self._schema_uid = schema_uid_hex
215283
self._save_schema_uid(schema_uid_hex)
@@ -221,6 +289,82 @@ def _ensure_schema_registered(self) -> Tuple[bool, str]:
221289
)
222290
return False, msg
223291

292+
# --- Event Decoding ---
293+
294+
def _extract_attestation_uid(self, receipt) -> str:
295+
"""Decode the Attested event from a tx receipt and return the UID.
296+
297+
Uses ``contract.events.Attested().process_log(log)`` (web3.py's ABI
298+
decoder) so we do not rely on fragile byte offsets. Falls back to
299+
manual decoding when the Attested event is not on the ABI or the log
300+
is not parseable. Returns "unknown" if no Attested log is present.
301+
"""
302+
logs = receipt.get("logs") if isinstance(receipt, dict) else getattr(
303+
receipt, "logs", None
304+
)
305+
if not logs:
306+
return "unknown"
307+
308+
# Prefer structured ABI decoding when the event is on the ABI.
309+
attested_event = None
310+
try:
311+
attested_event = self._eas_contract.events.Attested()
312+
except Exception:
313+
attested_event = None
314+
315+
if attested_event is not None:
316+
for log in logs:
317+
try:
318+
decoded = attested_event.process_log(log)
319+
except Exception:
320+
# Not an Attested event or ABI mismatch; try next log.
321+
continue
322+
args = decoded.get("args", {}) if hasattr(decoded, "get") \
323+
else getattr(decoded, "args", {})
324+
uid = None
325+
if hasattr(args, "get"):
326+
uid = args.get("uid")
327+
else:
328+
uid = getattr(args, "uid", None)
329+
if isinstance(uid, (bytes, bytearray)) and any(uid):
330+
return "0x" + bytes(uid).hex()
331+
332+
# Manual fallback: match Attested event by topic[0] signature and
333+
# read the single non-indexed ``uid`` word (first 32 bytes of data).
334+
try:
335+
from web3 import Web3
336+
sig_topic = bytes(
337+
Web3.keccak(text="Attested(address,address,bytes32,bytes32)")
338+
)
339+
for log in logs:
340+
topics = log.get("topics") if isinstance(log, dict) else getattr(
341+
log, "topics", []
342+
)
343+
if not topics:
344+
continue
345+
first = topics[0]
346+
first_bytes = (
347+
bytes(first) if isinstance(first, (bytes, bytearray))
348+
else bytes.fromhex(str(first).replace("0x", ""))
349+
)
350+
if first_bytes != sig_topic:
351+
continue
352+
data = log.get("data") if isinstance(log, dict) else getattr(
353+
log, "data", b""
354+
)
355+
data_bytes = (
356+
bytes(data) if isinstance(data, (bytes, bytearray))
357+
else bytes.fromhex(str(data).replace("0x", ""))
358+
)
359+
if len(data_bytes) >= 32:
360+
uid = data_bytes[:32]
361+
if any(uid):
362+
return "0x" + uid.hex()
363+
except Exception:
364+
pass
365+
366+
return "unknown"
367+
224368
# --- Hashing ---
225369

226370
def hash_artifact(self, artifact: dict) -> str:
@@ -311,20 +455,19 @@ def anchor_artifact(
311455
"error": "Attestation transaction reverted. Check gas and balance."
312456
}
313457

314-
# Extract attestation UID from Attested event log
315-
# EAS Attested event: Attested(address indexed recipient, address indexed attester, bytes32 uid, bytes32 indexed schema)
316-
# uid is in the data field (non-indexed), not in topics
317-
attestation_uid = "unknown"
318-
if receipt["logs"]:
319-
for log in receipt["logs"]:
320-
data_hex = log.get("data", b"")
321-
if isinstance(data_hex, bytes) and len(data_hex) >= 32:
322-
attestation_uid = "0x" + data_hex[:32].hex()
323-
break
324-
elif isinstance(data_hex, str) and len(data_hex) >= 66:
325-
clean = data_hex.replace("0x", "")
326-
attestation_uid = "0x" + clean[:64]
327-
break
458+
# Extract attestation UID via the Attested event ABI decoder.
459+
# EAS interface:
460+
# Attested(address indexed recipient,
461+
# address indexed attester,
462+
# bytes32 uid,
463+
# bytes32 indexed schemaUID)
464+
# The uid is the single non-indexed field, so naive byte slicing
465+
# (which was previously used) is fragile: it assumed the first log
466+
# was always Attested and that log.data started with the uid. We
467+
# now use contract.events.Attested().process_log(log) with a
468+
# topic-signature fallback so the uid is decoded correctly even
469+
# when other contracts emit logs in the same transaction.
470+
attestation_uid = self._extract_attestation_uid(receipt)
328471

329472
anchor_id = f"anchor:{uuid.uuid4().hex[:12]}"
330473
now = datetime.now(timezone.utc).isoformat()

0 commit comments

Comments
 (0)