EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8380: Unclonable Agent Execution Credentials

Single use agent capability tokens that burn a zero-knowledge nullifier so a cloned agent cannot replay them

Authors
Created2026-08-17
Discussion Linkhttps://ethereum-magicians.org/t/erc-8380-unclonable-agent-execution-credentials/29274
Requires

Markdown

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

EIP-GPT summary

Contents
AbstractMotivationSpecificationConstantsCapabilitySalt derivationDerivationPublic inputsInterfaceIssuanceExecutionObservabilityRecoveryRationaleThe nullifier binds to a salt rather than to the payloadThe nullifier preimage is the salt aloneThe nullifier, executor, and expiry must be checked by the circuit, not merely carriedUnclonability sits at the execution layerConsumption is fused to executionkeccak256 rather than PoseidonThe capability index is issuer disciplineWhat is deliberately left outBackwards CompatibilityTest CasesReference ImplementationSecurity ConsiderationsUnclonability and authorization soundness are orthogonalAt most once, with no orderingSalt secrecy is a liveness propertyWhat an attacker can do to a credentialOne home chain is requiredProof generation latencyAggregate consumption is not boundedVerifier trustCopyright

Abstract

This proposal defines a capability token that encodes exactly one authorized execution event for an agent identity, together with a Guard contract that spends it at most once.

A capability carries one secret, a per issuance salt held by the agent and never placed on chain. The nullifier is derived as keccak256(NULLIFIER_TAG, salt), and a capability commitment binds the same salt to the agent identity, the home chain, the issuing domain, a monotonic index, and a canonical hash of the authorized action. To spend a capability the agent submits a zero-knowledge proof that it knows a salt opening the commitment. Proof generation forces the nullifier into the open, and the Guard records it permanently.

A cloned agent holds the same salt and therefore produces the same nullifier. The Guard sees the duplicate and rejects the transaction. Issuance is mandatory and the burn is fused to the authorized call, so a capability can neither be replayed nor destroyed without performing the work it authorized.

This proposal defines the capability envelope, the nullifier derivation, and the verification interface. It does not define the proving system, the capability transport, or the policy that decided the capability should be issued.

Motivation

Agent authorization on Ethereum today relies on function scoped boundaries or static permissions. Both assume the credential holder is the party the credential was issued to.

That assumption breaks in multi agent swarms. The problem was found while building an autonomous trade finance network. When an orchestrator delegates a high stakes task to a specialized off chain agent it issues an authorization credential, and a compromised agent can be cloned. A bad actor copies the memory state and replays that authorization to drain funds or duplicate actions. Nothing in a scoped permission distinguishes the original agent from its clone, because both present the same valid credential for the same permitted call.

Existing primitives do not close this. Nonces belong to the account, not to the delegated agent, so an orchestrator that hands the same account to two workers gets no separation. Expiry windows shrink the replay window without removing it. Session keys bound to a scope authorize a class of calls rather than one call. What is missing is a credential that breaks after one use, where the break is detectable on chain rather than assumed off chain.

Unconditional unclonable encryption achieves an analogous property with quantum states. An EVM environment is classical and its data is infinitely replicable, so no property of a credential at rest can be made unclonable there. This proposal relocates the unclonable property to the proof of execution, where a clone attempt becomes a detectable collision.

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.

Constants

Two domain separation tags are defined. An implementation MUST use both strings byte for byte as written, and the same two strings MUST be used in the circuit and in the Guard. A Guard and a circuit that disagree on either tag accept nothing, and the failure is silent.

bytes32 constant NULLIFIER_TAG  = keccak256("ERC-8380/nullifier/v1");
bytes32 constant CAPABILITY_TAG = keccak256("ERC-8380/capability/v1");

Capability

