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
765 changes: 765 additions & 0 deletions ERCS/erc-8489.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions assets/erc-8489/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# AIFP assets index

Reference material for ERC-8489, the Agent Information Flow Paradigm. Read in agent-communication terms (a reading
aid, not a redefinition), AIFP records, for any context in which agents exchange information, who may observe,
contribute and redistribute, under which version of the rules; the protocols that carry the messages attach to
a channel as transport bindings. In that reading a paradigm is a communication pattern, a channel is a
communication context, a role binding is a listener, speaker, forwarder or moderator permission, and a predicate
is the admission check an agent passes before it may take part.

| Path | Contents |
| --- | --- |
| `contracts/interfaces/` | `IAIFPParadigmRegistry`, `IAIFPChannelRegistry`, `IAIFPParticipationRegistry`, `IAIFPPredicateVerifier` (with `IAIFPCompositeVerifier` and `IAIFPSubjectSet`), `IAIFPEventAnchor`, `IERC165`, `IERC6372` |
| `contracts/libraries/` | `AIFPRoles`, `AIFPOutcomes`, `AIFPSubject`, `AIFPPredicates`, `AIFPBindings`, `AIFPBasis`, `AIFPEvents`, `AIFPCalls` |
| `contracts/` | reference registries `AIFPParadigmRegistry`, `AIFPChannelRegistry`, `AIFPParticipationRegistry`, the `AIFPCompositeVerifier`, the `AIFPSubjectSet` roster, the optional `AIFPEventAnchor` profile |
| `contracts/companions/` | `AIFPObjectionWindow` (absence semantics companion, not part of the kernel) |
| `schemas/` | `paradigm-descriptor.schema.json`, `predicate.schema.json`, `binding-capabilities.schema.json`, `subject-ref.schema.json` |
| `examples/paradigms/` | one descriptor per named paradigm: source, intake, direct, panel, club, meeting, archive, blackboard, dispatch, forward, derive |
| `examples/composites/` | sealed-bid tender, blind review, embargoed announcement, FOGA handshake, heartbeat liveness, staked agora |
| `examples/invalid/` | descriptors that the canonical validator rejects, one per failure class |
| `vectors/` | `subject-refs.json`, `predicate-encoding.json`, `outcome-tables.json`, `interface-ids.json`, `paradigm-ids.json` |

The canonical validator (`tools/descriptor-validate.mjs` in the repository that hosts these assets) checks a descriptor against the schemas and the identification conditions, derives the per-role predicate encodings and prints the registration bundle for `registerParadigm`. The vectors are regenerated by `tools/make-vectors.mjs` and checked against the contracts by the Foundry suite.
339 changes: 339 additions & 0 deletions assets/erc-8489/contracts/AIFPChannelRegistry.sol

Large diffs are not rendered by default.

151 changes: 151 additions & 0 deletions assets/erc-8489/contracts/AIFPCompositeVerifier.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

import {IERC165} from "./interfaces/IERC165.sol";
import {IAIFPCompositeVerifier, IAIFPPredicateVerifier, IAIFPSubjectSet} from "./interfaces/IAIFPPredicateVerifier.sol";
import {AIFPPredicates} from "./libraries/AIFPPredicates.sol";
import {AIFPOutcomes} from "./libraries/AIFPOutcomes.sol";
import {AIFPCalls} from "./libraries/AIFPCalls.sol";

/// @title AIFPCompositeVerifier
/// @notice Reference evaluator of predicate trees. Order of evaluation, fixed by the paradigm:
/// 1. the node array is decoded (bytes that do not decode revert);
/// 2. every node kind is checked for support; one unsupported kind makes the whole predicate
/// UNDETERMINABLE before anything else is judged - a verifier cannot tell whether an array is a
/// tree when it does not know whether one of its kinds has children;
/// 3. the array is checked for well-formedness - a tree of at most 64 nodes and depth 8 (a malformed
/// predicate reverts: it is a caller error, not an outcome);
/// 4. the children of a composite are evaluated in index order (OPEN, ALLOWLIST natively; EXTERNAL
/// and semantic kinds through the declared verifier). A leaf call that reverts, does not complete
/// within the gas bound, returns anything other than exactly one 32-byte word, or returns a word
/// outside the declared range is VERIFIER_ERROR for that leaf only;
/// 5. composites combine their children with the four-valued allOf / anyOf rules. An `anyOf` stops at
/// its first SATISFIED child and an `allOf` at its first NOT_SATISFIED child: those answers decide
/// the composite whatever the remaining children would answer, so the remaining children are not
/// evaluated and their leaves are not called.
/// @dev Three properties of a leaf call, each of which a typed `try ... returns (uint8)` would not give:
/// - the raw return data is judged here. Solidity decodes a typed return value in the caller, so a
/// verifier that returns no data or a word above 255 would revert the whole evaluation instead of its
/// own leaf;
/// - at most 32 bytes of return data are copied (`AIFPCalls.staticcallWord`), so a leaf cannot make this
/// contract pay for a large return buffer that its own gas bound does not cover;
/// - every leaf that is called is given exactly `leafGasLimit()` gas, whatever gas the caller of
/// `evaluate` supplied. When that amount cannot be forwarded in full the evaluation reverts with
/// `AIFP_InsufficientGas` and reports no outcome: an outcome must not depend on the caller's gas, and a
/// leaf that was given less than the bound has not been shown to be faulty.
/// Two things about a transaction still reach a leaf, and no composite verifier can remove them. What a
/// leaf *needs* is not constant: state access is cheaper for accounts and storage slots already touched
/// in the transaction, so a leaf whose cost is close to the bound can complete in one transaction and
/// not in another; a leaf verifier has to fit the bound from a cold start for its answer to be the same
/// everywhere. And the sender of a transaction is charged its gas limit times the gas price before
/// execution, so a leaf that reads the native balance of the sending account sees a balance that depends
/// on the gas limit.
contract AIFPCompositeVerifier is IAIFPCompositeVerifier, IERC165 {
using AIFPPredicates for AIFPPredicates.Node[];

error AIFP_MalformedPredicate();

/// @dev What a leaf is asked about; constant for one evaluation.
struct Question {
bytes32 channelId;
bytes32 subject;
uint16 role;
bytes32 scopeRef;
}

uint256 internal constant LEAF_GAS_LIMIT = 1_000_000;

/// @dev Gas kept, on top of `LEAF_GAS_LIMIT * 64 / 63`, for the leaf call itself when checking that the
/// bound can be forwarded in full. The call costs 100 gas here, because `_eval` has already read the
/// target's code size; the margin would also cover a cold account (2,600) with a cold EIP-7702
/// delegate (2,600) and the opcodes between the check and the call.
uint256 internal constant LEAF_CALL_MARGIN = 10_000;

function supportsInterface(bytes4 interfaceId) public pure returns (bool) {
return interfaceId == type(IERC165).interfaceId || interfaceId == type(IAIFPCompositeVerifier).interfaceId;
}

function supportsKind(uint8 kind) public pure returns (bool) {
return kind <= AIFPPredicates.MAX_KNOWN_KIND;
}

/// @notice Gas given to every leaf call: exactly this amount, never less and never more.
function leafGasLimit() public pure returns (uint256) {
return LEAF_GAS_LIMIT;
}

function evaluate(
bytes32 channelId,
bytes32 subject,
uint16 role,
bytes32 scopeRef,
bytes calldata predicate,
bytes[] calldata evidence
) external view returns (uint8) {
AIFPPredicates.Node[] memory nodes = AIFPPredicates.decode(predicate);
// Kinds first: an array that uses a kind this verifier does not know cannot be judged at all, not
// even for well-formedness, since the unknown kind may be one that has children.
for (uint256 i = 0; i < nodes.length; i++) {
if (!supportsKind(nodes[i].kind)) return AIFPOutcomes.UNDETERMINABLE;
}
if (!nodes.isWellFormed()) revert AIFP_MalformedPredicate();
Question memory q = Question({channelId: channelId, subject: subject, role: role, scopeRef: scopeRef});
return _eval(nodes, 0, q, evidence);
}

/// @dev The recursion is at most `AIFPPredicates.MAX_DEPTH` deep and visits each node at most once:
/// `isWellFormed` has checked the depth of every node and that the array is a tree.
function _eval(AIFPPredicates.Node[] memory nodes, uint256 index, Question memory q, bytes[] calldata evidence)
internal
view
returns (uint8)
{
AIFPPredicates.Node memory node = nodes[index];
if (AIFPPredicates.isComposite(node.kind)) {
// NOT_SATISFIED decides an allOf and SATISFIED decides an anyOf (AIFPOutcomes): stop there.
uint8 deciding = node.kind == AIFPPredicates.ALL_OF ? AIFPOutcomes.NOT_SATISFIED : AIFPOutcomes.SATISFIED;
bool undeterminable;
bool verifierError;
for (uint256 c = 0; c < node.childCount; c++) {
uint8 outcome = _eval(nodes, node.childStart + c, q, evidence);
if (outcome == deciding) return deciding;
if (outcome == AIFPOutcomes.UNDETERMINABLE) undeterminable = true;
else if (outcome == AIFPOutcomes.VERIFIER_ERROR) verifierError = true;
}
if (undeterminable) return AIFPOutcomes.UNDETERMINABLE;
if (verifierError) return AIFPOutcomes.VERIFIER_ERROR;
// Every child gave the other definite answer.
return deciding == AIFPOutcomes.SATISFIED ? AIFPOutcomes.NOT_SATISFIED : AIFPOutcomes.SATISFIED;
}
if (node.kind == AIFPPredicates.OPEN) return AIFPOutcomes.SATISFIED;
if (node.verifier == address(0)) return AIFPOutcomes.UNDETERMINABLE;
if (node.verifier.code.length == 0) return AIFPOutcomes.VERIFIER_ERROR; // a declared verifier or set without code is a verifier fault
if (node.kind == AIFPPredicates.ALLOWLIST) {
(bool ok, uint256 contained) = _callLeaf(node.verifier, abi.encodeCall(IAIFPSubjectSet.contains, (q.subject)), 1);
if (!ok) return AIFPOutcomes.VERIFIER_ERROR;
return contained == 1 ? AIFPOutcomes.SATISFIED : AIFPOutcomes.NOT_SATISFIED;
}
// EXTERNAL and semantic kinds are delegated.
bytes memory ev = index < evidence.length ? bytes(evidence[index]) : bytes("");
(bool success, uint256 answer) = _callLeaf(
node.verifier,
abi.encodeCall(IAIFPPredicateVerifier.verify, (q.channelId, q.subject, q.role, q.scopeRef, node.kind, node.ref, node.params, ev)),
AIFPOutcomes.VERIFIER_ERROR
);
if (!success) return AIFPOutcomes.VERIFIER_ERROR;
// The cast cannot truncate: _callLeaf has already rejected any word above VERIFIER_ERROR (3).
// forge-lint: disable-next-line(unsafe-typecast)
return uint8(answer);
}

/// @dev One leaf call with exactly `LEAF_GAS_LIMIT` gas. Reverts with `AIFP_InsufficientGas` when that
/// amount cannot be forwarded in full. Returns (false, 0) when the leaf reverts, does not complete
/// within the bound, returns data that is not exactly one word, or returns a word above `maxValue`;
/// otherwise (true, word). The caller has read `target.code.length`, so the account is warm here.
function _callLeaf(address target, bytes memory data, uint256 maxValue) internal view returns (bool, uint256) {
if (!AIFPCalls.hasGasFor(LEAF_GAS_LIMIT, LEAF_CALL_MARGIN)) revert AIFP_InsufficientGas();
(bool success, uint256 size, bytes32 word) = AIFPCalls.staticcallWord(target, data, LEAF_GAS_LIMIT);
if (!success || size != 32 || uint256(word) > maxValue) return (false, 0);
return (true, uint256(word));
}
}
156 changes: 156 additions & 0 deletions assets/erc-8489/contracts/AIFPEventAnchor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

import {IERC165} from "./interfaces/IERC165.sol";
import {IAIFPEventAnchor} from "./interfaces/IAIFPEventAnchor.sol";
import {IAIFPChannelRegistry} from "./interfaces/IAIFPChannelRegistry.sol";
import {IAIFPParticipationRegistry} from "./interfaces/IAIFPParticipationRegistry.sol";
import {IERC6372} from "./interfaces/IERC6372.sol";
import {AIFPEvents} from "./libraries/AIFPEvents.sol";
import {AIFPRoles} from "./libraries/AIFPRoles.sol";
import {AIFPOutcomes} from "./libraries/AIFPOutcomes.sol";
import {AIFPSubject} from "./libraries/AIFPSubject.sol";

/// @title AIFPEventAnchor
/// @notice Reference implementation of the optional event-anchor profile. The anchor checks, at the
/// registry clock, that the actor holds the role the action needs; it records the rule version in
/// force and the role binding that satisfied the check; supersede and retract are accepted only from
/// the subject that anchored the target object. A forward keeps the object reference unchanged; a
/// derivation anchors a new object that points back to its source. Anchoring proves a declaration
/// at a time, not delivery or truth.
/// @dev The role check is a decision taken when the transaction executes. The record keeps the binding it
/// relied on because that decision cannot always be recomputed afterwards: a binding revoked or
/// suspended later within the same timepoint no longer evaluates as SATISFIED at that timepoint.
contract AIFPEventAnchor is IAIFPEventAnchor, IERC165 {
IAIFPChannelRegistry public immutable channelRegistry;
IAIFPParticipationRegistry public immutable participationRegistry;

/// @dev Stored form of an `EventRecord`: the same fields, ordered so that `at` and `action` share a slot.
struct StoredRecord {
bytes32 channelId;
bytes32 subject;
bytes32 objectRef;
bytes32 ruleVersion;
bytes32 bindingId;
uint48 at;
uint8 action;
}

StoredRecord[] private _records;
mapping(bytes32 => bytes32) private _anchoredBy; // objectRef => subject
mapping(bytes32 => bytes32) private _anchoredIn; // objectRef => channelId

error AIFP_InvalidAction(uint8 action);
error AIFP_InvalidRelation(uint8 kind);
error AIFP_MissingRelation(uint8 action, uint8 requiredKind);

constructor(IAIFPChannelRegistry channelRegistry_, IAIFPParticipationRegistry participationRegistry_) {
channelRegistry = channelRegistry_;
participationRegistry = participationRegistry_;
}

function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC165).interfaceId || interfaceId == type(IAIFPEventAnchor).interfaceId;
}

function anchor(bytes32 channelId, uint8 action, bytes32 objectRef, Relation[] calldata relations)
external
returns (uint256 recordIndex)
{
if (action > AIFPEvents.MAX_ACTION) revert AIFP_InvalidAction(action);
bytes32 subject = AIFPSubject.self(msg.sender);
uint48 now_ = IERC6372(address(channelRegistry)).clock();
uint16 role = _roleFor(action);
// The role is checked for the whole channel (scope zero), at the current clock value.
(uint8 outcome, bytes32 bindingId) = participationRegistry.evaluateWithBinding(channelId, subject, role, bytes32(0), now_);
if (outcome != AIFPOutcomes.SATISFIED) revert AIFP_RoleNotHeld(channelId, subject, role, outcome);
if (action == AIFPEvents.ACTION_FORWARD) {
// The same object enters another context: it must already be anchored and keeps its reference.
if (_anchoredBy[objectRef] == bytes32(0)) revert AIFP_NotAnchoredBySubject(objectRef, bytes32(0));
_requireRelation(relations, AIFPEvents.REL_FORWARDS, action);
} else {
if (_anchoredBy[objectRef] != bytes32(0)) revert AIFP_ObjectAlreadyAnchored(objectRef);
_anchoredBy[objectRef] = subject;
_anchoredIn[objectRef] = channelId;
}
if (action == AIFPEvents.ACTION_DERIVE) _requireRelation(relations, AIFPEvents.REL_DERIVES_FROM, action);
if (action == AIFPEvents.ACTION_SYNTHESIZE) _requireRelation(relations, AIFPEvents.REL_SYNTHESIZES, action);
if (action == AIFPEvents.ACTION_SUPERSEDE) _requireOwnTarget(relations, AIFPEvents.REL_SUPERSEDES, subject, action);
if (action == AIFPEvents.ACTION_RETRACT) _requireOwnTarget(relations, AIFPEvents.REL_RETRACTS, subject, action);

bytes32 ruleVersion = channelRegistry.paradigmAt(channelId, now_);
_records.push(
StoredRecord({
channelId: channelId,
subject: subject,
objectRef: objectRef,
ruleVersion: ruleVersion,
bindingId: bindingId,
at: now_,
action: action
})
);
recordIndex = _records.length - 1;
// Between the role check above and this log the anchor makes no call that can change state
// (`paradigmAt` is a view, called statically), so no other contract can record anything in between:
// the position of this log in the chain is the position at which the action was accepted.
emit EventAnchored(channelId, subject, objectRef, action, ruleVersion, now_, recordIndex, bindingId);
for (uint256 i = 0; i < relations.length; i++) {
if (relations[i].kind > AIFPEvents.MAX_RELATION) revert AIFP_InvalidRelation(relations[i].kind);
emit RelationAnchored(objectRef, relations[i].kind, relations[i].objectRef);
}
}

