Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies = [
"typer>=0.12.5",
"eth-utils>=5.0.0",
"jsonschema",
"jcs",
"eth_hash[pycryptodome]",
"rich",
"typer",
Expand Down
170 changes: 170 additions & 0 deletions src/erc7730/attest.py
Original file line number Diff line number Diff line change
@@ -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",
}
30 changes: 29 additions & 1 deletion src/erc7730/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.",
Expand Down
102 changes: 102 additions & 0 deletions tests/test_attest.py
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading