合約介面查詢標準(Standard Interface Detection)

NIC Lin
Taipei Ethereum Meetup
14 min readApr 2, 2018

目前以太坊開發生態系統中有各式各樣的合約,最有名例如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的目的都是方便讓別人知道你合約的介面,或讓其他合約(例如想要能支援各種代幣的交換合約或交易所合約)能夠自動判斷該使用哪個介面來和你的合約互動。但函式是否真的如預期執行還是要靠驗證比對合約原始碼和部署代碼。

Reference:
[1] https://github.com/ethereum/EIPs/issues/165
[2] https://github.com/ethereum/EIPs/pull/639
[3] https://github.com/ethereum/EIPs/pull/881
[4] https://github.com/ethereum/EIPs/blob/master/EIPS/eip-165.md
[5] https://github.com/ethereum/EIPs/issues/820
[6] https://github.com/ethereum/EIPs/pull/906
[7] https://github.com/ethereum/EIPs/issues/672

--

--