struct Capability {
    bytes32 nullifier;            // public output, keccak256(NULLIFIER_TAG, salt)
    bytes32 capabilityCommitment; // public input, binds salt to every field below
    uint256 agentId;              // agent identity
    uint256 homeChainId;          // the one chain this capability spends on
    uint256 homeDomainId;         // issuing orchestrator domain
    uint256 capabilityIndex;      // monotonic per agentId and homeDomainId
    bytes32 actionCommitment;     // keccak256(abi.encode(target, callData))
    address executor;             // intended submitter
    uint256 expiry;               // unix seconds
}

agentId SHOULD reference an identity registered under ERC-8004.

The salt is deliberately not a member of this struct. It is a private witness of the circuit only, and it MUST NOT appear in calldata, in an event, or in any other on chain artifact. A salt placed in calldata is visible in the mempool ahead of inclusion, which hands every observer the ability to compute the nullifier and destroy the capability before the honest agent's transaction is mined.

Salt derivation

The salt is the unit of exactly once. An issuer MUST derive it per issuance so that no two capabilities ever share one:

salt = HKDF(issuerSecret, agentId || homeDomainId || capabilityIndex)

capabilityIndex MUST be monotonically increasing per pair of agentId and homeDomainId. An issuer MUST NOT reissue a salt that has already been issued, because reuse of a spent salt is indistinguishable from a clone by construction and the Guard will reject it as one.

An issuer MAY use any derivation that yields a unique, unpredictable salt with at least 128 bits of entropy. The construction above is RECOMMENDED because it is stateless given the index and lets an issuer that lost its records rebuild any capability it ever issued.

Derivation

nullifier            = keccak256(abi.encodePacked(NULLIFIER_TAG, salt))

capabilityCommitment = keccak256(abi.encodePacked(
                           CAPABILITY_TAG,
                           salt,
                           bytes32(agentId),
                           bytes32(homeChainId),
                           bytes32(homeDomainId),
                           bytes32(capabilityIndex),
                           actionCommitment,
                           bytes32(uint256(uint160(executor))),
                           bytes32(expiry)))

The chain identifier MUST NOT appear in the nullifier preimage. Baking it in would fork the nullifier per chain, so the same credential could be spent once on every chain and the global single spend property would break. Chain binding is enforced instead by the acceptance check homeChainId == block.chainid, which fails a clone on the wrong chain rather than letting it past a nullifier that registry has never seen.

The transaction payload MUST NOT appear in the nullifier preimage. The payload appears in actionCommitment, which is covered by the capability commitment, so an issuer authorizes two identical actions by issuing two capabilities with distinct salts.

executor and expiry MUST appear in the capability commitment preimage. Neither is otherwise constrained by the circuit, so a party holding a valid salt could pair an unrelated executor or an extended expiry with an otherwise legitimate proof and have the Guard accept it.

Public inputs

A conforming verifier MUST receive the following nine public inputs in this exact order. The circuit MUST recompute capabilityCommitment from the private salt and these inputs, and MUST prove that its internally computed nullifier equals the nullifier public input, so a Guard comparing cap.nullifier against the proof is comparing a value the circuit actually derived rather than one supplied in calldata.

IndexValue
0capabilityCommitment
1agentId
2homeChainId
3homeDomainId
4capabilityIndex
5actionCommitment
6executor
7expiry
8nullifier

Interface

A conforming Guard MUST implement the following interface.

interface IUnclonableCredential {
    event CapabilityIssued(
        bytes32 indexed capabilityCommitment,
        uint256 indexed agentId,
        uint256 indexed homeDomainId,
        uint256 capabilityIndex
    );

    event NullifierBurned(
        bytes32 indexed nullifier,
        uint256 indexed agentId,
        uint256 capabilityIndex,
        bytes32 actionCommitment
    );

    error CredentialAlreadySpent(bytes32 nullifier);
    error CommitmentNotIssued(bytes32 capabilityCommitment);

    struct Capability {
        bytes32 nullifier;
        bytes32 capabilityCommitment;
        uint256 agentId;
        uint256 homeChainId;
        uint256 homeDomainId;
        uint256 capabilityIndex;
        bytes32 actionCommitment;
        address executor;
        uint256 expiry;
    }

