From 6fd1b2a19839c821e4667a296037dd0541e9f406 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Thu, 28 May 2026 17:19:54 +0200 Subject: [PATCH 1/3] feat: add attestation mechanism --- src/erc7730/attest.py | 170 ++++++++++++++++++ src/erc7730/common/ledger.py | 4 - src/erc7730/main.py | 30 +++- .../registries/clear-signing-erc7730-registry | 2 +- tests/test_attest.py | 102 +++++++++++ tests/test_main.py | 1 + 6 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 src/erc7730/attest.py create mode 100644 tests/test_attest.py diff --git a/src/erc7730/attest.py b/src/erc7730/attest.py new file mode 100644 index 00000000..c85c727b --- /dev/null +++ b/src/erc7730/attest.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import os +import re +from calendar import monthrange +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from eth_abi import encode +from eth_utils import keccak +from eth_utils.abi import function_signature_to_4byte_selector +import jcs + +from erc7730.common.json import read_json_with_includes + +EAS_SCHEMA_UID = "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2" +EAS_CONTRACT_ADDRESS = "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587" +EAS_DOMAIN_NAME = "EAS Attestation" +EAS_DOMAIN_VERSION = "0.26" +EAS_ATTEST_VERSION = 2 +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +ZERO_BYTES32 = "0x" + "00" * 32 +ATTEST_SIGNATURE = "attest((bytes32,(address,uint64,bool,bytes32,bytes,uint256)))" +ATTEST_SELECTOR = function_signature_to_4byte_selector(ATTEST_SIGNATURE) + +_TTL_RE = re.compile(r"^(\d+)(mo|[smhdwy])$") + + +def load_descriptor_json(path: Path) -> dict[str, Any]: + """Load a descriptor JSON file, resolving includes first.""" + value = read_json_with_includes(path) + if not isinstance(value, dict): + raise ValueError("descriptor must be a JSON object") + return value + + +def canonical_json(value: Any) -> str: + """Serialize JSON using deterministic key ordering and compact separators.""" + return jcs.canonicalize(value).decode() + + +def descriptor_hash(descriptor: dict[str, Any]) -> str: + """Compute the ERC-8176 descriptor hash.""" + return "0x" + keccak(text=canonical_json(descriptor)).hex() + + +def _add_months(value: datetime, months: int) -> datetime: + year = value.year + (value.month - 1 + months) // 12 + month = (value.month - 1 + months) % 12 + 1 + day = min(value.day, monthrange(year, month)[1]) + return value.replace(year=year, month=month, day=day) + + +def _hex_bytes32(value: str) -> bytes: + return bytes.fromhex(value.removeprefix("0x")) + + +def _hex_bytes(value: str) -> bytes: + return bytes.fromhex(value.removeprefix("0x")) + + +def build_eas_attestation_message( + descriptor_digest: str, + *, + expiration_time: datetime, + now: datetime | None = None, + salt: bytes | None = None, +) -> dict[str, Any]: + now = now or datetime.now(timezone.utc) + salt_bytes = salt if salt is not None else os.urandom(32) + return { + "version": EAS_ATTEST_VERSION, + "schema": EAS_SCHEMA_UID, + "recipient": ZERO_ADDRESS, + "time": str(int(now.timestamp())), + "expirationTime": str(int(expiration_time.timestamp())), + "revocable": True, + "refUID": ZERO_BYTES32, + "data": descriptor_digest, + "salt": "0x" + salt_bytes.hex(), + } + + +def build_eas_typed_data( + descriptor_digest: str, + *, + ttl: str = "6mo", + now: datetime | None = None, + salt: bytes | None = None, +) -> dict[str, Any]: + now = now or datetime.now(timezone.utc) + expiration_time = _apply_ttl(now, ttl) + message = build_eas_attestation_message( + descriptor_digest, + expiration_time=expiration_time, + now=now, + salt=salt, + ) + return { + "domain": { + "name": EAS_DOMAIN_NAME, + "version": EAS_DOMAIN_VERSION, + "chainId": "1", + "verifyingContract": EAS_CONTRACT_ADDRESS, + }, + "primaryType": "Attest", + "types": { + "Attest": [ + {"name": "version", "type": "uint16"}, + {"name": "schema", "type": "bytes32"}, + {"name": "recipient", "type": "address"}, + {"name": "time", "type": "uint64"}, + {"name": "expirationTime", "type": "uint64"}, + {"name": "revocable", "type": "bool"}, + {"name": "refUID", "type": "bytes32"}, + {"name": "data", "type": "bytes"}, + {"name": "salt", "type": "bytes32"}, + ] + }, + "message": message, + } + + +def _apply_ttl(now: datetime, ttl: str) -> datetime: + match = _TTL_RE.fullmatch(ttl.strip()) + if match is None: + raise ValueError("ttl must look like 30d, 6mo, 12h, 15m, or 90s") + + amount = int(match.group(1)) + unit = match.group(2) + if unit == "mo": + return _add_months(now, amount) + if unit == "y": + return _add_months(now, amount * 12) + + delta_by_unit: dict[str, timedelta] = { + "s": timedelta(seconds=amount), + "m": timedelta(minutes=amount), + "h": timedelta(hours=amount), + "d": timedelta(days=amount), + "w": timedelta(weeks=amount), + } + return now + delta_by_unit[unit] + + +def build_eas_attest_tx(descriptor_digest: str, *, ttl: str = "6mo", now: datetime | None = None) -> dict[str, str]: + now = now or datetime.now(timezone.utc) + expiration_time = _apply_ttl(now, ttl) + calldata = encode( + ["bytes32", "(address,uint64,bool,bytes32,bytes,uint256)"], + [ + _hex_bytes32(EAS_SCHEMA_UID), + ( + ZERO_ADDRESS, + int(expiration_time.timestamp()), + True, + _hex_bytes32(ZERO_BYTES32), + _hex_bytes(descriptor_digest), + 0, + ), + ], + ) + return { + "to": EAS_CONTRACT_ADDRESS, + "value": "0x0", + "data": "0x" + ATTEST_SELECTOR.hex() + calldata.hex(), + "chainId": "0x1", + } diff --git a/src/erc7730/common/ledger.py b/src/erc7730/common/ledger.py index 21c034ad..c36b436d 100644 --- a/src/erc7730/common/ledger.py +++ b/src/erc7730/common/ledger.py @@ -147,8 +147,6 @@ def ledger_network_id(chain_id: int) -> str | None: return "mantle_sepolia" case 5031: return "somnia" - case 5042: - return "arc" case 6342: return "megaeth_testnet" case 8217: @@ -227,8 +225,6 @@ def ledger_network_id(chain_id: int) -> str | None: return "id4good" case 1313114: return "ether1" - case 5042002: - return "arc_testnet" case 7762959: return "musicoin" case 11155111: diff --git a/src/erc7730/main.py b/src/erc7730/main.py index 14c51c1d..dc84c5db 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -3,7 +3,7 @@ import logging import os from pathlib import Path -from typing import Annotated, assert_never +from typing import Annotated, Literal, assert_never from eip712.convert.input_to_resolved import EIP712InputToResolvedConverter from eip712.model.input.descriptor import InputEIP712DAppDescriptor @@ -13,6 +13,7 @@ from typer import Argument, Exit, Option, Typer from erc7730.common.output import ConsoleOutputAdder +from erc7730.attest import build_eas_attest_tx, build_eas_typed_data, descriptor_hash, load_descriptor_json from erc7730.convert.calldata.convert_erc7730_input_to_calldata import erc7730_descriptor_to_calldata_descriptors from erc7730.convert.calldata.convert_erc7730_v2_input_to_calldata import erc7730_v2_descriptor_to_calldata_descriptors from erc7730.convert.convert import convert_to_file_and_print_errors @@ -223,6 +224,33 @@ def command_generate( print(descriptor.to_json_string()) +@app.command( + name="attest", + short_help="Generate EAS attestation payloads for an ERC-7730 file.", + help=""" + Generate EAS attestation payloads for an ERC-7730 file. + """, +) +def command_attest( + input_erc7730_path: Annotated[Path, Argument(help="The input ERC-7730 file path")], + output_format: Annotated[ + Literal["eip712", "tx"], Option("--format", help="Output format: eip712 or tx") + ] = "eip712", + ttl: Annotated[str, Option("--ttl", help="Attestation time-to-live, for example 6mo or 30d")] = "6mo", +) -> None: + descriptor = load_descriptor_json(input_erc7730_path) + digest = descriptor_hash(descriptor) + + try: + if output_format == "eip712": + builtins.print(json.dumps(build_eas_typed_data(digest, ttl=ttl), indent=2)) + else: + builtins.print(json.dumps(build_eas_attest_tx(digest, ttl=ttl), indent=2)) + except ValueError as exc: + builtins.print(f"error: {exc}") + raise Exit(1) from exc + + @app.command( name="calldata", short_help="Display calldata descriptors for an ERC-7730 file.", diff --git a/tests/registries/clear-signing-erc7730-registry b/tests/registries/clear-signing-erc7730-registry index 8577824a..f5a95f2c 160000 --- a/tests/registries/clear-signing-erc7730-registry +++ b/tests/registries/clear-signing-erc7730-registry @@ -1 +1 @@ -Subproject commit 8577824ab005fef8017fc07f8223bd225994243d +Subproject commit f5a95f2c7f9e520709e3b11cf6e13a818641c592 diff --git a/tests/test_attest.py b/tests/test_attest.py new file mode 100644 index 00000000..029c7263 --- /dev/null +++ b/tests/test_attest.py @@ -0,0 +1,102 @@ +import json +import json +from datetime import datetime, timezone +from pathlib import Path + +from eth_abi import decode +from typer.testing import CliRunner + +from erc7730.attest import ( + ATTEST_SELECTOR, + build_eas_attest_tx, + build_eas_typed_data, + descriptor_hash, + load_descriptor_json, +) +from erc7730.main import app + +runner = CliRunner() + + +def _write_json(path: Path, value: dict[str, object]) -> None: + path.write_text(json.dumps(value)) + + +def test_descriptor_hash_resolves_includes_first(tmp_path: Path) -> None: + base = tmp_path / "base.json" + child = tmp_path / "child.json" + + _write_json(base, {"b": 2, "a": 1}) + _write_json(child, {"includes": "base.json", "c": 3}) + + loaded = load_descriptor_json(child) + assert loaded == {"a": 1, "b": 2, "c": 3} + assert descriptor_hash(loaded) == descriptor_hash({"a": 1, "b": 2, "c": 3}) + + +def test_build_eas_typed_data() -> None: + now = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + salt = b"\x11" * 32 + payload = build_eas_typed_data("0x" + "22" * 32, ttl="6mo", now=now, salt=salt) + + assert payload["primaryType"] == "Attest" + assert payload["domain"] == { + "name": "EAS Attestation", + "version": "0.26", + "chainId": "1", + "verifyingContract": "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587", + } + assert payload["message"]["revocable"] is True + assert payload["message"]["salt"] == "0x" + salt.hex() + assert payload["message"]["time"] == str(int(now.timestamp())) + assert payload["message"]["expirationTime"] == str(int(datetime(2026, 7, 2, 3, 4, 5, tzinfo=timezone.utc).timestamp())) + + +def test_build_eas_attest_tx() -> None: + now = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + digest = "0x" + "33" * 32 + tx = build_eas_attest_tx(digest, ttl="30d", now=now) + + assert tx["to"] == "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587" + assert tx["value"] == "0x0" + assert tx["chainId"] == "0x1" + assert tx["data"].startswith("0x" + ATTEST_SELECTOR.hex()) + schema_uid, attestation = decode( + ["bytes32", "(address,uint64,bool,bytes32,bytes,uint256)"], + bytes.fromhex(tx["data"][2 + len(ATTEST_SELECTOR.hex()) :]), + ) + assert schema_uid.hex() == "e023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2" + assert attestation[4] == bytes.fromhex(digest.removeprefix("0x")) + assert len(attestation[4]) == 32 + + +def test_cli_attest_eip712(tmp_path: Path) -> None: + base = tmp_path / "base.json" + child = tmp_path / "child.json" + + _write_json(base, {"b": 2, "a": 1}) + _write_json(child, {"includes": "base.json", "c": 3}) + + result = runner.invoke(app, ["attest", str(child)]) + assert result.exit_code == 0 + + payload = json.loads(result.stdout) + assert payload["primaryType"] == "Attest" + assert payload["message"]["data"] == descriptor_hash({"a": 1, "b": 2, "c": 3}) + assert payload["message"]["revocable"] is True + assert len(payload["message"]["salt"]) == 66 + + +def test_cli_attest_tx(tmp_path: Path) -> None: + descriptor = tmp_path / "descriptor.json" + _write_json(descriptor, {"a": 1}) + + result = runner.invoke(app, ["attest", "--format", "tx", str(descriptor)]) + assert result.exit_code == 0 + + payload = json.loads(result.stdout) + assert set(payload) == {"to", "value", "data", "chainId"} + assert payload["to"] == "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587" + assert payload["value"] == "0x0" + assert payload["chainId"] == "0x1" + assert payload["data"].startswith("0x" + ATTEST_SELECTOR.hex()) diff --git a/tests/test_main.py b/tests/test_main.py index 023baa73..e6ea720e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -24,6 +24,7 @@ def test_help() -> None: assert "ERC-7730" in out assert "convert" in out assert "lint" in out + assert "attest" in out @pytest.mark.parametrize("model_type", list(ERC7730ModelType)) From 4839f9d488c8f6b3f22d2a0d7e47e7989f92bef5 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Thu, 28 May 2026 17:37:01 +0200 Subject: [PATCH 2/3] fix: restore jcs dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 39d5421b..f9890cba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "typer>=0.12.5", "eth-utils>=5.0.0", "jsonschema", + "jcs", "eth_hash[pycryptodome]", "rich", "typer", From a2d047871bfb93db793e15bcb9e169a127f990ab Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Thu, 28 May 2026 17:55:15 +0200 Subject: [PATCH 3/3] fix: restore arc ledger network ids --- src/erc7730/common/ledger.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/erc7730/common/ledger.py b/src/erc7730/common/ledger.py index c36b436d..21c034ad 100644 --- a/src/erc7730/common/ledger.py +++ b/src/erc7730/common/ledger.py @@ -147,6 +147,8 @@ def ledger_network_id(chain_id: int) -> str | None: return "mantle_sepolia" case 5031: return "somnia" + case 5042: + return "arc" case 6342: return "megaeth_testnet" case 8217: @@ -225,6 +227,8 @@ def ledger_network_id(chain_id: int) -> str | None: return "id4good" case 1313114: return "ether1" + case 5042002: + return "arc_testnet" case 7762959: return "musicoin" case 11155111: