EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8301: AI Agent Execution

An on-chain execution interface for AI agent task dispatch, orchestration, and verifiable step execution

Authors
Created2026-06-15
Discussion Linkhttps://ethereum-magicians.org/t/erc-8301-ai-agent-execution/28785

Markdown

https://raw.githubusercontent.com/ethereum/ERCs/re...
Pull Request#1815PR open

EIP-GPT summary

Contents
AbstractMotivationSpecificationData EnvelopesIAgentWorkflow β€” Core InterfaceExecution Modelrun() β€” Run InitiationonAgentReply β€” Reply SubmissiononAgentProve β€” Proof SubmissionGate Logic and Stage TransitionEvidence ChainStatic vs Dynamic FSMRationaleContract-as-WorkflowFSM ModelOpen Task DispatchEvidence Chain DesignLogical Reply IdempotencySync and Async Gate ModesFSM Anchor and OCP Temporal AnchorBackwards CompatibilityReference ImplementationCall SequencesSecurity ConsiderationsCopyright

Abstract

This ERC defines IAgentWorkflow: a standard on-chain interface for AI agent task execution. It pursues three goals: a universal execution interface so agents can be developed independently of any application framework and compete across protocols; an on-chain orchestration framework so reliability mechanisms such as BFT voting, competitive selection, and commit-reveal are enforceable by contract rather than assumed from off-chain coordination β€” and deterministic pipelines where every step is pre-declared and auditable; and a full execution evidence chain so trust in results derives from structure β€” any party can traverse the on-chain AgentTask / AgentReply chain from run() to result() and verify every step independently, without trusting any individual agent.

Motivation

1. A universal execution interface frees agents from application frameworks.

The emerging on-chain AI agent ecosystem has produced complementary standards β€” agent identity (ERC-8004), agentic commerce (ERC-8183), skill registry (ERC-8257) β€” each well-scoped to its layer. But every protocol that needs to invoke an agent defines its own invocation format in isolation: ERC-8183 encodes tasks as a free-form description string; ERC-8004 delegates execution entirely to off-chain conventions. Without a shared interface, agent builders must re-implement adapters for each protocol, agents are locked to the frameworks that built them, and competition between agents across protocols is structurally impossible.

2. An on-chain orchestration framework enables reliable distributed execution.

Off-chain orchestration frameworks (LangGraph, AutoGen, CrewAI) coordinate agents for efficiency, but their results are only as trustworthy as the agents themselves. On-chain, the execution structure can itself be the trust mechanism: when workflow logic lives in a contract, every stage transition is gate-enforced and no agent can bypass or reorder it. Patterns such as commit-reveal, BFT voting, and competitive selection are reliability mechanisms β€” they only compose correctly when the workflow is enforced by a contract rather than coordinated off-chain. For deterministic pipelines (audit trail, compliance, regulatory evidence), a static FSM with pre-declared transitions provides the same gate-enforced integrity with full omission detectability.

3. Trust through structure: on-chain evidence chain makes results independently verifiable.

Even when agents are permissionless and unknown, the execution itself can be trusted if every step is anchored on-chain. The bidirectional prevReplyHashes / prevTaskHashes chain means that any party β€” not just the caller β€” can traverse from result().finalTaskHash back to run() and verify every transition was gate-approved and every reply was submitted by the claimed replier. Trust in the result does not require trusting any individual agent; it requires only trusting the workflow contract's gate logic.

This ERC fills the execution primitive in the agent base layer. It defines IAgentWorkflow to:

  1. Provide a universal agent execution interface β€” agents implement once and run across any conformant protocol, decoupled from application frameworks and free to compete on quality.
  2. Define an on-chain orchestration and coordination framework β€” the workflow contract is a finite state machine that enforces every stage transition, making distributed reliability patterns (BFT, competitive selection, commit-reveal) structurally sound rather than off-chain assumptions.
  3. Establish a full on-chain execution evidence chain β€” every task and reply is anchored and linked; results are verifiable by anyone from run() to result() without trusting any individual agent.
PrimitiveBlockchain AnalogueERC
IdentityAddressERC-8004
ExecutionSmart ContractThis ERC
VerifyConsensusAI Inference Proof Verification
AnchorOn-chain StateTruthAnchor
SettlementValue TransferAgent Escrow & Settlement
ProveLogs / Audit TrailOCP + WYRIWE

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.


Data Envelopes

Four types carry all information between the contract, agents, and external callers.

RunStatus

enum RunStatus { Pending, Success, Failed }
  • Pending β€” run is active; no result yet
  • Success β€” FSM reached a terminal stage with all gates satisfied; finalTaskHash is non-zero
  • Failed β€” run aborted; finalTaskHash is bytes32(0)

AgentTask β€” task dispatched by the contract

struct AgentTask {
    uint8     stage;            // FSM stage (developer-defined enum cast to uint8)
    uint256   taskSeq;          // Per-run monotonic counter; starts at 0
    bytes32   inputHash;        // keccak256(input)
    bytes     input;            // Input plaintext; MAY be empty if conveyed off-chain
    uint256   timestamp;        // block.timestamp at emission
    uint256   expiresAt;        // Unix timestamp after which this task is no longer valid
    bytes32[] prevReplyHashes;  // Replies that triggered this task; empty for the initial task
    bytes32   workflowRunId;    // Run identifier
}

The contract stores one AgentTask per taskHash and MUST compute taskHash as follows (input plaintext is excluded):

taskHash = keccak256(abi.encode(
    task.stage,      task.taskSeq,
    task.inputHash,  task.timestamp,  task.expiresAt,
    keccak256(abi.encodePacked(task.prevReplyHashes)),
    task.workflowRunId
))

prevReplyHashes records which AgentReply submissions triggered this task via gate logic; it is empty for the initial task emitted by run(). When prevReplyHashes is empty, the inner term keccak256(abi.encodePacked(prevReplyHashes)) MUST resolve to keccak256(abi.encodePacked([])) i.e. keccak256("") = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470. Implementations MUST NOT special-case the empty array to bytes32(0). This pins the initial taskHash so two independent implementations cannot diverge at the entry point of the evidence chain.

AgentReply β€” reply submitted by an agent

struct AgentReply {
    bytes32   operationId;     // Opaque logical operation identifier; stable across retries
    bytes32   outputHash;      // keccak256(output)
    bytes     output;          // Reply output plaintext; MAY be empty
    uint256   timestamp;       // Off-chain execution time (Unix)
    address   replier;         // Agent address; MUST equal msg.sender when submitted
    bytes32[] prevTaskHashes;  // Tasks this reply responds to; MUST be non-empty
    bytes32   workflowRunId;   // Run identifier
}

The contract stores one AgentReply per replyHash and MUST compute replyHash as follows:

replyHash = keccak256(abi.encode(
    reply.operationId, reply.outputHash,  reply.timestamp,  reply.replier,
    keccak256(abi.encodePacked(reply.prevTaskHashes)),
    reply.workflowRunId
))

operationId identifies the logical operation that produced the reply. It MUST be non-zero, opaque, assigned by the logical operation's originator before the first submission, and carried unchanged by the replier across retries. A uniformly random bytes32 value is RECOMMENDED. An intentional new operation over the same input or output MUST use a new operationId. Because operationId is part of replyHash, distinct logical replies remain distinguishable even when every other hashed field is identical β€” for example, multiple replies from the same replier to the same tasks, with the same output, submitted within the same one-second timestamp resolution. Implementations MUST NOT derive operationId from reply content: content equality does not imply operation identity.

For idempotency, the contract MUST scope an operationId to the run and replier. The following formula is RECOMMENDED; an equivalent nested mapping is conformant:

operationKey = keccak256(abi.encode(
    reply.workflowRunId,
    reply.replier,
    reply.operationId
))

The contract MUST also bind the operation to stable reply content. Because a reconstructed retry may carry a later timestamp, the operation digest excludes timestamp:

replyDigest = keccak256(abi.encode(
    reply.outputHash,
    reply.replier,
    keccak256(abi.encodePacked(reply.prevTaskHashes)),
    reply.workflowRunId
))

The first accepted submission binds operationKey immutably to (replyDigest, replyHash). prevTaskHashes records which tasks this reply responds to, completing the bidirectional evidence chain (see Evidence Chain).


IAgentWorkflow β€” Core Interface

A conformant workflow contract MUST implement the following interface:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

enum RunStatus { Pending, Success, Failed }

struct AgentTask {
    uint8     stage;
    uint256   taskSeq;
    bytes32   inputHash;
    bytes     input;            // MAY be empty
    uint256   timestamp;        // block.timestamp at emission
    uint256   expiresAt;
    bytes32[] prevReplyHashes;  // empty for the initial task
    bytes32   workflowRunId;
}

struct AgentReply {
    bytes32   operationId;      // MUST be non-zero and stable across retries
    bytes32   outputHash;
    bytes     output;           // MAY be empty
    uint256   timestamp;        // Off-chain execution time (Unix)
    address   replier;          // MUST equal msg.sender when submitted
    bytes32[] prevTaskHashes;   // MUST be non-empty
    bytes32   workflowRunId;
}

interface IAgentWorkflow {

    // ── Invocation ───────────────────────────────────────────────────────────

    /// @notice Start a new workflow run.
    /// @param  inputHash     keccak256 of the input
    /// @param  input         Input plaintext; MAY be empty if conveyed off-chain
    /// @param  expiresAt     Unix timestamp after which the initial task expires
    /// @return workflowRunId Contract-generated unique run identifier
    function run(
        bytes32        inputHash,
        bytes calldata input,
        uint256        expiresAt
    ) external returns (bytes32 workflowRunId);

    /// @notice Query the result of a run.
    /// @return status         Pending | Success | Failed
    /// @return finalTaskHash  taskHash of the terminal AgentTask; bytes32(0) if not Success
    /// @return completedAt    block.timestamp at completion; 0 if not Success
    function result(bytes32 workflowRunId)
        external view
        returns (
            RunStatus  status,
            bytes32    finalTaskHash,
            uint256    completedAt
        );

    // ── Query ────────────────────────────────────────────────────────────────

    /// @notice Returns the stored AgentTask and its proven status.
    /// @dev    MUST revert if taskHash is unknown.
    ///         proven=true iff prevReplyHashes is empty OR all prevReplyHashes are proven.
    function getAgentTask(bytes32 taskHash)
        external view
        returns (AgentTask memory task, bool proven);

    /// @notice Returns the stored AgentReply and its verification status.
    /// @dev    MUST revert if replyHash is unknown.
    function getAgentReply(bytes32 replyHash)
        external view
        returns (
            AgentReply memory reply,
            address           verifier,
            bool              proven,
            bytes32           verificationDigest
        );

    /// @notice Resolve a logical reply operation to its canonical replyHash.
    /// @return replyHash The first anchored replyHash; bytes32(0) if unknown
    function getReplyHashByOperation(
        bytes32 workflowRunId,
        address replier,
        bytes32 operationId
    ) external view returns (bytes32 replyHash);

    // ── Agent Callbacks ──────────────────────────────────────────────────────

    /// @notice Agent submits a reply to a dispatched task.
    /// @param  reply  See AgentReply; reply.replier MUST equal msg.sender
    /// @return replyHash The canonical replyHash for this logical operation
    function onAgentReply(AgentReply calldata reply)
        external returns (bytes32 replyHash);

    /// @notice Submit a cryptographic proof covering one or more anchored replies.
    /// @param  replyHashes  Each MUST have been anchored by onAgentReply
    /// @param  proof        Proof bytes covering all listed replies; encoding is verifier-specific
    function onAgentProve(bytes32[] calldata replyHashes, bytes calldata proof) external;

    // ── Events ───────────────────────────────────────────────────────────────

    /// @notice Emitted when a new task is dispatched. Full details via getAgentTask(taskHash).
    event NewAgentTask(
        bytes32 indexed workflowRunId,
        uint8   indexed stage,
        bytes32 indexed taskHash
    );

    /// @notice Emitted when a reply is anchored, before gate evaluation.
    /// Fires for every profile β€” transparent, confidential, attested β€” at the
    /// same anchor point. A consumer can observe that a reply was submitted and
    /// retrieve it via getAgentReply(replyHash), regardless of whether the gate
    /// later accepts or silently discards it.
    event AgentReplyAnchored(
        bytes32 indexed workflowRunId,
        bytes32 indexed replyHash,
        address indexed replier
    );

    /// @notice Emitted when a run reaches a terminal state.
    event WorkflowCompleted(
        bytes32   indexed workflowRunId,
        RunStatus         status,
        bytes32           finalTaskHash,
        uint256           timestamp
    );
}

getReplyHashByOperation MUST use the same (workflowRunId, replier, operationId) scope as onAgentReply. It MUST return the canonical replyHash for an accepted operation and bytes32(0) when no binding exists.


Execution Model

A conformant IAgentWorkflow contract is a finite state machine (FSM). The developer defines:

  • A stage enum β€” the complete set of states the workflow can be in. Stages MAY include loops; the set is finite and fixed at deploy time.
  • Gate logic β€” conditions for stage transition, evaluated after onAgentReply (async mode) or onAgentProve (sync mode).
  • Proof policy β€” which stages require proof, whether proof is sync or async, and which proof verifier to invoke (IProofVerifier or IAgentVerifier from AI Inference Proof Verification). MAY vary per stage.
  • FSM topology β€” whether the workflow graph is static (all transitions hardcoded at deploy time) or dynamic (path emerges at runtime from gate logic and replies). See Static vs Dynamic FSM.

The contract drives all stage transitions. Agents react to emitted tasks; they do not control sequencing.

Run lifecycle:

run(inputHash, input, expiresAt)
    β”‚  generates workflowRunId; emits NewAgentTask(stage=initial)
    β–Ό
Agent observes NewAgentTask; calls getAgentTask(taskHash) for details
    β”‚  calls onAgentReply(...)
    β–Ό
Contract validates operationId and reply; resolves or stores canonical replyHash
    β”‚  async: gate evaluates immediately after onAgentReply
    β”‚  sync:  gate evaluates only after onAgentProve succeeds
    β”‚  (gate modes defined in Gate Logic and Stage Transition)
    β”‚
    β”œβ”€β”€ gate passes β†’ FSM transitions β†’ emit NewAgentTask(next stage)
    └── gate fails  β†’ retry / abort / silent (implementation-defined)
    β–Ό
... stages repeat ...
    β–Ό
WorkflowCompleted(workflowRunId, Success | Failed, ...)

A single stage MAY emit multiple NewAgentTask events in the same block (e.g. parallel dispatch). taskSeq ensures each is uniquely identified.


run() β€” Run Initiation

The contract MUST generate workflowRunId deterministically. Recommended formula:

workflowRunId = keccak256(abi.encode(msg.sender, block.number, runNonce))

where runNonce is a per-contract monotonic counter. The contract MUST initialise the FSM to its initial stage and emit at least one NewAgentTask before returning.

The contract SHOULD verify keccak256(input) == inputHash when input is non-empty. Callers with sensitive inputs SHOULD pass input = "" and deliver plaintext to agents through a confidential channel; inputHash still commits to the content.


onAgentReply β€” Reply Submission

When an agent calls onAgentReply, the contract MUST enforce the following checks in order:

1. Replier and operation identity

The contract MUST verify reply.replier == msg.sender. This binds the stored struct to the actual submitting address, preventing fronting. The contract MUST reject operationId == bytes32(0).

The contract MUST compute operationKey and replyDigest according to Data Envelopes, then look up the operation before evaluating expiry, current-stage gate eligibility, or any externally visible effect.

If operationKey is already bound:

  • If the stored replyDigest equals the submitted replyDigest, the call is a logical retry. The contract MUST return the stored canonical replyHash without storing another reply, emitting AgentReplyAnchored, evaluating gate logic, invoking downstream hooks, or applying a second charge or settlement effect. The first accepted reply, including its original timestamp, remains canonical.
  • If the digests differ, the contract MUST revert and MUST NOT overwrite the existing binding. Reusing an operationId for different output or task context is an operation-ID conflict, not a retry.

This lookup MUST be atomic with first acceptance. Concurrent calls for the same operationKey MUST result in exactly one accepted reply; all matching retries resolve to its canonical replyHash.

2. prevTaskHashes validity for a new operation

For every h in reply.prevTaskHashes, getAgentTask(h) MUST return a valid record with task.workflowRunId == reply.workflowRunId. The contract MUST reject replies referencing unknown or foreign task hashes.

3. Run and task eligibility for a new operation

The run MUST be Pending. Every referenced task MUST still be eligible to receive a new reply under the workflow's current FSM stage and gate policy. The contract MUST reject a new operation that references a completed, superseded, consumed, or otherwise ineligible task. This check does not apply to a matching retry resolved under step 1 because that retry creates no new reply or state transition.

4. Timestamp and expiry constraints for a new operation

Let anchor_time(h) be block.timestamp recorded when taskHash h was emitted. The submitted timestamp MUST satisfy:

max(anchor_time(h) for h in reply.prevTaskHashes) < reply.timestamp ≀ block.timestamp

The lower bound proves execution occurred after the preceding tasks were anchored. The upper bound prevents future-dated claims. Because anchor_time is written by the contract at emission β€” not by the agent β€” it cannot be manipulated.

The contract SHOULD also verify that block.timestamp ≀ task.expiresAt for all tasks in reply.prevTaskHashes (when expiresAt != 0). New replies submitted after a task has expired SHOULD be rejected. A matching retry of an operation accepted before expiry returns its canonical replyHash under step 1.

5. Compute, bind, and store replyHash

After all checks pass, the contract MUST compute replyHash (formula in Data Envelopes). Before emitting events, evaluating gate logic, or calling external contracts, it MUST immutably bind operationKey to (replyDigest, replyHash), store the full AgentReply struct keyed by replyHash, and record block.timestamp as its anchor time. It MUST then emit AgentReplyAnchored(workflowRunId, replyHash, msg.sender) and return replyHash.

This event fires before gate evaluation and is profile-agnostic β€” it fires for every anchored reply regardless of verification profile (transparent, confidential, attested). It provides observability that a reply was submitted, even when the gate later silently discards it. A consumer can retrieve the full reply via getAgentReply(replyHash).

6. Gate evaluation (async mode)

If the current stage is async, the contract MUST immediately evaluate gate logic and, if it passes, transition the FSM and emit the next NewAgentTask.


onAgentProve β€” Proof Submission

onAgentProve accepts a single proof covering one or more anchored replies. It MAY be called by the agent or any third party.

1. Retrieve stored replies

The contract MUST revert if replyHashes is empty or if any entry is unknown.

For each replyHash, the contract MUST retrieve the stored AgentReply and derive the canonical inputHash from on-chain task data β€” never from caller-supplied data, preventing hash substitution attacks.

AgentReply memory reply = _replies[replyHashes[i]];
(AgentTask memory prevTask,) = getAgentTask(reply.prevTaskHashes[0]);
bytes32 inputHash = prevTask.inputHash;

When reply.prevTaskHashes has more than one entry, prevTaskHashes[0] MUST be the primary task (i.e., the task carrying the input to be proven). If multiple tasks carry different inputs, the proof statement covers prevTaskHashes[0].inputHash; implementations that need to bind multiple inputs MUST encode them into a single composite inputHash at task emission time.

2. Call the proof verifier

The developer chooses one of two conformant options; the choice is implementation-defined and MAY vary per stage.

Option A β€” IAgentVerifier (outer, identity-bound):

Produces a verificationDigest audit anchor. Suitable when proof verification should bind to replier identity.

(bool valid, bytes32 digest) = IAgentVerifier(verifier).verify(
    reply.workflowRunId,
    bytes32(uint256(uint160(reply.replier))),
    inputHash,
    reply.outputHash,
    proof
);

Option B β€” IProofVerifier (inner, stateless):

Pure cryptographic validity; no identity binding. Suitable for ZK, TEE, or signature-based proofs where the replier address is not part of the proof statement.

bool valid = IProofVerifier(verifier).verify(
    inputHash,
    reply.outputHash,
    metadata,  // deployment-time config: circuit key, enclave hash, etc.
    proof
);

3. Update proven state and evaluate gate (sync mode)

After a successful verification, the contract MUST mark the replyHash as proven, MUST propagate proven status to downstream tasks (see Evidence Chain), and MUST NOT call the verifier again for the same replyHash. If the current stage is sync, the contract MUST then evaluate gate logic.


Gate Logic and Stage Transition

Gate logic is implementation-defined. This ERC defines when it is evaluated, not what it checks.

Async mode β€” gate evaluates immediately after onAgentReply:

onAgentReply β†’ validate β†’ store replyHash β†’ gate
    β”œβ”€ pass    β†’ FSM.transition() β†’ emit NewAgentTask(next stage)
    β”œβ”€ retry   β†’ emit NewAgentTask(same stage, incremented taskSeq)
    β”œβ”€ abort   β†’ emit WorkflowCompleted(Failed)
    └─ silent  β†’ no action; stage remains open

Sync mode β€” gate evaluates only after onAgentProve succeeds:

onAgentReply β†’ validate β†’ store replyHash β†’ wait for proof
onAgentProve β†’ verify β†’ proven β†’ gate
    β”œβ”€ pass    β†’ FSM.transition() β†’ emit NewAgentTask(next stage)
    β”œβ”€ retry   β†’ emit NewAgentTask(same stage, incremented taskSeq)
    β”œβ”€ abort   β†’ emit WorkflowCompleted(Failed)
    └─ silent  β†’ no action; stage remains open

The silent failure mode is most useful in permissionless workflows: submissions that fail the gate are discarded without disrupting the run; the stage stays open for further submissions. The AgentReplyAnchored event makes silent failures observable β€” it fires before gate evaluation, so a consumer can see that a reply was submitted even when the gate later discards it. For confidential steps, a DENY verdict emits the event and burns a nullifier, distinguishing "evaluated and rejected" from silence. In a static FSM, the gate can still be competitive (multiple replies, one winner), but the transition target is predetermined β€” the gate selects which reply wins, not which stage is next. Static FSM implementations MAY use revert or explicit-failure modes since the FSM path is not affected by a gate failure; a failed reply simply does not advance the run at the current stage.

Terminal stage. The gate whose pass causes WorkflowCompleted(Success) to be emitted is the final gate; it MUST be sync. When the final sync gate passes, the contract MUST:

  1. Create and store a final AgentTask representing the completion state: stage SHOULD be a terminal sentinel (e.g., type(uint8).max), taskSeq is the next monotonic counter, inputHash = bytes32(0), prevReplyHashes contains all terminal reply hashes that satisfied the gate, timestamp = block.timestamp, and workflowRunId is the current run. The contract computes finalTaskHash via the standard taskHash formula (see Data Envelopes). This final task anchors the completion state in the evidence chain β€” the chain now uniformly ends with a Task node, consistent with every other stage transition.
  2. Emit WorkflowCompleted(workflowRunId, Success, finalTaskHash, block.timestamp). No NewAgentTask is emitted for the terminal task; the WorkflowCompleted event alone serves as the on-chain anchor.
  3. Persist block.timestamp as completedAt in storage so that result() can return it as a view function.
  4. Reject any subsequent new onAgentReply or onAgentProve for this workflowRunId. A matching retry of an already accepted operationKey MUST remain resolvable to its canonical replyHash and MUST have no effect.

A run MAY terminate with Failed at any stage by emitting WorkflowCompleted(workflowRunId, Failed, bytes32(0), block.timestamp). The same terminal callback rules apply after failure: the contract MUST reject subsequent new onAgentReply and onAgentProve calls for that run, while a matching retry of an already accepted operationKey MUST remain resolvable to its canonical replyHash without effect.


Evidence Chain

Every operation in a conformant workflow is stored on-chain and linked into a bidirectional chain verifiable by any party without trusting the contract's internal state.

Structure. The chain alternates between AgentTask and AgentReply nodes:

run()
  β”‚
  β–Ό
AgentTask T0  (prevReplyHashes = [])       ← initial; always proven
  β”‚ agent responds
  β–Ό
AgentReply R0  (prevTaskHashes = [T0])     ← links back to T0
  β”‚ gate passes β†’ FSM transitions
  β–Ό
AgentTask T1  (prevReplyHashes = [R0])     ← links back to R0
  β”‚
  β–Ό
  ... (stages repeat) ...
  β”‚
  β–Ό
AgentReply Rn  (prevTaskHashes = [Tn])     ← terminal reply(s)
  β”‚ terminal gate passes
  β–Ό
AgentTask Tf  (prevReplyHashes = [Rn, ...])  ← final task (no NewAgentTask emitted)
  β”‚
  β–Ό
WorkflowCompleted  (finalTaskHash = Tf)

The chain always ends with a Task node β€” the final AgentTask created at terminal gate pass. The prevReplyHashes / prevTaskHashes link fields (defined in Data Envelopes) form a tamper-evident record traversable from result().finalTaskHash back to the initial task.