    function issue(
        bytes32 capabilityCommitment,
        uint256 agentId,
        uint256 homeDomainId,
        uint256 capabilityIndex
    ) external;

    function execute(
        Capability calldata cap,
        bytes calldata proof,
        address target,
        bytes calldata callData
    ) external returns (bytes32 nullifier);

    function isConsumed(bytes32 nullifier) external view returns (bool);

    function highestIssuedIndex(uint256 agentId, uint256 homeDomainId) external view returns (uint256);
}

Issuance

issue MUST be callable only by the issuing orchestrator of the domain. It MUST record capabilityCommitment as issued, MUST raise highestIssuedIndex[agentId][homeDomainId] when capabilityIndex exceeds it, and MUST emit CapabilityIssued. The orchestrator authorized to call issue for a given homeDomainId is recorded in the domain registry at registration time.

Issuance is mandatory rather than optional. Because capabilityCommitment binds actionCommitment, a Guard that accepts any internally consistent proof accepts a capability for an action the orchestrator never authorized, which lets a clone burn the nullifier on an action of its own choosing. Whether the Guard checks the commitment against an issuance record is a security property and not an implementation detail.

Execution

execute MUST perform the following checks in this order and MUST revert if any of them fails.

  1. cap.homeChainId == block.chainid.
  2. cap.homeDomainId is registered and not revoked.
  3. block.timestamp <= cap.expiry.
  4. msg.sender == cap.executor.
  5. isConsumed(cap.nullifier) is false, otherwise revert with CredentialAlreadySpent(cap.nullifier).
  6. cap.capabilityCommitment was issued, otherwise revert with CommitmentNotIssued(cap.capabilityCommitment).
  7. keccak256(abi.encode(target, callData)) == cap.actionCommitment.
  8. The verifier accepts proof against the nine public inputs above.

The ordering is normative. Cheap and public checks precede proof verification so that a caller cannot force an expensive verification with a capability that already fails on a public field.

On success execute MUST mark cap.nullifier consumed, MUST emit NullifierBurned, and MUST then call target with callData in the same transaction. If the action reverts, the whole call including the burn MUST revert.

A Guard MUST NOT expose any path that marks a nullifier consumed without performing the committed action, and MUST NOT expose any path that clears a consumed nullifier. Separating the two is what allows a party who merely satisfies the executor check to destroy a capability without doing the work it authorized.

Observability

A rejecting path reverts and therefore cannot emit a log. Observability is therefore two sided, and both sides are REQUIRED.

  • The first and only successful spend emits NullifierBurned.
  • Every subsequent attempt reverts with the named error CredentialAlreadySpent.

A collision is the only on chain evidence that a clone exists, and a bare revert makes a live compromise indistinguishable from an orchestrator reissuing a spent index. highestIssuedIndex exists so that an operator can tell the two apart. A collision at an index the orchestrator never issued indicates a clone. A collision at an index it did issue indicates a reissue bug on the orchestrator side.

highestIssuedIndex is scoped per (agentId, homeDomainId), not per agentId alone, so a collision in one domain cannot be misclassified against another domain's ceiling for the same agent. An agent operating in two domains has two independent ceilings, and an operator MUST classify a collision against the ceiling for the domain the collision occurred in.

Recovery

A burned nullifier is permanently burned. An implementation MUST NOT revive one.

Recovery from a destroyed or prematurely spent capability is reissuance at the next index under a fresh salt, so the state transition for an index is issued, then burned, then reissue at index + 1. The old nullifier stays dead. Deciding when to reissue is orchestrator policy and is outside this proposal.

Rationale

The nullifier binds to a salt rather than to the payload

Binding the nullifier to the transaction payload would look simpler and would need no secret at all. It also makes any recurring agent action impossible, because the second legitimate run of an identical task produces the same nullifier as the first and is rejected as a replay. A settlement agent that sweeps the same amount to the same address every week would brick itself on week two.

Binding to a per issuance salt separates intent from content. Two intentional runs of the same action receive two capabilities with distinct salts and therefore distinct nullifiers. A clone of one capability carries the same salt and collides. The unit of exactly once is the issuance, not the agent and not the action.

The nullifier preimage is the salt alone

An earlier draft placed agentId and homeDomainId in the nullifier preimage alongside the salt. Both are already inputs to the salt derivation and both are already bound by the capability commitment, so including them a second time changes no security property and only creates a second place for a circuit and a Guard to disagree. The preimage is therefore the tag and the salt.

An issuer that ignores the RECOMMENDED derivation and picks a random salt still gets a unique nullifier, because uniqueness comes from the salt itself rather than from the fields around it.

The nullifier, executor, and expiry must be checked by the circuit, not merely carried

An earlier draft treated nullifier, executor, and expiry as calldata the Guard trusts once the proof passes. Review found that none of the three sat inside a constraint the circuit actually enforced. nullifier was never a public input, so nothing tied cap.nullifier to the salt used in the proof, and executor and expiry sat outside capabilityCommitment, so a salt holder could pair a valid proof with an arbitrary executor or an unbounded expiry.

The fix moves all three inside the boundary the circuit enforces. executor and expiry join the capabilityCommitment preimage, so the existing commitment check now covers them for free. nullifier becomes a ninth public input that the circuit must prove equals its own keccak256(NULLIFIER_TAG, salt) output, closing the gap between what the Guard checks and what the proof actually attests to.

Unclonability sits at the execution layer

Unconditional unclonable encryption relies on the impossibility of copying an unknown quantum state. The EVM has no analogue. Any credential at rest is a byte string and a byte string can be copied, so this proposal cannot make a credential unclonable in storage and does not claim to.

What it can do is make the act of spending observable and unrepeatable. Proof generation forces the nullifier into the open, and the registry makes the second appearance of that nullifier a detectable event. The credential is still cloneable. The execution is not.

{
  "type": "article",
  "id": "AnanthSahai2026",
  "author": [
    {
      "family": "Ananth",
      "given": "Prabhanjan"
    },
    {
      "family": "Sahai",
      "given": "Amit"
    }
  ],
  "title": "Unconditional Unclonable Encryption",
  "DOI": "10.48550/arXiv.2607.21551",
  "URL": "https://arxiv.org/abs/2607.21551",
  "issued": {
    "date-parts": [
      [2026, 7, 23]
    ]
  }
}

Consumption is fused to execution

A separable spend primitive was specified first and then withdrawn. Under it, a party satisfying the executor check could burn a nullifier on any action and leave the authorized work permanently undone, because the burn asked nothing of the action. Exactly once held perfectly and the deployment still lost.

Mandatory issuance narrows that attack rather than closing it, since a clone can still spend the one action that was authorized. Fusing the burn to the call closes what remains: a burned nullifier now implies the authorized action ran, so the worst outcome available to a clone is that the intended work happens earlier than intended rather than never. The residual is a clone holding both the salt and the executor key, which is a key management boundary that no interface check can close.

An external wrapper that checks the action commitment before calling a separable spend does not achieve this, because nothing forces the wrapper to be the caller.

keccak256 rather than Poseidon

Poseidon costs fewer constraints inside the circuit and would make proving cheaper. It also requires the Guard to carry a Poseidon implementation, and requires this document to pin the exact field, round counts, and constants, since a Guard and a circuit that disagree on any of those silently accept nothing.

keccak256 is available as a precompiled cost on chain and is already the canonical hash of the platform, so parity between Solidity and the circuit is a direct equality check. Proving cost is paid once per capability by the agent, whereas a parity bug is paid by every integrator.

The capability index is issuer discipline

capabilityIndex is not enforced monotonic by execute, because the Guard cannot distinguish an orchestrator legitimately issuing out of order from a replay attempt, and rejecting the former would break concurrent issuance. highestIssuedIndex records the ceiling so that a collision can be classified after the fact, which is the property an operator actually needs.

What is deliberately left out

Three neighbouring concerns are out of scope and are named here so that a deployment does not mistake this proposal for them.

  • Whether an action should have been authorized at all is a soundness question about the issuing policy.
  • How much one identity may consume in total is an identity scoped cumulative bound. Exactly once per issuance says nothing about aggregate value, and n clones of one agent should share one budget rather than multiplying it. That bound belongs in a separate layer that meters spend per agentId.
  • When to freeze an agent and reissue after a collision is orchestrator policy.

Backwards Compatibility

No backward compatibility issues are introduced. This proposal adds a new contract and a new interface and changes no existing behaviour.

The Guard composes as a pre execution hook or a validation module for accounts built on ERC-7579 or ERC-6900. An integrator on either MUST route the authorized call through execute rather than validating and then dispatching separately, since a validation module that returns a verdict and leaves dispatch to the account reintroduces the separable spend this proposal rejects.

Agent identity is supplied by ERC-8004 and is referenced by identifier only, so the Guard holds no identity state of its own.

Test Cases

The reference implementation covers the following cases.

CaseExpectation
Happy pathAn issued capability spends, the nullifier reads as consumed, and the action ran
Double spendA second spend reverts with CredentialAlreadySpent and the action does not run twice
Clone replayA submitter that is not the executor reverts
Wrong chainhomeChainId other than block.chainid reverts
Expiredexpiry in the past reverts
Executor mismatchA capability naming a different executor reverts
Executor or expiry tamperA proof valid for one executor or expiry fails verification if either is swapped in calldata, because both are bound inside capabilityCommitment
Nullifier substitutionA proof valid for one salt fails verification if cap.nullifier is swapped for an unrelated value, because the circuit proves the two equal
Front run with a lifted proofA third party resubmitting an observed proof reverts
Unregistered domainAn unregistered homeDomainId reverts
Commitment parityThe commitment computed in Solidity equals the one computed in the circuit
RaceExactly one of two concurrent spends of the same nullifier lands, and the Guard does not determine which
Grief on an unissued actionA spend against a commitment that was never issued reverts with CommitmentNotIssued, and the honest action still runs afterwards
Burn implies executionA consumed nullifier always corresponds to the committed action having run
Premature spendAn early spend performs the authorized action rather than destroying it
Collision classificationA collision at an issued index and one at an unissued index are distinguishable through highestIssuedIndex, scoped per (agentId, homeDomainId) so a collision unissued in one domain is not misclassified against a different domain's ceiling for the same agent
RecoveryA burned index stays burned and the capability is reauthorized at the next index

Vectors for the commitment parity case, using a home chain identifier of 11155111 and a home domain identifier of 1:

salt                 = 0x0000000000000000000000000000000000000000000000000000000000000007
agentId              = 5
homeChainId          = 11155111
homeDomainId         = 1
capabilityIndex      = 3
actionCommitment     = 0x0000000000000000000000000000000000000000000000000000000000000063
executor             = 0x0000000000000000000000000000000000000001
expiry               = 1900000000

capabilityCommitment = keccak256(abi.encodePacked(
                           CAPABILITY_TAG, salt,
                           bytes32(agentId), bytes32(homeChainId),
                           bytes32(homeDomainId), bytes32(capabilityIndex),
                           actionCommitment,
                           bytes32(uint256(uint160(executor))), bytes32(expiry)))

Reference Implementation

A reference implementation is provided in the assets directory for this proposal.

The implementation is a reference and has not been audited.

Security Considerations

Unclonability and authorization soundness are orthogonal

This proposal guarantees that a specific authorized payload executes at most once. It makes no claim about whether that payload should have been authorized at all.

