Counterfactual

Emit-only ERC-8004 adapter registration for indexer-keyed identities that can settle onchain later.

What counterfactual registration is#

Counterfactual registration lets the current controller of an external token publish ERC-8004-style identity events without minting an ERC-8004 token. It is an indexable, low-gas alternative when an agent needs a stable identity before it needs a full onchain registration. Anyone who controls the same external token can later promote that identity path by calling the normal register function with the same token coordinates.

The emitted events are the record. Indexers key that record by a deterministic registrationHash, then reconstruct the current off-chain identity from the latest counterfactual events for the underlying token.

Every counterfactual function still checks current bound-token control. ERC-721 uses ownerOf(tokenId) == msg.sender; ERC-1155 and ERC-6909 use balanceOf(msg.sender, tokenId) > 0. Shared-control token standards therefore allow any current holder to emit the latest claim.

Why use it#

Counterfactual registration is useful when an agent needs a stable identifier before it needs a minted ERC-8004 identity. The caller pays only event-log gas for the claim and can defer the onchain mint until the identity needs registry state.

The model also lets a master NFT carry multiple counterfactual identities. Each distinct (tokenContract, tokenId) maps to a distinct registrationHash; the same token always maps to the same hash on a given chain and adapter.

When you later need an onchain ERC-8004 token, call register with the same external token coordinates. There is no settle function today; settle-later promotion is future work, and the current promotion path is a normal onchain registration.

The registrationHash#

The registrationHash is the canonical indexer key for a counterfactual identity:

registrationHash = keccak256(
    abi.encode(block.chainid, address(adapter), tokenContract, tokenId)
)

The adapter exposes the same computation as a public view:

function registrationHash(address tokenContract, uint256 tokenId) external view returns (bytes32)

Off-chain consumers should call that view when possible instead of re-implementing the encoding.

The hash includes:

FieldWhy it is included
block.chainidPrevents replay across chains.
address(adapter)Prevents replay across adapter deployments or proxies.
tokenContractBinds the claim to one external token contract.
tokenIdBinds the claim to one token id inside that contract.

TokenStandard is intentionally excluded. A hybrid token contract that exposes the same tokenId through multiple standards can therefore collide on the same hash, but the indexer resolves the standard from the originating CounterfactualAgentRegistered event and uses token coordinates as the off-chain binding key.

Function reference#

Every counterfactual write:

  • requires tokenContract != address(0) (InvalidTokenContract() otherwise);
  • requires the caller to currently control the external token (NotController(account, type(uint256).max) otherwise);
  • computes registrationHash using the canonical encoding above;
  • emits one event on IERC8004AdapterCounterfactual;
  • uses nonReentrant;
  • writes no adapter storage and makes no ERC-8004 registry call.

counterfactualRegister (with metadata)#

function counterfactualRegister(
    TokenStandard standard,
    address tokenContract,
    uint256 tokenId,
    string calldata agentURI,
    MetadataEntry[] memory metadata
) public returns (bytes32 registrationHash)

What it does

Emits a counterfactual identity claim for an external token. No ERC-8004 identity is minted. The returned registrationHash is the canonical indexer key for this off-chain claim.

Parameters

  • standard - ERC721, ERC1155, or ERC6909.
  • tokenContract - external token contract address.
  • tokenId - id within tokenContract.
  • agentURI - initial off-chain URI.
  • metadata - array of MetadataEntry { string metadataKey; bytes metadataValue; }.

Returns

  • registrationHash - deterministic, chain-and-adapter-scoped id for this counterfactual claim.

Events

  • CounterfactualAgentRegistered(registrationHash, tokenContract, tokenId, standard, agentURI, metadata, emitter).

Reverts

  • InvalidTokenContract() if tokenContract is zero.
  • NotController(account, type(uint256).max) if the caller does not currently control the external token.
  • ReservedMetadataKey("agent-binding") if the metadata array contains the canonical binding key.

counterfactualRegister (no metadata)#

function counterfactualRegister(
    TokenStandard standard,
    address tokenContract,
    uint256 tokenId,
    string calldata agentURI
) external returns (bytes32 registrationHash)