Proven state. Each node carries a proven boolean:

Nodeproven = true when
AgentTaskprevReplyHashes is empty (initial task), or all entries in prevReplyHashes are proven
AgentReplyonAgentProve has succeeded for this replyHash

The contract MUST propagate proven status after each onAgentProve: after marking a replyHash proven, check whether all prevReplyHashes of any downstream task are now proven; if so, cache task.proven = true. This makes the sync gate check O(1):

(, bool proven) = getAgentTask(prevTaskHash);
require(proven, "upstream chain not fully proven");

Verification paths.

  • Trusted (O(1)): call getAgentTask(taskHash) and check proven == true.
  • Trustless (full traversal): start from result().finalTaskHash; call getAgentTask(finalTaskHash) to get prevReplyHashes, then traverse by alternating getAgentReply(replyHash) β†’ reply.prevTaskHashes and getAgentTask(taskHash) β†’ task.prevReplyHashes back to the initial task. Prune branches where proven == true is already cached.

result().finalTaskHash is the entry point for external verification. A downstream contract MAY check finalTaskHash != bytes32(0) as a sufficient condition that all gates on the execution path were satisfied β€” provided the workflow contract is correctly implemented.

Static vs Dynamic FSM

This ERC does not mandate a specific FSM topology. The developer chooses whether the workflow is static (all stages and transitions are hardcoded at deploy time) or dynamic (the path emerges at runtime from gate logic and agent replies). Both are conformant; the choice belongs to the use case.

Dynamic FSM. The contract enumerates all possible stages, but the actual execution path is determined at runtime β€” gate logic selects which reply advances the FSM, and which stage is emitted next may depend on the content of the winning reply. This is the natural shape for permissionless, competitive, or BFT workflows:

  • Competitive selection (multiple agents bid; gate picks the lowest bid and silently discards the rest).
  • Commit-reveal rounds (stages are fixed but which agents participate and which commitment wins is unknown until the reveal gate fires).
  • BFT voting (quorum threshold reached β†’ transition; the set of voters is not known in advance).

The trade-off: a dynamic FSM cannot guarantee omission detectability at the spec level. The path is not pre-declared, so an operator who steers the FSM away from a node it dislikes leaves no NewAgentTask for that node β€” and from outside, "the FSM didn't go there" and "the FSM shouldn't have gone there" are the same observation. Omission detectability in a dynamic FSM requires additional machinery: a pre-committed expected node-graph (declared before execution), an event for every stored reply before gate selection, and timeout or expected-transition semantics, so that a missing dispatched task is a visible gap against a stated expectation.

Static FSM. All stages, transitions, and the complete execution graph are hardcoded in the contract. The operator cannot route around a node β€” every declared stage MUST be dispatched via NewAgentTask before the next transition can fire. This closes the omission gap at the implementation level: the contract itself is the pre-declared path, and a suppressed step is a contract rule violation, not an unobservable routing choice.

Static FSM is appropriate for:

  • Auditable workflows where every step must be provably executed (compliance, regulatory evidence, multi-party settlement).
  • Deterministic pipelines where the topology is known in advance (input sanitization β†’ inference β†’ verification β†’ settlement).
  • Scenarios where the beneficiary of the claim is not the operator β€” the static contract guarantees that a third-party skeptic can enumerate every owed step without trusting the operator's word.

The static FSM still requires liveness consideration: ruling out routing-around a node is not the same as forcing the next transition to be called. Without timeout or expected-transition semantics, "the step was suppressed" and "the workflow has not advanced yet" remain the same observation from outside. Implementations SHOULD declare expected-transition windows or timeout semantics where omission detectability is load-bearing.

PropertyDynamic FSMStatic FSM
Path determinedAt runtime (gate logic + replies)At deploy time (hardcoded)
Omission detectabilityRequires additional machineryClosed at implementation level
Best forCompetitive, BFT, open participationAudit, compliance, deterministic pipelines
NewAgentTask coverageOnly nodes the gate actually dispatchesEvery declared node must be dispatched
LivenessSame as staticSame as dynamic

Both shapes use the same IAgentWorkflow interface. The ERC standardises the dispatch, anchoring, and verification surface; the FSM topology is an implementation decision.


Rationale

Contract-as-Workflow

Earlier designs committed to an off-chain workflow definition via agentWorkflowHash. This creates a structural contradiction: the execution definition lives where the contract cannot read or enforce it. When an agent calls back, the contract cannot verify whether the agent followed the committed workflow.

Replacing agentWorkflowHash with the contract itself closes this gap. The workflow contract IS the execution definition: its stage enum defines the valid state space, its gate logic enforces all transition invariants, and its internal storage tracks which replies have been anchored at each stage. Different workflows are different contracts; the ERC standardises only the interface. The AgentVerifier or IProofVerifier is responsible only for proof verification β€” it no longer carries workflow state.

FSM Model

The v0.1 design modelled coordination as a DAG of replies. This expresses data dependencies but has no concept of execution state: the contract cannot distinguish a new coordination round from a competing bid or a consensus reveal without reading an off-chain document.

The FSM model adds a stage dimension. Each NewAgentTask carries a stage value that unambiguously identifies the current phase. Agents can determine what is expected of them from the event alone; the contract enforces valid transitions. Patterns like commit-reveal and BFT voting become expressible on-chain without off-chain coordination: the contract emits stage=Commit tasks, agents commit, the gate transitions to stage=Reveal, and no agent needs to infer the phase from context.

Open Task Dispatch

NewAgentTask carries no agentId. This enables permissionless participation: any conformant agent may monitor the event stream and respond to tasks matching its capabilities, supporting competitive selection, BFT voting, and open agent markets. Permissioned workflows restrict participation inside onAgentReply by checking msg.sender; the interface does not change. taskSeq ensures each emitted task is uniquely identified even when multiple tasks are dispatched in the same block and stage.

Evidence Chain Design

The symmetric prevReplyHashes / prevTaskHashes linking design makes the entire execution history verifiable from result().finalTaskHash alone. Rather than trusting the contract's proven cache, any observer can traverse the alternating task-reply chain back to the initial task and independently verify every anchored hash. The cached proven flag is an optimisation β€” the O(1) sync gate check β€” not a source of truth. The incremental propagation rule (a task becomes proven when all its prevReplyHashes are proven) ensures the chain is self-consistent: a proven task implies a proven upstream chain back to run().

