Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7738: Permissionless Script Registry

Permissionless registry to fetch executable scripts for contracts

Authors Victor Zhang (@zhangzhongnan928), James Brown (@JamesSmartCell)
Created 2024-07-01
Discussion Link https://ethereum-magicians.org/t/erc-7738-permissionless-script-registry/20503
Requires EIP-173

Abstract

This EIP provides a means to create a standard registry for locating executable scripts associated with the token.

Motivation

ERC-5169 provides a client script lookup method for contracts. This requires the contract to have implemented the ERC-5169 interface at the time of construction (or allow an upgrade path).

This proposal outlines a contract that can supply prototype and certified scripts. The contract would be a multichain singleton instance that would be deployed at identical addresses on supported chains.

Overview

The registry contract will supply a set of URI links for a given contract address. These URI links point to script programs that can be fetched by a wallet, viewer or mini-dapp.

The pointers can be set permissionlessly using a setter in the registry contract.

Specification

The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY” and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

The contract MUST implement the IERC7738 interface. The contract MUST emit the ScriptUpdate event when the script is updated. The contract SHOULD order the scriptURI returned so that the ERC-173 owner() of the contract’s script entries are returned first (in the case of simple implementations the wallet will pick the first scriptURI returned). The contract SHOULD provide a means to page through entries if there are a large number of scriptURI entries.

interface IERC7738 {
    /// @dev This event emits when the scriptURI is updated, 
    /// so wallets implementing this interface can update a cached script
    event ScriptUpdate(address indexed contractAddress, string[] newScriptURI);

    /// @notice Get the scriptURI for the contract
    /// @return The scriptURI
    function scriptURI(address contractAddress) external view returns (string[] memory);

    /// @notice Update the scriptURI 
    /// emits event ScriptUpdate(address indexed contractAddress, scriptURI memory newScriptURI);
    function setScriptURI(address contractAddress, string[] memory scriptURIList) external;
}

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.

Rationale

This method allows contracts written without the ERC-5169 interface to associate scripts with themselves, and avoids the need for a centralised online server, with subsequent need for security and the requires an organisation to become a gatekeeper for the database.

Test Cases

Test cases are included in NFTRegistryTest.test.ts. Contracts, deployment scripts and registry script can be found alongside the test script.

Clone the repo and run:

cd ../assets/eip-7738
npm install --save-dev hardhat
npm install
npx hardhat test

Reference Implementation

The live implementation of the script registry is at 0x0077380bCDb2717C9640e892B9d5Ee02Bb5e0682 on several mainnet, L2 and testnet chains. To deploy scripts for use you can directly call the setScriptURI function:

function setScriptURI(address contractAddress, string[] memory newScriptURIs)

or use the bundled ethers script, ensuring to fill in the target contract address and scriptURI:

Create Registry Entry

Simplified Implementation

import "@openzeppelin/contracts/access/Ownable.sol";

contract DecentralisedRegistry is IERC7738 {
    struct ScriptEntry {
        mapping(address => string[]) scriptURIs;
        address[] addrList;
    }

    mapping(address => ScriptEntry) private _scriptURIs;

    function setScriptURI(
        address contractAddress,
        string[] memory scriptURIList
    ) public {
        require (scriptURIList.length > 0, "> 0 entries required in scriptURIList");
        bool isOwnerOrExistingEntry = Ownable(contractAddress).owner() == msg.sender 
            || _scriptURIs[contractAddress].scriptURIs[msg.sender].length > 0;
        _scriptURIs[contractAddress].scriptURIs[msg.sender] = scriptURIList;
        if (!isOwnerOrExistingEntry) {
            _scriptURIs[contractAddress].addrList.push(msg.sender);
        }
        
        emit ScriptUpdate(contractAddress, msg.sender, scriptURIList);
    }

    // Return the list of scriptURI for this contract.
    // Order the return list so `Owner()` assigned scripts are first in the list
    function scriptURI(
        address contractAddress
    ) public view returns (string[] memory) {
        //build scriptURI return list, owner first
        address contractOwner = Ownable(contractAddress).owner();
        address[] memory addrList = _scriptURIs[contractAddress].addrList;
        uint256 i;

        //now calculate list length
        uint256 listLen = _scriptURIs[contractAddress].scriptURIs[contractOwner].length;
        for (i = 0; i < addrList.length; i++) {
            listLen += _scriptURIs[contractAddress].scriptURIs[addrList[i]].length;
        }

        string[] memory ownerScripts = new string[](listLen);

        // Add owner scripts
        uint256 scriptIndex = _addScriptURIs(contractOwner, contractAddress, ownerScripts, 0);

        // Add remainder scripts
        for (uint256 i = 0; i < addrList.length; i++) {
            scriptIndex = _addScriptURIs(addrList[i], contractAddress, ownerScripts, scriptIndex);
        }

        return ownerScripts;
    }

    function _addScriptURIs(
        address user,
        address contractAddress,
        string[] memory ownerScripts,
        uint256 scriptIndex
    ) internal view returns (uint256) {
        for (uint256 j = 0; j < _scriptURIs[contractAddress].scriptURIs[user].length; j++) {
            string memory thisScriptURI = _scriptURIs[contractAddress].scriptURIs[user][j];
            if (bytes(thisScriptURI).length > 0) {
                ownerScripts[scriptIndex++] = thisScriptURI;
            }
        }
        return scriptIndex;
    }
}

Security Considerations

The scripts provided could be authenticated in various ways:

  1. The target contract which the setter specifies implements the ERC-173 Ownable interface. Once the script is fetched, the signature can be verified to match the Owner(). In the case of TokenScript this can be checked by a dapp or wallet using the TokenScript SDK, the TokenScript online verification service, or by extracting the signature from the XML, taking a keccak256 of the script and ecrecover the signing key address.
  2. If the contract does not implement Ownable, further steps can be taken: a. The hosting app/wallet can acertain the deployment key using 3rd party API or block explorer. The implementing wallet, dapp or viewer would then check the signature matches this deployment key. b. Signing keys could be pre-authenticated by a hosting app, using an embedded keychain. c. A governance token could allow a script council to authenticate requests to set and validate keys.

If these criteria are not met:

  • For mainnet implementations the implementing wallet should be cautious about using the script - it would be at the app and/or user’s discretion.
  • For testnets, it is acceptable to allow the script to function, at the discretion of the wallet provider.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Victor Zhang (@zhangzhongnan928), James Brown (@JamesSmartCell), "ERC-7738: Permissionless Script Registry [DRAFT]," Ethereum Improvement Proposals, no. 7738, July 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7738.