What it does

Convenience overload equivalent to the metadata-array form with an empty MetadataEntry[]. Same event and revert behavior.

Returns

  • registrationHash - deterministic, chain-and-adapter-scoped id for this counterfactual claim.

counterfactualSetAgentURI#

function counterfactualSetAgentURI(
    TokenStandard standard,
    address tokenContract,
    uint256 tokenId,
    string calldata newURI
) external

What it does

Emits a counterfactual URI update for the token's off-chain identity.

Parameters

  • standard, tokenContract, tokenId - external token coordinates.
  • newURI - new off-chain URI.

Events

  • CounterfactualAgentURISet(registrationHash, tokenContract, tokenId, newURI, emitter).

Reverts

  • InvalidTokenContract() if tokenContract is zero.
  • NotController(account, type(uint256).max) if the caller does not currently control the external token.

counterfactualSetMetadata#

function counterfactualSetMetadata(
    TokenStandard standard,
    address tokenContract,
    uint256 tokenId,
    string calldata metadataKey,
    bytes calldata metadataValue
) external

What it does

Emits a counterfactual single-key metadata write.

Parameters

  • standard, tokenContract, tokenId - external token coordinates.
  • metadataKey, metadataValue - metadata pair.

Events

  • CounterfactualMetadataSet(registrationHash, tokenContract, tokenId, metadataKey, metadataValue, emitter).

Reverts

  • InvalidTokenContract() if tokenContract is zero.
  • NotController(account, type(uint256).max) if the caller does not currently control the external token.
  • ReservedMetadataKey("agent-binding") if metadataKey is the canonical binding key.

counterfactualSetMetadataBatch#

function counterfactualSetMetadataBatch(
    TokenStandard standard,
    address tokenContract,
    uint256 tokenId,
    MetadataEntry[] calldata metadata
) external

What it does

Emits a counterfactual batch metadata write in one transaction.

Parameters

  • standard, tokenContract, tokenId - external token coordinates.
  • metadata - array of MetadataEntry { string metadataKey; bytes metadataValue; }.

Events

  • CounterfactualMetadataBatchSet(registrationHash, tokenContract, tokenId, metadata, emitter).

Reverts

  • InvalidTokenContract() if tokenContract is zero.
  • NotController(account, type(uint256).max) if the caller does not currently control the external token.
  • ReservedMetadataKey("agent-binding") if any entry uses the canonical binding key.

counterfactualSetAgentWallet#

function counterfactualSetAgentWallet(
    TokenStandard standard,
    address tokenContract,
    uint256 tokenId,
    address newWallet
) external

What it does

Emits a counterfactual agent-wallet assignment. This function deliberately accepts no signature and no deadline because no ERC-8004 wallet binding is being created. The event is purely an off-chain claim gated by current bound-token control.

Parameters

  • standard, tokenContract, tokenId - external token coordinates.
  • newWallet - wallet address being claimed off-chain.

Events

  • CounterfactualAgentWalletSet(registrationHash, tokenContract, tokenId, newWallet, emitter).

Reverts

  • InvalidTokenContract() if tokenContract is zero.
  • NotController(account, type(uint256).max) if the caller does not currently control the external token.

counterfactualUnsetAgentWallet#

function counterfactualUnsetAgentWallet(
    TokenStandard standard,
    address tokenContract,
    uint256 tokenId
) external

What it does

Emits a counterfactual agent-wallet clear.

Parameters

  • standard, tokenContract, tokenId - external token coordinates.

Events

  • CounterfactualAgentWalletUnset(registrationHash, tokenContract, tokenId, emitter).

Reverts

  • InvalidTokenContract() if tokenContract is zero.
  • NotController(account, type(uint256).max) if the caller does not currently control the external token.

registrationHash#

function registrationHash(address tokenContract, uint256 tokenId) external view returns (bytes32)

What it does

Returns the canonical counterfactual hash for the given external token, scoped to the current chain and this adapter proxy.

Behavior

return keccak256(abi.encode(block.chainid, address(this), tokenContract, tokenId));

Reverts

  • Does not perform token-control checks and does not revert for a zero tokenContract; it only computes the hash.

Event reference#

All counterfactual events are declared on IERC8004AdapterCounterfactual. registrationHash, tokenContract, and tokenId are indexed on every event, so indexers can filter by any of those three topics.

CounterfactualAgentRegistered#

event CounterfactualAgentRegistered(
    bytes32 indexed registrationHash,
    address indexed tokenContract,
    uint256 indexed tokenId,
    TokenStandard standard,
    string agentURI,
    MetadataEntry[] metadata,
    address emitter
)

Fires from counterfactualRegister. The payload establishes the initial counterfactual identity claim: token standard, initial URI, initial metadata, and the controller address that emitted the claim.

CounterfactualAgentURISet#

event CounterfactualAgentURISet(
    bytes32 indexed registrationHash,
    address indexed tokenContract,
    uint256 indexed tokenId,
    string newURI,
    address emitter
)

Fires from counterfactualSetAgentURI. newURI replaces the current counterfactual URI for the token under the latest-event policy.

CounterfactualMetadataSet#

event CounterfactualMetadataSet(
    bytes32 indexed registrationHash,
    address indexed tokenContract,
    uint256 indexed tokenId,
    string metadataKey,
    bytes metadataValue,
    address emitter
)

Fires from counterfactualSetMetadata. The key/value pair updates one metadata key in the counterfactual record.

CounterfactualMetadataBatchSet#

event CounterfactualMetadataBatchSet(
    bytes32 indexed registrationHash,
    address indexed tokenContract,
    uint256 indexed tokenId,
    MetadataEntry[] metadata,
    address emitter
)

Fires from counterfactualSetMetadataBatch. The payload carries multiple metadata entries emitted as one update.

CounterfactualAgentWalletSet#

event CounterfactualAgentWalletSet(
    bytes32 indexed registrationHash,
    address indexed tokenContract,
    uint256 indexed tokenId,
    address newWallet,
    address emitter
)

Fires from counterfactualSetAgentWallet. newWallet is the runtime wallet claimed for the off-chain identity. This is not an ERC-8004 registry wallet binding and has no signature proof.

CounterfactualAgentWalletUnset#

event CounterfactualAgentWalletUnset(
    bytes32 indexed registrationHash,
    address indexed tokenContract,
    uint256 indexed tokenId,
    address emitter
)

Fires from counterfactualUnsetAgentWallet. Indexers should clear the current counterfactual wallet value for the token.

Indexer guidance#

Use (tokenContract, tokenId) as the authoritative conflict domain and registrationHash as the stable lookup key. The chain does not dedupe or enforce uniqueness; it only verifies current token control at emission time.

The canonical policy is latest event per (tokenContract, tokenId) wins. If the same controller, or another current controller on a shared-control token standard, re-emits a claim, the most recent log is the current record.

Deduplicate exact re-emissions by transaction hash and log index first. For semantic duplicates across transactions, keep the latest log and treat older logs as history. Do not assume identical payloads mean the event can be skipped unless your indexer has already processed that exact log.

If you observe an update event before the corresponding CounterfactualAgentRegistered event, you can back-resolve the hash by scanning for CounterfactualAgentRegistered with the same indexed registrationHash or the same (tokenContract, tokenId). If no registration event is available in your indexed range, keep the update as an incomplete record and continue watching; the update still came from a controller-gated adapter call.

Relationship to the on-chain register surface#

Counterfactual functions mirror the onchain registration and setter shapes by taking (TokenStandard, tokenContract, tokenId, ...), then applying the same bound-token control rule used by register.

They do not mint an ERC-8004 token. There is no agentId, no ownerOf record in the ERC-8004 registry, no registry metadata, and no adapter bindingOf storage row. Any identity state exists only in event logs and indexer state.

Use counterfactual registration when you want a cheap, indexer-keyed identity now and can defer the registry mint. Use Contract Reference register when you need a real ERC-8004 token and registry-backed metadata or wallet state. A future settle path may link the two more directly; today, settling means calling the normal onchain register function with the same external token.