ENSIP-24: Arbitrary Data Resolution

Pre-context.

Unruggable have been working with the team at Wonderland, Josh Rudolf from the Ethereum Foundation, and the wider Interop working group on fleshing out the specifications and implementations for interoperable addressing standards.

Over the past year @Premm.eth, @raffy, and myself have had numerous conversations about standardising a method for the resolution of arbitrary binary data. Previously it was a nice-to-have, but the requirements of ERC-7828: Interoperable Names using ENS (more to follow) have made it a necessity.

This specification is simple yet incredibly flexible. It allows for the resolution of anything using an ENS name.

I’ve posted the body of the specification below, and have opened a Pull Request into the main ENSIP repository here.

We would be appreciative of any feedback from the ENS community, and are, of course, happy to answer any questions.


ENSIP-24: Arbitrary Data Resolution

Abstract

This ENSIP proposes a new resolver profile for resolving arbitrary bytes data.

Motivation

ENS has seen significant adoption across the blockchain ecosystem, but the current resolver profiles are too restrictive for many emerging applications.

There is a clear need for a richer record type that can store unstructured binary data. This would provide a flexible, gas-efficient mechanism for associating any data with an ENS name. This would enable direct support for resolving:

  • Decentralized identifiers (DIDs)
  • Hashed data commitments for off-chain data verification
  • Interoperable addresses
  • Context data

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.

Overview

This ENSIP introduces a new interface for the resolution of arbitrary bytes data:

interface IDataResolver {
    function data(
        bytes32 node,
        string calldata key
    ) external view returns (bytes memory);
}

The ERC-165 identifier for this interface is 0xecbfada3.

The key argument is a string type for simplicity and clarity.

Resolvers implementing this ENSIP MUST emit the following event when data is changed:

event DataChanged(
    bytes32 node, 
    string key,
    bytes32 indexed keyHash, 
    bytes32 indexed dataHash
);

The keyHash and dataHash values are the keccak256 hashes of the key and the data being set.

Resolvers MAY implement the following interface

interface ISupportedDataKeys {
    function supportedDataKeys(bytes32 node) external view returns (string[] memory);
}

The ERC-165 identifier for this interface is 0x29fb1892.

If implemented this function MUST return an array of string keys which the resolver MAY store data for.

Rationale

Retrofitting the addr(bytes32 node, uint coinType) function defined in ENSIP-9 as a getter for resolving arbitrary bytes was considered, but whilst cool, its usage is hacky and not semantically clear to developers.

We considered simply storing bytes data as a string within text records but this is inefficient, expensive, and impractical.

The DataChanged event emits both the key and the keyHash, the keccak256 hash of the key. For flexibility, the keyHash is explicitly calculated rather than using indexed strings.

The DataChanged event emits a keccak256 hash of the stored data to allow data integrity checks whilst avoiding chain bloat.

The optional ISupportedDataKeys interface allows for data key discovery. The intended usage of this interface is to enable data discovery for small, known, and bounded sets of keys without the need for external dependencies.

Example

Below is an illustrative snippet that shows how to set and retrieve arbitrary data:

pragma solidity ^0.8.25;

interface IDataResolver {

    event DataChanged(
        bytes32 node, 
        string key,
        bytes32 indexed keyHash, 
        bytes32 indexed dataHash
    );

    function data(
        bytes32 node,
        string calldata key
    ) external view returns (bytes memory);
}

contract Resolver is IDataResolver {
    mapping(bytes32 node => mapping(string key => bytes data)) private dataStore;
    
    function data(bytes32 node, string calldata key) external view returns (bytes memory) {
        return dataStore[node][key];
    }
    
    // setData function can be used to set the data (not shown)
}

Set and retrieve arbitrary data:

// Pseudo javascript example

// Store arbitrary data
const tx = await resolver.setData(node, "agent-context", "0x0001ABCD...");
await tx.wait();

// Retrieve arbitrary data
const result = await resolver.data(node, "agent-context");

Backwards Compatibility

This proposal introduces a new resolver profile and does not affect existing ENS functionality. It introduces no breaking changes.

Security Considerations

None.

Copyright

Copyright and related rights waived via CC0.

12 Likes

Use keyhash in mapping.

mapping(bytes32 node => mapping(bytes32 keyhash => bytes data)) private dataStore;

Double key and keyhash is unnecessary event cost, and node should be indexed.

    event DataChanged(
        bytes32 indexed node,
        bytes32 indexed keyHash, 
        bytes32 dataHash // ? better not indexed
    );

string key is good for humons only, it’d be better to use keyhash for gas saving as tx/calldata is bottleneck for gas optimization. if human readability is preferred we could still use bytes32 space for small strings without breaking anything.

    function data(
        bytes32 node,
        bytes32 keyhash
    ) external view returns (bytes memory);

overall this ENSIP could also be reused to store everything in same mapping.

  • eg, storing addr records in same mapping
bytes32 _keyhash = keccak256(abi.encodeWithSelector(bytes4(keccak256("addr(bytes32)")), node));

bytes memory _data = abi.encode(address(0xc0de4c0ffee));