Logical Reply Idempotency

Transaction and account nonces prevent replay of the same signed envelope, but they cannot identify two separately signed calls as retries of the same logical operation. operationId supplies that missing caller intent. It is scoped by workflowRunId and replier, so independent agents and runs may safely reuse the same opaque value.

Content hashes cannot replace operationId: submitting the same artifact twice may intentionally request two independent reviews. Conversely, a reconstructed retry may change the off-chain timestamp while preserving the same output and task context. The immutable operationKey β†’ (replyDigest, replyHash) binding therefore preserves the first accepted reply as canonical without collapsing intentional operations that use different IDs.

This mechanism provides transport-level idempotency only. Whether two distinct operations describe the same real-world event is workflow-specific and remains the responsibility of gate or application logic.

Sync and Async Gate Modes

Two gate modes address two fundamentally different verification shapes:

Post-hoc attestation (async): the agent executes, submits a result via onAgentReply, and proof is submitted afterward. The FSM advances before verification completes. Appropriate for most use cases β€” the proof provides an auditable record; fraud is detectable after the fact.

Pre-action gating (sync): the verifier must approve before the FSM emits the next task. Required when acting on an incorrect or unauthorized input is irreversible (e.g., a task that triggers an on-chain token transfer). The sync-mode WYRIWE (What You Read Is What You Execute) stage exemplifies this: the FSM will not emit the inference task until the input sanitization proof passes, so the gateway cannot substitute an unauthorized transform after seeing the input.

Both shapes are expressible through the same interface. The choice belongs to the workflow contract's proof policy, not to the dispatch interface.

FSM Anchor and OCP Temporal Anchor

The taskAnchorTime[taskHash] = block.timestamp write that occurs at NewAgentTask emission and the OCP record() call are complementary, not competing:

  • FSM anchor is per-task: it records the block timestamp at which a specific task was dispatched. It is written automatically by the workflow contract and cannot be influenced by the caller.
  • OCP anchor is per-input: it commits the input content to an external log, with an independently checkable timestamp, before execution begins.

They compose naturally: OCP anchors the input before the task is dispatched; the FSM anchor records when the task was emitted. A verifier can check both independently β€” that the input was committed before the task existed (ocp.timestamp < taskAnchorTime) and that all subsequent replies carry timestamp > taskAnchorTime. Neither anchor subsumes the other.


Backwards Compatibility

This ERC introduces new interfaces with no conflicts with existing ERCs. Agent identity, agentic commerce, proof verification, on-chain anchoring, OCP, WYRIWE, and settlement layers integrate as optional dependencies; none require modification.

This ERC v0.3 is not backwards-compatible with v0.1 or v0.2. The IAgentCaller / IAgentHandler split of v0.1 and the agentWorkflowHash commitment model are replaced by the unified IAgentWorkflow interface. Implementations of prior versions must be redeployed.

The addition of AgentReply.operationId changes the tuple ABI and the canonical replyHash preimage. Implementations and clients built against an earlier AgentReply layout must update their ABI, hash computation, and stored-reply handling. The AgentReplyAnchored event signature is unchanged.

The minimum conforming path β€” run() β†’ onAgentReply (terminal stage) β†’ onAgentProve βœ“ β€” requires no additional infrastructure and is equivalent in behavior to the simplest existing agent invocation patterns.


Reference Implementation

A full reference implementation will be provided in a separate repository and linked here once available.

Call Sequences

In all diagrams, T0, T1, … denote taskHash values from NewAgentTask events; R0, R1, … denote canonical replyHash values returned by the contract in onAgentReply. Each illustrated new reply uses a fresh non-zero operationId unless explicitly labelled as a retry.


Scenario 0 β€” Single-Stage (Baseline)

The simplest conforming workflow: one task, one reply, one proof.

run(inputHash, input, expiresAt)
     β”‚  emit NewAgentTask(stage=Execute, taskHash=T0)
     β–Ό
onAgentReply({prevTaskHashes:[T0], outputHash:H1, replier:A, ...})
     β”‚  contract stores; replyHash=R0
     β”‚  terminal stage is sync β†’ waits
     β–Ό
onAgentProve([R0], proof)  βœ“
     β”‚  create & store final AgentTask Tf (prevReplyHashes=[R0])
     β”‚  emit WorkflowCompleted(Success, finalTaskHash=Tf)

Scenario 1 β€” Input Provenance

The input a caller commits to on-chain (inputHash) and the input a model actually processes may differ due to sanitization transforms, template injection, or RAG context assembly. The taskHash and replyHash chains make this gap provable through three composable patterns:

  1. WYRIWE attestation: an agent submits a reply whose outputHash equals the sanitized input hash. The FSM transitions to the inference stage, emitting a NewAgentTask with inputHash = sanitizedHash. All subsequent inference replies reference this task in prevTaskHashes, proving on-chain that inference happened after the WYRIWE attestation and used the attested input.
  2. OCP temporal anchoring: the input commitment is submitted as a reply in a dedicated stage; the FSM only advances after the OCP record() call is confirmed, proving on-chain that the input was anchored before execution began.
  3. Pre-action gate: the input provenance stage is declared sync mode; the verifier must approve before the FSM emits the inference task.
run(inputHash=H0, input, expiresAt)
     β”‚  emit NewAgentTask(stage=Sanitize, taskHash=T0)
     β”‚  // getAgentTask(T0) β†’ {stage=Sanitize, inputHash=H0, ...}
     β–Ό
onAgentReply({prevTaskHashes:[T0], outputHash:sanitizedHash, replier:GW, ...})
     β”‚  contract stores; replyHash=R0
     β”‚  stage=Sanitize is sync β†’ waits for proof
     β–Ό
onAgentProve([R0], proof)  βœ“   ← sync gate passes; R0 proven
     β”‚  FSM transitions to Infer
     β”‚  emit NewAgentTask(stage=Infer, taskHash=T1)
     β”‚  // getAgentTask(T1) β†’ {stage=Infer, inputHash=sanitizedHash,
     β”‚  //                      prevReplyHashes=[R0], ...}
     β–Ό
onAgentReply({prevTaskHashes:[T1], outputHash:inferHash, replier:A, ...})
     β”‚  contract stores; replyHash=R1
     β”‚  terminal stage is sync β†’ waits
     β–Ό