function record(uint256 recordIndex) external view returns (EventRecord memory) {
StoredRecord storage r = _records[recordIndex];
return EventRecord({
channelId: r.channelId,
subject: r.subject,
action: r.action,
objectRef: r.objectRef,
ruleVersion: r.ruleVersion,
at: r.at,
bindingId: r.bindingId
});
}

function anchoredBy(bytes32 objectRef) external view returns (bytes32) {
return _anchoredBy[objectRef];
}

function anchoredIn(bytes32 objectRef) external view returns (bytes32) {
return _anchoredIn[objectRef];
}

function recordCount() external view returns (uint256) {
return _records.length;
}

function _roleFor(uint8 action) internal pure returns (uint16) {
if (action == AIFPEvents.ACTION_PUBLISH || action == AIFPEvents.ACTION_SUBMIT) return AIFPRoles.SUBMIT;
if (action == AIFPEvents.ACTION_RESPOND) return AIFPRoles.RESPOND;
if (action == AIFPEvents.ACTION_MODIFY) return AIFPRoles.MODIFY;
if (action == AIFPEvents.ACTION_FORWARD || action == AIFPEvents.ACTION_DERIVE || action == AIFPEvents.ACTION_SYNTHESIZE) {
return AIFPRoles.REDISTRIBUTE;
}
return AIFPRoles.SUBMIT; // supersede, retract: the author acts on its own object
}

function _requireRelation(Relation[] calldata relations, uint8 kind, uint8 action) internal pure {
for (uint256 i = 0; i < relations.length; i++) {
if (relations[i].kind == kind) return;
}
revert AIFP_MissingRelation(action, kind);
}

function _requireOwnTarget(Relation[] calldata relations, uint8 kind, bytes32 subject, uint8 action) internal view {
bool found;
for (uint256 i = 0; i < relations.length; i++) {
if (relations[i].kind == kind) {
found = true;
if (_anchoredBy[relations[i].objectRef] != subject) revert AIFP_NotAnchoredBySubject(relations[i].objectRef, subject);
}
}
if (!found) revert AIFP_MissingRelation(action, kind);
}
}
Loading
Loading