// eg, set data with offchain libzipped compression in tx input
resolver.setData(node, _keyhash, LibZip.cdCompress(_data);

Finally it’s also missing versioned storage/events to lookup old data records.
eg,

    struct Record {
        mapping(uint256 version => bytes data) record;
        uint256 version; // latest version
    }
    // or could be stored as 
    // _records[node][key][version] = data
    // _version[node][key] = version
    mapping(bytes32 node => mapping(bytes32 key => Record record)) internal _records;

    function data(bytes32 node, bytes32 key) external view returns (bytes memory _data) {
        uint256 v = _records[node][key].version;
        require(v > 0, RecordNotFound());
        return _records[node][key].record[v];
    }

    function getDataVersion(bytes32 node, bytes32 key, uint256 version) external view returns (bytes memory _data) {
        require(version > 0, RecordNotFound());
        return _records[node][key].record[version];
    }

   event RecordUpdated(
        bytes32 indexed node,
        bytes32 indexed keyHash, 
        bytes32 dataHash,
        uint256 version
    );

Some of this has changed. The latest draft can be found here:

1 Like

From Draft… Arbitrary Data Resolution | ENS Docs

Resolvers implementing this ENSIP MUST emit the following event when data is changed:


/// @notice For a specific `node`, the data associated with a `key` has changed.
event DataChanged(
    bytes32 indexed node, 
    string indexed indexedKey,
    string key, 
    bytes indexed indexedData
);

The DataChanged event emits both the key and the indexedKey. This allows for efficient event filtering whilst immediately exposing useful key data. indexedData is also emitted to allow data integrity checks whilst avoiding chain bloat.

IDK if this event is typo or nobody compared extra gas overheads in such overloaded events?
I’d suggest to keep it minimalist,

event DataChanged(
    bytes32 indexed node, 
    bytes32 indexed keyHash, // or string
    bytes indexedData
);

bytes indexed data only reason to add that data is for indexers to read directly from event without using 2nd RPC call… & end users pay for that in setData tx.


From the basics…

We already have text(bytes32 node, string calldata key) returns (string memory)

so obvious iteration for bytes data storage would be
function data(bytes32 node, string calldata key) returns (bytes memory _data)

and optimizing key as keyhash = keccak256(key) gives us

function data(bytes32 node, bytes32 keyhash) returns (bytes memory _data)

setData(bytes32 node, bytes32 key, bytes calldata _data)

Resolvers MAY implement the following interface

/// @dev Interface selector: `0x29fb1892`
interface ISupportedDataKeys {
    /// @notice For a specific `node`, get an array of supported data keys.
    /// @param node The node (namehash).
    /// @return The keys for which we have associated data.
    function supportedDataKeys(bytes32 node) external view returns (string[] memory);
}

ENS Public resolvers use version internally so this ENSIP could leverage that to detect individual data key supported in resolver. Keeping track of all supported data keys will add more gas per setData in public resolvers.

function supportedDataKeys(bytes32 node, bytes32 keyHash) external view returns (bool) {
   return _dataStorage[node][keyHash].version > 0; 
}
1 Like

@0xc0de4c0ffee thanks for sharing your thoughts and feedback.

This is a very basic illustrative example. In reality implementors will likely (and should) have much more context specific implementations that appropriately trade-off gas considerations and user experience.

Ultimately we decided on:

event DataChanged(
        bytes32 indexed node, 
        string indexed indexedKey,
        string key, 
        bytes indexed indexedData
);

This was to align with the ITextResolver profile implemented by ENS Labs within their resolvers. It was felt that familiarity should text precedence over gas costs.

There was some further discussion about this event on the PR, here.

We envisage this getter being called from off-chain by humans. Human readability and ease-of-interface was intentionally prioritized. This also mimics ENSIP-5 for text records.

Agree ! I’ve been implementing the resolver for ERC-7828: Interoperable Names using ENS (note - this is currently being reworked). That was what motivated the push to have this standardized.

Intentional. This is an implementation detail for people who need versioning.
ENS Labs have implemented a TextResolver that outlines one possible architecture for storage of versioned text records.

Not a typo. It does allow for efficient event filtering. There is an increased gas cost, but it was felt the trade-off was a reasonable one.

In the case of:

event DataChanged(
        bytes32 indexed node,
        bytes32 indexed keyHash, 
        bytes32 dataHash // ? better not indexed
    );

Back of napkin suggests a 119 gas difference on the indexing (or not) of dataHash.

As defined we have indexedData and include the string key. The inclusion of the key adds 4-8 gas per byte. Unless the keys being used are incredibly long, my opinion is that the additional gas costs are negligible and a worthwhile tradeoff for the usability improvements.

ENS integrated dApps rely heavily on indexing, and removing the need for second RPC calls was a consideration. Every element of this spec was a trade-off between gas cost, and usability - serving the majority, out the box.

If for example an implementor does not utilize an architecture that gives consideration to versioning then these events can provide that history through a simple, established mechanism.

Your positioning on versioning within real world implementations does make sense. I wrote a PR for the ens-contracts repo, and your suggestions were mimicked by @raffy. I will make those changes.

Appreciative of your comments - good to have competent technical eyes on these specs and associated discussions.

2 Likes