onAgentProve([R1], proof)  βœ“
     β”‚  create & store final AgentTask Tf (prevReplyHashes=[R1])
     β”‚  emit WorkflowCompleted(Success, finalTaskHash=Tf)

Scenario 2 β€” Node Selection (Competitive, Dynamic FSM)

Agents compete; gate selects one winner and silently discards the rest.

run(inputHash, input, expiresAt)
     β”‚  emit NewAgentTask(stage=Bid, taskHash=T0)
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β–Ό                  β–Ό                  β–Ό
onAgentReply(A,[T0])  onAgentReply(B,[T0])  onAgentReply(C,[T0])
replyHash=RA          replyHash=RB          replyHash=RC
bid=100               bid=80 (lowest)       bid=120

[gate selects B: lowest bid; A and C are silently discarded]
     β”‚  FSM transitions to Execute
     β”‚  emit NewAgentTask(stage=Execute, taskHash=T1)
     β”‚  // getAgentTask(T1) β†’ {inputHash=B.outputHash, prevReplyHashes=[RB], ...}
     β–Ό
onAgentReply(B, {prevTaskHashes:[T1], ...})
     β”‚  contract stores; replyHash=R1
     β”‚  terminal stage is sync β†’ waits
     β–Ό
onAgentProve([R1], proof)  βœ“
     β”‚  create & store final AgentTask Tf (prevReplyHashes=[R1])
     β”‚  emit WorkflowCompleted(Success, finalTaskHash=Tf)

Scenario 3 β€” Sequential Pipeline (A β†’ B β†’ C)

Each stage passes its output as the next stage's input via inputHash.

run(inputHash=H0, ...)
     β”‚  emit NewAgentTask(stage=StepA, taskHash=T0)
     β”‚  // getAgentTask(T0) β†’ {inputHash=H0, ...}
     β–Ό
onAgentReply(A, {prevTaskHashes:[T0], outputHash:H1, ...})
     β”‚  contract stores; replyHash=R0
     β”‚  gate passes (async) β†’ FSM to StepB
     β”‚  emit NewAgentTask(stage=StepB, taskHash=T1)
     β”‚  // getAgentTask(T1) β†’ {inputHash=H1, prevReplyHashes=[R0], ...}
     β–Ό
onAgentReply(B, {prevTaskHashes:[T1], outputHash:H2, ...})
     β”‚  contract stores; replyHash=R1
     β”‚  gate passes (async) β†’ FSM to StepC
     β”‚  emit NewAgentTask(stage=StepC, taskHash=T2)
     β”‚  // getAgentTask(T2) β†’ {inputHash=H2, prevReplyHashes=[R1], ...}
     β–Ό
onAgentReply(C, {prevTaskHashes:[T2], outputHash:H3, ...})
     β”‚  contract stores; replyHash=R2
     β”‚  terminal stage is sync β†’ waits
     β–Ό
onAgentProve([R2], proof)  βœ“
     β”‚  create & store final AgentTask Tf (prevReplyHashes=[R2])
     β”‚  emit WorkflowCompleted(Success, finalTaskHash=Tf)

Scenario 4 β€” BFT Commit-Reveal (Dynamic FSM)

Agents commit to a hidden result, then reveal simultaneously. Prevents last-mover advantage.

run(inputHash, ...)
     β”‚  emit NewAgentTask(stage=Commit, taskHash=T0)
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β–Ό                      β–Ό
onAgentReply(A,[T0])    onAgentReply(B,[T0])     ← commit: outputHash=H(resultβ€–nonce)
replyHash=RcA           replyHash=RcB

[gate: quorum of commits β†’ FSM to Reveal]
     β”‚  emit NewAgentTask(stage=Reveal, taskHash=T1)
     β”‚  // getAgentTask(T1) β†’ {prevReplyHashes=[RcA,RcB], ...}
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β–Ό                      β–Ό
onAgentReply(A,[T1])    onAgentReply(B,[T1])     ← reveal: output=resultβ€–nonce
replyHash=RrA           replyHash=RrB

[gate: majority result determined β†’ FSM to Finalize (terminal, sync)]
     β”‚  emit NewAgentTask(stage=Finalize, taskHash=T2)
     β”‚  // getAgentTask(T2) β†’ {prevReplyHashes=[RrA,RrB], ...}
     β–Ό
onAgentReply(AGG, {prevTaskHashes:[T2], outputHash:majorityHash, ...})
     β”‚  contract stores; replyHash=Rf
     β”‚  sync mode β†’ waits
     β–Ό
onAgentProve([Rf], proof)  βœ“
     β”‚  create & store final AgentTask Tf (prevReplyHashes=[Rf])
     β”‚  emit WorkflowCompleted(Success, finalTaskHash=Tf)

Scenario 5 β€” Parallel Multi-Agent Audit (Static FSM)

A contract is submitted for audit across three independent dimensions: Gas, Security, and Logic. The workflow hardcodes three stages β€” Audit β†’ Qualify β†’ Settle β€” with no way to skip any of them. The Audit stage dispatches all three tasks in parallel; the gate only advances when every dimension has replied.

run(inputHash=H_code, input, expiresAt)
     β”‚  emit NewAgentTask(stage=Audit, taskHash=T_gas)
     β”‚  emit NewAgentTask(stage=Audit, taskHash=T_sec)
     β”‚  emit NewAgentTask(stage=Audit, taskHash=T_logic)
     β”‚  // Parallel dispatch: same stage, same block, three agents
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β–Ό                  β–Ό                  β–Ό
onAgentReply(G, [T_gas])   onAgentReply(S, [T_sec])   onAgentReply(L, [T_logic])
replyHash=R_gas            replyHash=R_sec             replyHash=R_logic

[Gate: all three replies received β†’ FSM transitions to Qualify (hardcoded)]
     β”‚  emit NewAgentTask(stage=Qualify, taskHash=T_qual)
     β”‚  // getAgentTask(T_qual) β†’ {prevReplyHashes=[R_gas,R_sec,R_logic], ...}
     β–Ό
onAgentReply(Q, {prevTaskHashes:[T_qual], outputHash:qualHash, ...})
     β”‚  Qualification agent checks auditor credentials and proof validity.
     β”‚  contract stores; replyHash=R_qual
     β”‚  stage=Qualify is sync β†’ waits for proof
     β–Ό
