A minimal, upgradeable on-chain registry where anyone can record an endorsement of any target. An endorsement is a tuple of (endorsementType, targetType, target, data).
EEP generalizes the concept of "starring" to arbitrary endorsement types with flexible cross-chain targets. It is designed for AI agents endorsing other agents, domain names, smart contracts, or any addressable entity.
Inspired by Ethereum Follow Protocol (EFP) and its prefix-byte encoding approach. EEP goes further by supporting ERC-7930 cross-chain addresses as a target type from day one.
- UUPS upgradeable proxy pattern (OpenZeppelin 5.x)
- AccessControl —
DEFAULT_ADMIN_ROLEauthorizes upgrades. No owner, no pause. - Storage key:
hashedTarget = keccak256(endorsementType || targetType || target)— endorsements of the same target but different types are tracked separately. - Deploy target: Base mainnet initially; the same contract can be deployed on any EVM chain.
Storage uses a mapping-of-mapping pattern with a unified counter:
mapping(bytes32 => mapping(uint256 => Endorsement)) _endorsements;
mapping(bytes32 => uint256) _counts;_counts[hashedTarget] is a single counter that advances for every endorsement (methods 1 and 2). It doubles as the next write index. Method 1 (endorse) writes the full endorsement struct at the current index before incrementing. Method 2 (endorseCount) only increments the counter, leaving a gap — reading _endorsements[hashedTarget][thatIndex] returns the zero struct.
This gives every endorsement a deterministic sequence number regardless of method. Indexers can iterate 0..count-1, distinguishing stored endorsements (endorser != address(0)) from counter-only gaps.
| Value | Meaning |
|---|---|
0x01 |
Star |
0x02+ |
Reserved for future endorsement types |
| Value | Meaning |
|---|---|
0x01 |
Domain — target is UTF-8 bytes of a domain name (e.g. vitalik.eth, example.com) |
0x02 |
ERC-7930 address — target is an ERC-7930 encoded cross-chain address |
Contents depend on targetType:
- Domain (
0x01): UTF-8 encoded domain name, e.g."vitalik.eth"(11 bytes),"example.com"(11 bytes). The protocol does not resolve or validate the domain — that is the consumer's job. - ERC-7930 (
0x02): Binary cross-chain address per the ERC-7930 spec
For Star endorsements: UTF-8 comment text. Empty bytes allowed.
An Ethereum L1 address encoded in ERC-7930:
| Field | Bytes | Value (example) |
|---|---|---|
| Version | 2 | 0x0001 |
| ChainType | 2 | 0x0000 (eip155) |
| ChainRefLength | 1 | 0x01 |
| ChainReference | 1 | 0x01 (chain ID 1) |
| AddressLength | 1 | 0x14 (20) |
| Address | 20 | 20-byte Ethereum address |
| Total | 27 |
Full endorsement key components for hashing:
| Component | Bytes |
|---|---|
| endorsementType | 1 |
| targetType | 1 |
| target (ERC-7930 eth addr) | 27 |
| Total key input | 29 |
For a Base address (chain ID 8453 = 0x2105), ChainReference is 2 bytes → target is 28 bytes, total key input is 30 bytes. The 2-byte saving from shrinking both type fields to bytes1 gives headroom for chains with larger chain IDs or non-Ethereum address schemes.
function endorse(bytes1 endorsementType, bytes1 targetType, bytes target, bytes data) external;Stores the complete endorsement at the current sequence index, then increments the unified counter. Emits Endorsed event with full data.
Use when: You need on-chain queryability of individual endorsements (who endorsed, when, with what comment).
Example — Star a domain name:
// ENS domain
registry.endorse(
0x01, // Star
0x01, // Domain target
bytes("vitalik.eth"), // target
bytes("legend") // comment
);
// DNS domain — same target type, different string
registry.endorse(
0x01, // Star
0x01, // Domain target
bytes("example.com"), // target
bytes("useful site") // comment
);function endorseCount(bytes1 endorsementType, bytes1 targetType, bytes target, bytes data) external;Increments the unified counter and emits EndorsedCount event. Does not store endorsement data — the mapping slot at this sequence index remains the zero struct. Cheaper gas than method 1.
Use when: You only need the count on-chain and can reconstruct details from event logs.
Example — Star an ERC-7930 cross-chain address:
bytes memory target = abi.encodePacked(
bytes2(0x0001), // ERC-7930 version
bytes2(0x0000), // chainType: eip155
bytes1(0x01), // chainRefLen
bytes1(0x01), // chainRef: chain 1
bytes1(0x14), // addrLen: 20
targetAddress // 20-byte address
);
registry.endorseCount(0x01, 0x02, target, bytes(""));Send a raw transaction to the registry contract with calldata that does not match any function selector. The calldata itself is the endorsement.
No event emitted. No counter incremented. Cheapest gas cost. Indexers must scan transaction calldata directly.
Encoding: abi.encode(bytes1, bytes1, bytes, bytes) — full ABI encoding with length prefixes, so the boundary between the variable-length target and data fields is unambiguous.
Use when: Minimizing gas is the priority and you have an off-chain indexer.
Example — Raw calldata star:
// Star vitalik.eth via inscription (no function, raw calldata)
(bool ok, ) = address(registry).call(
abi.encode(
bytes1(0x01), // endorsementType = Star
bytes1(0x01), // targetType = Domain
bytes("vitalik.eth"), // target
bytes("inscribed star") // data
)
);Methods 1 & 2 are standard function calls: calldata is 4-byte selector || abi.encode(bytes1, bytes1, bytes, bytes). Indexers already have the ABI and can decode via the normal event log or calldata-trace path.
Method 3 inscriptions: calldata is abi.encode(bytes1, bytes1, bytes, bytes) directly, with no selector prefix. Indexers scan transactions sent to the registry whose calldata does not start with a known function selector (endorse or endorseCount), then decode:
(bytes1 endorsementType, bytes1 targetType, bytes memory target, bytes memory data) =
abi.decode(calldata, (bytes1, bytes1, bytes, bytes));Distinguish valid inscriptions from accidental transfers by verifying the decoded endorsementType and targetType correspond to registered type values.
// Get unified count (methods 1 + 2) — also the next sequence index
bytes32 hashed = registry.hashTarget(0x01, 0x01, bytes("vitalik.eth"));
uint256 count = registry.endorsementCount(hashed);
// Iterate all sequence indices — sparse mapping means some are zero structs
for (uint256 i = 0; i < count; i++) {
IEEPRegistry.Endorsement memory e = registry.getEndorsement(hashed, i);
if (e.endorser != address(0)) {
// Full on-chain endorsement (method 1)
} else {
// Counter-only gap (method 2) — data lives in EndorsedCount event logs
}
}Prerequisites: Foundry
# Build
forge build
# Test
forge test -v
# Deploy to Base mainnet (dry run)
forge script script/DeployEEPRegistry.s.sol --rpc-url base
# Deploy to Base mainnet (broadcast)
forge script script/DeployEEPRegistry.s.sol --rpc-url base --broadcast --verifySet environment variables:
BASE_RPC_URL— Base mainnet RPC endpointBASESCAN_API_KEY— for contract verification
The deployer address becomes the admin (DEFAULT_ADMIN_ROLE) and can authorize future upgrades.
src/
├── EEPRegistry.sol # Main registry contract (UUPS upgradeable)
└── interfaces/
└── IEEPRegistry.sol # Interface with structs, events, errors
test/
└── EEPRegistry.t.sol # Foundry tests (27 tests)
script/
└── DeployEEPRegistry.s.sol # Deploy script (ERC1967 proxy)
MIT