A replayed credential from a compromised agent and a correctly issued credential encoding a genuinely bad decision are indistinguishable to the Guard, because both present a valid and previously unseen nullifier. A deployment needs a boundary that constrains what an agent may ever reach, a soundness argument for how a particular authorization decision was reached, and a consumption guarantee that the resulting credential is spent once. This proposal supplies the third. None of the three substitutes for another, and adopting this proposal alone does not make an agent safe.

Authorization soundness itself splits into two properties this proposal does not distinguish elsewhere. The first is whether the action that executed is the one actually authorized, which a proof over the committed action can show. The second is whether the authorization itself was a sound decision, which no proof over a committed policy can show, because that judgment is about the world outside the circuit rather than a computation over its inputs. This proposal reaches only the first.

At most once, with no ordering

A clone that holds the agent memory holds the salt, and can therefore construct the same valid proof as the original. Under the executor binding path it also holds the same executor key, so the Guard cannot rank the two by construction. Both can submit, and the guarantee is only that one of them lands.

The guarantee is at most once with no ordering. It is not exactly once and it is not correct agent wins. Deployments that need the honest agent to win MUST keep the salt outside cloneable memory, for example inside a hardware backed enclave or an external signer. A secondary time lock moves the race later without changing who wins it.

Salt secrecy is a liveness property

If a nullifier were computable from public data, anyone could burn a capability before the honest agent spends it. Salt secrecy therefore protects availability, not confidentiality. This is why the salt is excluded from the Capability struct: a salt in calldata is public from the moment the transaction enters the mempool, which is strictly worse than a leaked salt because it leaks on every single spend.

A leaked salt does not let an attacker perform an unauthorized action, because the action is fixed by actionCommitment and by the issuance record, but it does let an attacker consume the capability early.

What an attacker can do to a credential

At most once bounds what an attacker can do with a credential. It says nothing on its own about what an attacker can do to one. The cheapest attack for a clone is not to win the race for the valuable action, it is to spend the credential at all.

Mandatory issuance and fused execution are what bound that. A clone cannot burn on an action that was never issued, and a burn it does land performs the authorized action, so the damage is bounded to the task happening earlier than intended. A deployment that separates the two, or that adds any path marking a nullifier consumed without executing, reopens a denial of service in which a series of spends leaves every task permanently undone.

Deployments SHOULD keep expiry short and SHOULD reconcile NullifierBurned against observed settlement, because a burn always implies the action ran but says nothing about whether it ran at the intended time.

One home chain is required

A capability with no designated home domain is out of scope. A nullifier registry is a spend problem, where the answer has to be unique rather than merely available, so redundancy without ordering is precisely what an attacker wants. Mirroring a registry across domains without consensus on ordering reintroduces the double spend the registry exists to prevent.

An implementation MUST reject a capability whose homeChainId is not the current chain, and MUST NOT treat a nullifier burned on one chain as evidence of spentness on another unless a mirroring scheme with its own ordering guarantee is in place. Because the chain identifier is excluded from the nullifier preimage, such a scheme can compare nullifiers across domains directly without redefining the derivation.

Proof generation latency

Proof generation sits in the execution path and takes measurable time. An attacker who has already cloned the agent memory can begin proving at the same moment as the honest agent, so latency is a window in which the race is decided by transaction ordering rather than by legitimacy. This proposal does not define a mitigation, and a time lock above it does not resolve the race.

Aggregate consumption is not bounded

Exactly once per issuance is a count on one credential. It does not bound what one identity consumes in total. An attacker with a compromised agent that keeps receiving fresh capabilities spends each of them once, legitimately, and drains value without ever triggering a collision. Deployments handling value SHOULD meter cumulative spend per agentId in a separate layer.

Verifier trust

The Guard delegates soundness to the verifier it was constructed with. A verifier that accepts an invalid proof defeats every guarantee here. The verifier address MUST be immutable, or governed by a process at least as strong as the value the capabilities authorize, and MUST correspond to a circuit that enforces the public input ordering defined above. A mock verifier MUST NOT be deployed to a production network.

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