onAgentProve([R_qual], proof)  βœ“   ← sync gate passes
     β”‚  FSM transitions to Settle (hardcoded, terminal)
     β”‚  emit NewAgentTask(stage=Settle, taskHash=T_settle)
     β–Ό
onAgentReply(Set, {prevTaskHashes:[T_settle], outputHash:settleHash, ...})
     β”‚  contract stores; replyHash=R_settle
     β–Ό
onAgentProve([R_settle], proof)  βœ“
     β”‚  create & store final AgentTask Tf (prevReplyHashes=[R_settle])
     β”‚  emit WorkflowCompleted(Success, finalTaskHash=Tf)

The three-stage graph (Audit β†’ Qualify β†’ Settle) is hardcoded at deploy time. Each audit dimension is pre-declared; the operator cannot skip the security audit by routing the FSM around it β€” an omitted dimension leaves the gate unsatisfied and the run pending. The parallelism is structural β€” the three audits are independent β€” rather than coordinated off-chain. A skeptic can enumerate every owed step from the contract source alone: 3 audit tasks + 1 qualification + 1 settlement = 5 guaranteed dispatch points.


Security Considerations

inputHash integrity. The caller computes inputHash = keccak256(input) off-chain. The contract SHOULD verify keccak256(input) == inputHash when input is provided. Sensitive inputs SHOULD be withheld from the event and delivered to agents through a confidential channel.

Gate logic is implementation-defined. This ERC standardises the interface, not the gate logic. A malicious or negligent workflow contract may define gates that always pass or emit WorkflowCompleted(Success) without verification. Callers SHOULD audit the workflow contract before integrating. A non-zero finalTaskHash in result() proves gate-level enforcement only if the contract is correctly implemented.

Executor identity is msg.sender. replyHash commits to reply.replier which is validated against msg.sender. No external registry is required by the base protocol. Permissioned implementations MAY check msg.sender against an allowlist or integrate ERC-8004 inside gate logic.

taskAnchorTime must not be agent-writable. The timestamp constraint relies on taskAnchorTime[taskHash] being written by the contract at NewAgentTask emission. If agents can influence this value, the ordering guarantee breaks. The contract MUST ensure only internal logic writes to taskAnchorTime.

Replay attacks and logical retries. workflowRunId is contract-generated and unique per run() call. taskHash commits to (stage, taskSeq, inputHash, timestamp, expiresAt, prevReplyHashes, workflowRunId); replyHash commits to (operationId, outputHash, timestamp, replier, prevTaskHashes, workflowRunId). A transaction or account nonce prevents replay of the same signed envelope, while operationId identifies separately signed submissions that represent the same logical reply. A matching retry MUST resolve to the first canonical replyHash without emitting another anchor event or repeating gate and downstream effects. Reusing the scoped operationId with a different replyDigest MUST revert and MUST NOT overwrite the canonical binding.

Operation IDs are not semantic deduplication. An agent can use a new operationId to submit an intentional new operation over identical content. Workflow-specific rules such as preventing the same external source record from being accepted twice MUST use a domain identifier in gate or application logic. Implementations MUST NOT treat operationId as proof that two payloads describe the same real-world event.

Permissionless spam resistance. In permissionless workflows, any agent may submit replies. In a dynamic FSM, gate logic SHOULD use silent failure mode rather than reverting, so malformed submissions do not block the run β€” the stage stays open for further submissions. In a static FSM, silent failure is not load-bearing for omission, since the transition target is predetermined; implementations MAY use explicit-failure or revert modes. Sybil resistance and quorum thresholds are the workflow contract's responsibility.

expiresAt is not automatically enforced. The contract does not self-terminate when expiresAt passes. Workflow contracts SHOULD expose a timeout function (e.g., replyTimeout(bytes32 taskHash)) callable by any address once expiresAt has passed, which retries or aborts the run. Implementations SHOULD also validate expiresAt > block.timestamp at run() time and reject expiresAt = 0 unless the intent is "no expiry."

Reentrancy. onAgentReply evaluates gate logic which may emit new tasks or call downstream hooks. onAgentProve calls an external verifier contract mid-execution. Both MUST follow the checks-effects-interactions pattern: complete all state writes (binding the operation and anchoring the reply / marking proven) before emitting events or calling external contracts. Implementations SHOULD apply a standard reentrancy guard (e.g., OpenZeppelin ReentrancyGuard) to both functions.

Race condition β€” concurrent replies to the same stage. In async mode, multiple agents may call onAgentReply in the same block. Concurrent submissions with the same operationKey MUST atomically resolve to one canonical reply. If the gate passes for the first new operation and transitions the FSM, the implementation MUST ensure subsequent new operations at the now-completed stage are rejected. The FSM stage state MUST be updated before emitting NewAgentTask for the next stage, so that any racing onAgentReply call either resolves as a matching retry or finds the stage already advanced and reverts or is silently dropped.

Empty proof array. The contract MUST revert if onAgentProve is called with replyHashes.length == 0. An empty array would skip the retrieval loop and potentially trigger gate evaluation without verifying anything.

Griefing on proof submission. onAgentProve MAY be called by any third party. An attacker may front-run the legitimate agent's proof with a malformed or zero-length proof, causing the verifier to reject it. Implementations MUST allow proof resubmission for a replyHash after a failed onAgentProve (i.e., a failed proof MUST NOT permanently mark the reply as "proven=false" in a way that blocks future attempts). Only a successful onAgentProve MUST update state.

Meta-transaction / relayer compatibility. The reply.replier == msg.sender check binds the reply to the transaction sender. If the contract is deployed behind a meta-transaction relayer (e.g., ERC-2771), msg.sender is the relayer contract, not the agent EOA. Implementations that wish to support meta-transactions MUST explicitly handle _msgSender() substitution and use the same effective sender for both replier validation and operation scoping. ERC-2771 integration is OPTIONAL; the base protocol uses raw msg.sender.

workflowRunId collision. The recommended runNonce counter MUST be a private, monotonically increasing storage variable that only run() can increment. If runNonce is publicly settable, an attacker could reset it to produce a workflowRunId that collides with a prior run.

Unknown workflowRunId in result(). result() MUST return (Pending, bytes32(0), 0) for an unrecognised workflowRunId β€” it MUST NOT revert. Callers SHOULD treat a Pending result with completedAt == 0 as either a live run or an unknown run; they SHOULD independently verify the run was started by checking for the originating NewAgentTask event.


Copyright and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth