引介 | 合约接口查询标准(Standard Interface Detection)

Ajian   |     |   1864 次阅读

目前以太坊开发生态系统中有各式各样的合约,最有名例如 ERC20 代币标准、加密猫使用的 NFT 代币,还有其他不断出现的新概念。但即便是 ERC20 的正式标准也是经过一年多的时间才确立,而市面上早已存在许多不同的衍生版本。

如果合约拥有者有公布原始码、编译版本,或是使用 Etherscan 验证合约代码的功能,则你至少可以验证部署的合约代码是否正确,然后再从原始码来得知该合约支持哪些功能。有没有其他更方便的方式?

EIP165(Standard Interface Detection)的目的即是为了订立一个查询合约接口的标准。

- https://upload.wikimedia.org/wikipedia/commons/b/bd/UNIVAC-I-BRL61-0977.jpg -

EIP165

界面?

以 ERC20 为例,一个标准的 ERC20 合约必须要包含 name()balanceOf()transfer()transferFrom()approve() 等等的函式,这即是一个标准的 ERC20接口。

但并非要包含所有规定必须要有的函式才能算是接口,由name()balanceOf()transfer()三个函式组成也可以叫做一个接口(只是就不会称为标准 ERC20 接口,而是例如 ERC20TransferOnly 的名称)。如果你的 ERC20 多包含像是mine()burn()等的函式,则一样也是一个接口(名称可能是ERC20Mineable)。

一个合约可以有多个接口。

如何区分/识别一个界面?

每个函式都有自己的函式标识符(function identifier),例如 ERC20 的 transfer 函式的标识符是0x23b872dd,计算方式是哈希后取前四个byte,如下。

0x23b872dd = bytes4(sha3("transferFrom(address,address,uint256)"))

在 Solidity 0.4.17 版之后,函式多了一个selector值,会回传该函式的标识符,不需要再自己计算。
而一个接口的标识符则是由该接口所有的函式的标识符 XOR 后的值,举例如下。

pragma solidity ^0.4.20;

contract InterfaceExample {
    function foo(string name) returns (uint256);
    function bar(address addr) returns (uint256);

    function interfaceID() constant returns (bytes4) {
        return this.foo.selector ^ this.bar.selector;
    }

}

如何确认一个合约是否支持某个接口?

如果一个合约支持 EIP165,则必须要包含 supportsInterface 函式:

contract InterfaceExample {

    // 此函式的标识符为0x01ffc9a7
    function supportsInterface(bytes4 interfaceID) external view returns (bool);

}

要确认一个合约是否支持某个接口,首先要确认该合约是否支持EIP165。表示如果你呼叫这个合约的 supportsInterface 函式并带入参数0x01ffc9a7,它必须要回传 true

但这里要注意的是,如果你呼叫一个不存在的函式,则合约会去执行 fallback 函式。如果该合约有 fallback 函式且成功执行,则你最后都会得到 true 的回传值(false positive)。所以当你第一次呼叫 supportsInterface(0x01ffc9a7) 并得到 true 的回传值时,你必须要再呼叫一次 supportsInterface(0xffffffff) 来确认,如果supportsInterface(0xffffffff)回传true,表示该合约不支持EIP165,因为你得到的 true 并非 supportsInterface 回传的 true;如果 supportsInterface(0xffffffff) 回传 false,则表示该合约支持 EIP165。

接着查询是否支持指定的接口,呼叫 supportsInterface 并带入你欲查询的 interfaceID 作为参数。以下是支持 EIP165 的合约简单范例:

pragma solidity ^0.4.20;

contract InterfaceExample {

    // 注意0xffffffff不可设为true
    mapping(bytes4 => bool) internal supportedInterfaces;

    function InterfaceExample() internal {
        supportedInterfaces[this.supportsInterface.selector] = true;
    }

    function supportsInterface(bytes4 interfaceID) external view returns (bool) {
        return supportedInterfaces[interfaceID];
    }
}

要注意的是 EIP165 规定 supportInterface 消耗的 gas 要少于 30000。虽然没办法强制,但你最好假设其他人呼叫你合约的 supportInterface 时会设定 gas 限额为 30000,如果用超过就会导致函式因为 OutOfGas 结束并回传 false

EIP820(EIP165的延伸)

EIP165 设计是由合约自己去实作 supportsInterface 函式,EIP820 则是透过一个 Registry 合约让大家来登记自己的合约包含哪些接口,而且对象不只限于合约,单纯的用户帐户(External Owned Account)也可以登记让其他人知道自己有哪些接口可以互动。

例如你可能想要在收到代币时能做出反应(例如触发 event 等),但这只有在你的账户是一个合约的时候才有办法做到。透过 EIP820,即便是单纯的用户帐户,你可以登记如果有人转代币给你时,它要去哪个地址呼叫例如 tokenFallback(或 onTokenReceived)函式来完成你预期收到代币要做的事。而这也是代币标准 ERC777 的目标。

登记人的资格

首先,账户的拥有者可以为自己的帐户登记哪个地址(addr)帮自己实作哪些了哪些接口(这里接口标识符用 iHash 代替)。EIP820 也提供了 manager 的机制,让账户拥有者可以指定其他地址(可以是合约也可以是单纯的用户帐户)来担任自己的 manager,替自己登记。和 manager 相关的资料和函式如下:

contract ERC820Registry {

    // manager改变时所触发的event
    event ManagerChanged(address indexed addr, address indexed newManager);

    // 纪录各个地址的manager的数据
    mapping (address => address) managers;

    // 身份检查的modifier
    modifier canManage(address addr) {
        require(getManager(addr) == msg.sender);
        _;
    }

    // manager预设为自己
    function getManager(address addr) public view returns(address) {
        if (managers[addr] == 0) {
            return addr;
        } else {
            return managers[addr];
        }
    }

    function setManager(address addr, address newManager) public canManage(addr) {
        managers[addr] = newManager == addr ? 0 : newManager;
        ManagerChanged(addr, newManager);
    }
}

登记的方式

和接口登记的相关数据和函式如下:

contract ERC820Registry {

    event InterfaceImplementerSet(address indexed addr, bytes32 indexed interfaceHash, address indexed implementer);

    mapping (address => mapping(bytes32 => address)) interfaces;

    function getInterfaceImplementer(address addr, bytes32 iHash) constant public returns (address);

    function setInterfaceImplementer(address addr, bytes32 iHash, address implementer) public canManage(addr);

}

Implementer 是实作接口的合约地址,如果是合约来登记自己提供的接口的话,Implementer 会设为自己。
要注意的是 EIP820 的接口标识符是使用 bytes32,但为了兼容 EIP165 的接口,在 getInterfaceImplementer 里会先检查是否是要查询 EIP165 的接口。因为 EIP165 的接口标识符是 bytes4,所以如果是要透过 EIP820 查询 EIP165 的接口,你必须要将 EIP165 的接口标识符后方补上28个 0。下方是查询 EIP820 接口的函式 getInterfaceImplementer

function getInterfaceImplementer(address addr, bytes32 iHash) constant public returns (address) {

    //检查是不是要查询EIP165的接口
    if (isERC165Interface(iHash)) {
        bytes4 i165Hash = bytes4(iHash);
        return erc165InterfaceSupported(addr, i165Hash) ? addr : 0;
    }
    return interfaces[addr][iHash];
}

支援 EIP165 的函式:

function isERC165Interface(bytes32 iHash) internal pure returns (bool);

function erc165InterfaceSupported(address _contract, bytes4 _interfaceId) constant public returns (bool);

function erc165UpdateCache(address _contract, bytes4 _interfaceId) public;

function erc165InterfaceSupported_NoCache(address _contract, bytes4 _interfaceId) public constant returns (bool);

其中 erc165UpdateCache 会将查询过或登记过的 EIP165 的界面存下来(存在 mapping (address => mapping(bytes4 => bool)) erc165Cache; 中),这样就不必每次查询都要再经过 EIP165 的步骤去确认是否支持特定接口。

指定 Implementer 须经过对方确认

任何人都可以登记任何合约地址为自己的 Implementer,这会有一个潜在的漏洞:攻击者发行代币 A(部署在合约 X),并透过 EIP820 先登记代币 A 的 Implementer 为合约 X,这时大家都是正常的以合约 X 上的逻辑去交换代币 A;某天攻击者忽然将代币 A 的 Implementer 改为价格较高的代币 B 背后的合约 Y,这时大家以为自己还是在交换代币 A,但其实做的交易都是送到合约 Y去,也就是大家变成在交换代币 B。此时攻击者可以大量收购代币 A(卖方以为自己是在卖代币A),藉此以廉价的代币 A 价格买到较贵的代币 B。

所以当你要登记 Implementer 时,EIP820 会确认对方同意你登记它为你 EIP820 上的 Implementer

bytes32 constant ERC820_ACCEPT_MAGIC = keccak256("ERC820_ACCEPT_MAGIC");


interface ERC820ImplementerInterface {

    // Implementer必须要支持下列函式,且这个函式必须回传ERC820_ACCEPT_MAGIC这个值表示同意做为addr这个地址的implementer
    function canImplementInterfaceForAddress(address addr, bytes32 interfaceHash) view public returns(bytes32);

}

Implementer 合约必须要支持 canImplementInterfaceForAddress 这个函式。当你指定某个合约为 Implementer 时,EIP820 合约会呼叫对方的 canImplementInterfaceForAddress 函式,对方回传 ERC820_ACCEPT_MAGIC 才会被视为同意。下方是登记 EIP820 接口的函式 setInterfaceImplementer

function setInterfaceImplementer(address addr, bytes32 iHash, address implementer) public canManage(addr)  {

    //不能是EIP165的界面
    require(!isERC165Interface(iHash));

    //呼叫canImplementInterfaceForAddress并确认回传值是否为ERC820_ACCEPT_MAGIC
    if ((implementer != 0) && (implementer!=msg.sender)) {
require(ERC820ImplementerInterface(implementer).canImplementInterfaceForAddress(addr, iHash) == ERC820_ACCEPT_MAGIC);
        }
    interfaces[addr][iHash] = implementer;
    InterfaceImplementerSet(addr, iHash, implementer);
}

最后要注意的是,EIP165 和 EIP820 的目的都是方便让别人知道你合约的接口,或让其他合约(例如想要能支持各种代币的交换合约或交易所合约)能够自动判断该使用哪个接口来和你的合约互动。但函式是否真的如预期执行还是要靠验证比对合约原始码和部署代码。


原文链接: https://medium.com/taipei-ethereum-meetup/%E5%90%88%E7%B4%84%E4%BB%8B%E9%9D%A2%E6%9F%A5%E8%A9%A2%E6%A8%99%E6%BA%96-standard-interface-detection-2160b2336e1a
作者: NIC Lin


你可能还会喜欢:

科普 | ERC 和 EIP 代表什么呢?
干货 | ERC721: Non-fungible Token Standard
科普 | 以太坊改进建议(EIP)演示

 
0 人喜欢