Skip to content

Commit d5f3ca4

Browse files
kenneivesclaude
andcommitted
feat(bridge): scaffold agentgraph_bridge_erc8004 Day-1 MVP
Day 1 of 3-day MVP per docs/internal/monday-may18-scope.md task #47. Production code for AgentGraph composite trust score to consume ERC-8004 registry entries as primitives via URN-shaped references. Shipped (Day 1): - __init__.py — public API exports - models.py — Pydantic schemas: ERC8004Registry enum, ERC8004Entry raw registry entry, NormalizedAttestation with is_admissible property (both signature layers + non-expired) - urn_resolver.py — pure-logic URN parser for urn:erc8004:{identity,reputation,validation}:<entry_id> with strict regex (no leading zeros, no extra segments, no negative entry_ids). Verified standalone: 10/10 PASS (3 positive + 7 negative cases). - config.py — ERC8004Config dataclass + load_config_from_env() reading ETH_RPC_URL + per-registry contract addresses - tests/test_urn_resolver.py — 11 unit tests covering positive + negative URN parsing paths Pending (Day 2-3): - registry_reader.py with web3.py wiring against mainnet RPC - attestation_normalizer.py validating Ethereum entry signature + CTEF Ed25519 signature on embedded payload - score_ingest.py integrating into src/trust/score.py EXTERNAL: 0.35 - Real mainnet contract addresses (currently placeholder 0x0...) - Mainnet entry snapshot fixtures for byte-match validation Note: test suite requires production-style import path (uvicorn / pytest with proper conftest) due to pre-existing src/email.py shadowing stdlib email package when src/ is on sys.path. URN parser logic verified standalone outside the project import context. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fd52474 commit d5f3ca4

6 files changed

Lines changed: 370 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""ERC-8004 bridge — production code for AgentGraph composite trust score
2+
to consume ERC-8004 registry entries as primitives via URN-shaped references.
3+
4+
ERC-8004 ("Trustless Agents", Ethereum mainnet 2026-01-29) ships three on-chain
5+
registries: Identity, Reputation, Validation. This bridge reads entries from
6+
those registries, normalizes the CTEF-formatted payload, verifies both the
7+
Ethereum-layer signature (registry-side) and the Ed25519 signature on the
8+
embedded CTEF attestation, and feeds the normalized attestation into
9+
AgentGraph's composite trust score `EXTERNAL: 0.35` weight.
10+
11+
Architecture (per docs/standards/v0.3.2-layering-figure.md):
12+
- ERC-8004 is a Layer 2 primitive over an alternate execution substrate
13+
(Ethereum / L2), not a Layer 1' addition
14+
- CTEF supplies the embedded claim_type attestation that travels inside
15+
the registry entry's `data` field
16+
- Both ERC-8004 entry signature and CTEF Ed25519 signature must verify
17+
before the attestation is admitted to the composite score
18+
- URN scheme: `urn:erc8004:{identity,reputation,validation}:<entry_id>`
19+
20+
MVP scope (Day 1 of 3-day plan per docs/internal/monday-may18-scope.md):
21+
- models.py + urn_resolver.py + config.py shipped
22+
- registry_reader.py + attestation_normalizer.py + score_ingest.py are skeleton
23+
24+
Day 2-3: wire web3.py against mainnet RPC, add per-vector mainnet snapshot
25+
fixtures, integrate normalized attestations into src/trust/score.py.
26+
"""
27+
from __future__ import annotations
28+
29+
from agentgraph_bridge_erc8004.models import (
30+
ERC8004Entry,
31+
ERC8004Registry,
32+
NormalizedAttestation,
33+
)
34+
from agentgraph_bridge_erc8004.urn_resolver import (
35+
ParsedURN,
36+
URNParseError,
37+
parse_erc8004_urn,
38+
)
39+
40+
__all__ = [
41+
"ERC8004Entry",
42+
"ERC8004Registry",
43+
"NormalizedAttestation",
44+
"ParsedURN",
45+
"URNParseError",
46+
"parse_erc8004_urn",
47+
]
48+
49+
__version__ = "0.0.1" # MVP scaffold; bump to 0.1.0 when Day 2-3 wiring lands
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Configuration for ERC-8004 bridge — RPC endpoint + contract addresses.
2+
3+
The three ERC-8004 registry contracts went live on Ethereum mainnet
4+
2026-01-29 per EIP-8004. Production deployment loads addresses from
5+
env vars; tests use the mocked addresses from `ERC8004_TEST_ADDRESSES`.
6+
7+
TODO Day 2 of MVP: replace placeholder addresses with actual mainnet
8+
deployment addresses from https://eips.ethereum.org/EIPS/eip-8004
9+
once verified.
10+
"""
11+
from __future__ import annotations
12+
13+
import os
14+
from dataclasses import dataclass
15+
16+
17+
@dataclass(frozen=True)
18+
class ERC8004Config:
19+
"""Configuration for ERC-8004 bridge — RPC endpoint + contract addresses."""
20+
21+
rpc_url: str
22+
identity_registry_address: str
23+
reputation_registry_address: str
24+
validation_registry_address: str
25+
chain_id: int = 1 # 1 = Ethereum mainnet; override for testnets
26+
request_timeout_seconds: int = 15
27+
freshness_ttl_seconds: int = 24 * 60 * 60 # 24h default TTL for attestations
28+
29+
30+
# Placeholder addresses — replace with actual mainnet deployment addresses
31+
# from EIP-8004 once verified on-chain. These addresses are intentionally
32+
# 0x0000... to fail-fast if production config isn't loaded.
33+
ERC8004_PLACEHOLDER_ADDRESSES = {
34+
"identity": "0x0000000000000000000000000000000000000000",
35+
"reputation": "0x0000000000000000000000000000000000000000",
36+
"validation": "0x0000000000000000000000000000000000000000",
37+
}
38+
39+
# Test addresses — use anvil/foundry mock chain in tests
40+
ERC8004_TEST_ADDRESSES = {
41+
"identity": "0x" + "01" * 20,
42+
"reputation": "0x" + "02" * 20,
43+
"validation": "0x" + "03" * 20,
44+
}
45+
46+
47+
def load_config_from_env() -> ERC8004Config:
48+
"""Build config from env vars.
49+
50+
Required env vars:
51+
- ETH_RPC_URL — Ethereum mainnet RPC endpoint (Alchemy, Quicknode, etc.)
52+
- ERC8004_IDENTITY_ADDRESS
53+
- ERC8004_REPUTATION_ADDRESS
54+
- ERC8004_VALIDATION_ADDRESS
55+
56+
Optional:
57+
- ETH_CHAIN_ID (default: 1)
58+
- ERC8004_REQUEST_TIMEOUT_SECONDS (default: 15)
59+
- ERC8004_FRESHNESS_TTL_SECONDS (default: 86400)
60+
"""
61+
rpc_url = os.environ.get("ETH_RPC_URL")
62+
if not rpc_url:
63+
raise RuntimeError("ETH_RPC_URL env var not set")
64+
65+
return ERC8004Config(
66+
rpc_url=rpc_url,
67+
identity_registry_address=os.environ.get(
68+
"ERC8004_IDENTITY_ADDRESS",
69+
ERC8004_PLACEHOLDER_ADDRESSES["identity"],
70+
),
71+
reputation_registry_address=os.environ.get(
72+
"ERC8004_REPUTATION_ADDRESS",
73+
ERC8004_PLACEHOLDER_ADDRESSES["reputation"],
74+
),
75+
validation_registry_address=os.environ.get(
76+
"ERC8004_VALIDATION_ADDRESS",
77+
ERC8004_PLACEHOLDER_ADDRESSES["validation"],
78+
),
79+
chain_id=int(os.environ.get("ETH_CHAIN_ID", "1")),
80+
request_timeout_seconds=int(
81+
os.environ.get("ERC8004_REQUEST_TIMEOUT_SECONDS", "15"),
82+
),
83+
freshness_ttl_seconds=int(
84+
os.environ.get("ERC8004_FRESHNESS_TTL_SECONDS", str(24 * 60 * 60)),
85+
),
86+
)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Pydantic schemas for ERC-8004 registry entries and normalized attestations.
2+
3+
ERC-8004 registries (per EIP-8004):
4+
- Identity Registry: links agent DID → on-chain address + identity metadata
5+
- Reputation Registry: stores feedback entries (numeric score + signed payload)
6+
- Validation Registry: stores verified-work attestations (proof of completed task)
7+
8+
Each entry carries a CTEF-formatted payload in its `data` field, signed
9+
Ed25519 by the attestation issuer (which may differ from the entry submitter).
10+
"""
11+
from __future__ import annotations
12+
13+
from datetime import datetime
14+
from enum import Enum
15+
from typing import Any, Optional
16+
17+
from pydantic import BaseModel, Field
18+
19+
20+
class ERC8004Registry(str, Enum):
21+
"""The three ERC-8004 on-chain registries."""
22+
23+
IDENTITY = "identity"
24+
REPUTATION = "reputation"
25+
VALIDATION = "validation"
26+
27+
28+
class ERC8004Entry(BaseModel):
29+
"""Raw entry as returned from an ERC-8004 registry contract call.
30+
31+
The `data` field carries an opaque payload — for AgentGraph integration,
32+
that payload is expected to be a CTEF-formatted attestation (JCS-canonical
33+
JSON with Ed25519 signature). Other consumers may use different payload
34+
formats; ERC-8004 itself does not constrain the payload shape.
35+
"""
36+
37+
registry: ERC8004Registry
38+
entry_id: int = Field(ge=0, description="Sequential entry index in the registry")
39+
submitter: str = Field(
40+
pattern=r"^0x[0-9a-fA-F]{40}$",
41+
description="Ethereum address that submitted the entry",
42+
)
43+
subject_did: Optional[str] = Field(
44+
default=None,
45+
description="DID this entry attests about (e.g. did:web:agent.example.com)",
46+
)
47+
data: bytes = Field(description="Raw entry payload bytes (typically CTEF JSON)")
48+
block_number: int = Field(ge=0)
49+
block_timestamp: datetime
50+
tx_hash: str = Field(pattern=r"^0x[0-9a-fA-F]{64}$")
51+
52+
53+
class NormalizedAttestation(BaseModel):
54+
"""ERC-8004 entry normalized into AgentGraph composite-trust-score input.
55+
56+
Produced by `attestation_normalizer.normalize()` after:
57+
1. Reading the entry from the on-chain registry
58+
2. Parsing the `data` field as CTEF v0.3.1 envelope
59+
3. Verifying the CTEF Ed25519 signature (substrate-side)
60+
4. Verifying the entry submitter's Ethereum signature (registry-side)
61+
"""
62+
63+
source_urn: str = Field(description="urn:erc8004:{registry}:<entry_id> originating URN")
64+
claim_type: str = Field(description="CTEF claim_type — one of {identity, transport, authority, continuity}")
65+
claim_subtype: Optional[str] = Field(default=None)
66+
subject_did: str = Field(description="The DID this attestation is about")
67+
provider_did: str = Field(description="The DID of the attestation issuer (CTEF provider)")
68+
payload: dict[str, Any] = Field(description="Parsed CTEF envelope payload")
69+
signature_verified: bool = Field(
70+
description="True iff CTEF Ed25519 signature verified against provider's published JWKS",
71+
)
72+
registry_signature_verified: bool = Field(
73+
description="True iff ERC-8004 entry submitter signature verified on-chain",
74+
)
75+
issued_at: datetime
76+
expires_at: Optional[datetime] = Field(default=None)
77+
freshness_ttl_remaining_seconds: Optional[int] = Field(default=None)
78+
79+
@property
80+
def is_admissible(self) -> bool:
81+
"""Both signature layers must verify and attestation must not be expired."""
82+
return (
83+
self.signature_verified
84+
and self.registry_signature_verified
85+
and (self.expires_at is None or self.expires_at > datetime.utcnow())
86+
)

src/agentgraph_bridge_erc8004/tests/__init__.py

Whitespace-only changes.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tests for URN parser — pure logic, no chain calls."""
2+
from __future__ import annotations
3+
4+
import pytest
5+
6+
from agentgraph_bridge_erc8004.models import ERC8004Registry
7+
from agentgraph_bridge_erc8004.urn_resolver import (
8+
ParsedURN,
9+
URNParseError,
10+
parse_erc8004_urn,
11+
)
12+
13+
14+
class TestParseValid:
15+
def test_identity_registry(self):
16+
result = parse_erc8004_urn("urn:erc8004:identity:42")
17+
assert result == ParsedURN(
18+
registry=ERC8004Registry.IDENTITY, entry_id=42,
19+
)
20+
21+
def test_reputation_registry(self):
22+
result = parse_erc8004_urn("urn:erc8004:reputation:0")
23+
assert result == ParsedURN(
24+
registry=ERC8004Registry.REPUTATION, entry_id=0,
25+
)
26+
27+
def test_validation_registry(self):
28+
result = parse_erc8004_urn("urn:erc8004:validation:999999")
29+
assert result == ParsedURN(
30+
registry=ERC8004Registry.VALIDATION, entry_id=999999,
31+
)
32+
33+
def test_round_trip(self):
34+
urn = "urn:erc8004:reputation:7"
35+
assert parse_erc8004_urn(urn).to_urn() == urn
36+
37+
38+
class TestParseInvalid:
39+
def test_wrong_scheme(self):
40+
with pytest.raises(URNParseError, match="Malformed"):
41+
parse_erc8004_urn("urn:concordia:attestation:42")
42+
43+
def test_unknown_registry(self):
44+
with pytest.raises(URNParseError, match="Malformed"):
45+
parse_erc8004_urn("urn:erc8004:unknown:42")
46+
47+
def test_negative_entry_id(self):
48+
with pytest.raises(URNParseError, match="Malformed"):
49+
parse_erc8004_urn("urn:erc8004:identity:-1")
50+
51+
def test_leading_zero(self):
52+
# "0" is valid, but "01" is not
53+
with pytest.raises(URNParseError, match="Malformed"):
54+
parse_erc8004_urn("urn:erc8004:identity:01")
55+
56+
def test_non_numeric_entry_id(self):
57+
with pytest.raises(URNParseError, match="Malformed"):
58+
parse_erc8004_urn("urn:erc8004:identity:abc")
59+
60+
def test_extra_segments(self):
61+
with pytest.raises(URNParseError, match="Malformed"):
62+
parse_erc8004_urn("urn:erc8004:identity:42:extra")
63+
64+
def test_empty_string(self):
65+
with pytest.raises(URNParseError, match="Malformed"):
66+
parse_erc8004_urn("")
67+
68+
def test_non_string_input(self):
69+
with pytest.raises(URNParseError, match="must be str"):
70+
parse_erc8004_urn(42) # type: ignore[arg-type]
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""URN parsing for ERC-8004 cross-protocol references.
2+
3+
Per Concordia v0.5.1 §11.5.7, ERC-8004 entries are addressable via three
4+
URN schemes:
5+
- `urn:erc8004:identity:<entry_id>` — Identity Registry entry
6+
- `urn:erc8004:reputation:<entry_id>` — Reputation Registry entry
7+
- `urn:erc8004:validation:<entry_id>` — Validation Registry entry
8+
9+
The URN scheme is the cross-protocol pointer shape AgentGraph's composite
10+
trust score uses to consume ERC-8004 attestations without any translation
11+
layer — the substrate stays at JCS canonicalization; the cross-protocol
12+
semantics live in the URN scheme itself.
13+
14+
This module is the pure-logic URN parser. No on-chain RPC, no signature
15+
verification — just string parsing + validation.
16+
"""
17+
from __future__ import annotations
18+
19+
import re
20+
from dataclasses import dataclass
21+
22+
from agentgraph_bridge_erc8004.models import ERC8004Registry
23+
24+
25+
class URNParseError(ValueError):
26+
"""Raised when an ERC-8004 URN doesn't parse correctly."""
27+
28+
29+
@dataclass(frozen=True)
30+
class ParsedURN:
31+
"""Result of parsing a `urn:erc8004:{registry}:<entry_id>` URN.
32+
33+
`entry_id` is the on-chain integer index; `registry` is one of the
34+
three ERC-8004 registry enum values.
35+
"""
36+
37+
registry: ERC8004Registry
38+
entry_id: int
39+
40+
def to_urn(self) -> str:
41+
"""Reconstruct canonical URN string from parsed components."""
42+
return f"urn:erc8004:{self.registry.value}:{self.entry_id}"
43+
44+
45+
# urn:erc8004:{registry}:{entry_id}
46+
# - registry must be one of identity, reputation, validation
47+
# - entry_id must be a non-negative integer (no leading zeros except "0")
48+
_URN_PATTERN = re.compile(
49+
r"^urn:erc8004:(?P<registry>identity|reputation|validation):(?P<entry_id>0|[1-9]\d*)$"
50+
)
51+
52+
53+
def parse_erc8004_urn(urn: str) -> ParsedURN:
54+
"""Parse an `urn:erc8004:{registry}:<entry_id>` URN into typed components.
55+
56+
Raises URNParseError on any malformed input:
57+
- Wrong scheme (not `urn:erc8004:`)
58+
- Unknown registry (not in {identity, reputation, validation})
59+
- Invalid entry_id (negative, leading zeros, non-numeric)
60+
- Extra path segments
61+
62+
Example:
63+
>>> parse_erc8004_urn("urn:erc8004:reputation:42")
64+
ParsedURN(registry=ERC8004Registry.REPUTATION, entry_id=42)
65+
"""
66+
if not isinstance(urn, str):
67+
raise URNParseError(f"URN must be str, got {type(urn).__name__}")
68+
69+
match = _URN_PATTERN.match(urn)
70+
if not match:
71+
raise URNParseError(
72+
f"Malformed ERC-8004 URN: {urn!r} (expected "
73+
f"urn:erc8004:{{identity|reputation|validation}}:<entry_id>)",
74+
)
75+
76+
return ParsedURN(
77+
registry=ERC8004Registry(match.group("registry")),
78+
entry_id=int(match.group("entry_id")),
79+
)

0 commit comments

Comments
 (0)