Alert Source Discuss
Standards Track: ERC

ERC-3668: CCIP Read—安全的链下数据检索

CCIP Read 提供了一种机制,允许合约获取外部数据。

Authors Nick Johnson (@arachnid)
Created 2020-07-19

摘要

希望支持从外部源查找数据的合约,可以不直接返回数据,而是使用 OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData) 进行 revert 操作。支持此规范的客户端随后对 urls 中的 URL 进行 RPC 调用,提供 callData,并获取不透明的字节字符串 response。最后,客户端在合约上调用由 callbackFunction 指定的函数,提供 responseextraData。然后,合约可以使用特定于实现的方法解码和验证返回的数据。

这种机制允许以对客户端透明的方式进行链下数据查找,并允许合约作者实现任何必要的验证; 在许多情况下,这可以在不需要任何超出数据存储在链上所需的额外信任假设的情况下提供。

动机

最小化以太坊上的存储和交易成本促使合约作者采用各种技术将数据转移到链下,包括哈希、递归哈希(例如 Merkle Trees/Tries)和 L2 解决方案。虽然每个解决方案都有独特的约束和参数,但它们都具有共同点,即链上存储了足够的信息,以便在需要时验证外部存储的数据。

到目前为止,应用程序倾向于设计定制的解决方案,而不是尝试定义通用标准。当单个链下数据存储解决方案就足够时,这是实用的(尽管效率低下),但在多个最终用户可能希望根据自己的需求使用不同的数据存储和可用性解决方案的系统中,这很快变得不切实际。

通过定义一个通用规范,允许智能合约从链下获取数据,我们促进了编写完全与所使用的存储解决方案无关的客户端,这使得新的应用程序能够在不知道与其交互的合约的底层存储细节的情况下运行。

这方面的例子包括:

  • 与存储在 Merkle 树中链下接收者列表的“空投”合约进行交互。
  • 查看存储在 L2 解决方案上的代币的代币信息,就像它们是原生 L1 代币一样。
  • 允许将诸如 ENS 域名之类的数据委托给各种 L2 解决方案,而无需客户端单独支持每个解决方案。
  • 允许合约主动请求外部数据以完成调用,而无需调用者了解该数据的详细信息。

规范

概述

通过 CCIP read 回答查询需要三个步骤:

  1. 查询合约。
  2. 使用 (1) 中提供的 URL 查询网关。
  3. 使用来自 (1) 和 (2) 的数据查询或向合约发送交易。

在步骤 1 中,对合约进行标准的区块链调用操作。合约使用一个错误进行 revert 操作,该错误指定可以在链下找到完成调用所需的数据,并提供可以提供答案的服务的 url,以及步骤 (3) 中调用所需的其他上下文信息。

在步骤 2 中,客户端使用步骤 (1) 中 revert 消息中的 callData 调用网关服务。网关以答案 response 进行响应,其内容对客户端是不透明的。

在步骤 3 中,客户端调用原始合约,提供来自步骤 (2) 的 response 和合约在步骤 (1) 中返回的 extraData。合约解码所提供的数据,并使用它来验证响应并对其采取行动 - 通过将信息返回给客户端或通过在交易中进行更改。合约也可以使用新的错误进行 revert 操作以启动另一个查找,在这种情况下,协议从步骤 1 重新开始。

┌──────┐                                          ┌────────┐ ┌─────────────┐
│客户端│                                          │合约│ │网关 @ url│
└──┬───┘                                          └───┬────┘ └──────┬──────┘
   │                                                  │             │
   │ somefunc(...)                                    │             │
   ├─────────────────────────────────────────────────►│             │
   │                                                  │             │
   │ revert OffchainLookup(sender, urls, callData,    │             │
   │                     callbackFunction, extraData) │             │
   │◄─────────────────────────────────────────────────┤             │
   │                                                  │             │
   │ HTTP request (sender, callData)                  │             │
   ├──────────────────────────────────────────────────┼────────────►│
   │                                                  │             │
   │ Response (result)                                │             │
   │◄─────────────────────────────────────────────────┼─────────────┤
   │                                                  │             │
   │ callbackFunction(result, extraData)              │             │
   ├─────────────────────────────────────────────────►│             │
   │                                                  │             │
   │ answer                                           │             │
   │◄─────────────────────────────────────────────────┤             │
   │                                                  │             │

合约接口

每当调用需要链下数据的函数时,启用 CCIP read 的合约必须使用以下错误进行 revert 操作:

error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData)

sender 是引发错误的合约的地址,用于确定错误是由客户端调用的合约引发的,还是从嵌套调用中“冒泡”出来的。

urls 指定了实现 CCIP read 协议并可以制定查询答案的服务(称为网关)的 URL 模板列表。 urls 可以是空列表 [],在这种情况下,客户端必须指定 URL 模板。尝试 URL 的顺序由客户端决定,但合约应按优先级顺序返回它们,最重要的是第一个条目。

每个 URL 可能包含两个替换参数,{sender}{data}。在调用 URL 之前,sender 将替换为小写的带有 0x 前缀的十六进制格式的 sender 参数,data 将替换为带有 0x 前缀的十六进制格式的 callData 参数。

callData 指定了调用网关的数据。此值对客户端是不透明的。通常,这将是 ABI 编码的,但这是一个实现细节,合约和网关可以根据需要对其进行标准化。

callbackFunction 是原始合约上回调应发送到的函数的 4 字节函数选择器。

extraData 是回调所需的其他数据,并且必须由客户端保留并以未修改的方式提供给回调函数。此值对客户端是不透明的。

合约还必须实现一个回调方法,用于解码和验证网关返回的数据。此方法的名称是特定于实现的,但它必须具有签名 (bytes response, bytes extraData),并且必须具有与使用 OffchainLookup 进行 revert 操作的函数相同的返回类型。

如果客户端成功调用网关,客户端将调用 OffchainLookup 错误中指定的回调函数,并将 response 设置为网关返回的值,并将 extraData 设置为合约的 OffchainLookup 错误中返回的值。合约可以在此回调中启动另一个 CCIP read 查找,但作者应记住,递归调用次数的限制因客户端而异。

在调用上下文中(而不是交易),此调用的返回数据将返回给用户,就像它是由最初调用的函数返回的一样。

例子

假设一个合约有以下方法:

function balanceOf(address addr) public view returns(uint balance);

这些查询的数据以某种哈希数据结构存储在链下,其详细信息对于此示例并不重要。合约作者希望网关获取此查询的证明信息,并使用它调用以下函数:

function balanceOfWithProof(bytes calldata response, bytes calldata extraData) public view returns(uint balance);

因此,balanceOf 的一个有效实现示例是:

function balanceOf(address addr) public view returns(uint balance) {
    revert OffchainLookup(
        address(this),
        [url],
        abi.encodeWithSelector(Gateway.getSignedBalance.selector, addr),
        ContractName.balanceOfWithProof.selector,
        abi.encode(addr)
    );
}

请注意,在此示例中,合约在 callDataextraData 中都返回 addr,因为网关(为了查找数据)和回调函数(为了验证它)都需要它。合约不能简单地将其传递给网关并依赖于它在响应中返回,因为这会使网关有机会响应与最初发出的查询不同的查询。

CCIP 感知合约中的递归调用

当一个 CCIP 感知合约希望调用另一个合约,并且存在被调用者可能实现 CCIP read 的可能性时,调用合约必须捕获被调用者抛出的所有 OffchainLookup 错误,并且如果错误的 sender 字段与被调用者地址不匹配,则使用不同的错误进行 revert 操作。

合约可以选择用不同的错误替换所有 OffchainLookup 错误。这样做可以避免实现对嵌套 CCIP read 调用支持的复杂性,但会使它们变得不可能。

如果存在被调用者实现 CCIP read 的可能性,则 CCIP 感知合约不得允许从嵌套调用中冒泡 revert 操作的默认 Solidity 行为。这是为了防止出现以下情况:

  1. 合约 A 调用非 CCIP 感知合约 B。
  2. 合约 B 回调 A。
  3. 在嵌套调用中,A 使用 OffchainLookup 进行 revert 操作。
  4. 合约 B 不理解 CCIP read 并将 OffchainLookup 传播给其调用者。
  5. 合约 A 也会将 OffchainLookup 传播给其调用者。

此操作序列的结果将是 OffchainLookup,对于客户端来说,它看起来是有效的,因为 sender 字段与被调用的合约的地址匹配,但无法正确执行,因为它仅完成嵌套调用。

例子

下面的代码演示了合约可能支持嵌套 CCIP read 调用的一种方式。为了简单起见,这里使用 Solidity 的 try/catch 语法显示,尽管在撰写本文时,它还不支持捕获自定义错误。

contract NestedLookup {
    error InvalidOperation();
    error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData);

    function a(bytes calldata data) external view returns(bytes memory) {
        try target.b(data) returns (bytes memory ret) {
            return ret;
        } catch OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData) {
            if(sender != address(target)) {
                revert InvalidOperation();
            }
            revert OffchainLookup(
                address(this),
                urls,
                callData,
                NestedLookup.aCallback.selector,
                abi.encode(address(target), callbackFunction, extraData)
            );
        }
    }

    function aCallback(bytes calldata response, bytes calldata extraData) external view returns(bytes memory) {
        (address inner, bytes4 innerCallbackFunction, bytes memory innerExtraData) = abi.decode(extraData, (address, bytes4, bytes));
        return abi.decode(inner.call(abi.encodeWithSelector(innerCallbackFunction, response, innerExtraData)), (bytes));
    }
}

网关接口

合约返回的 URL 可以是任何模式,但本规范仅定义客户端应如何处理 HTTPS URL。

给定在 OffchainLookup 中返回的 URL 模板,要查询的 URL 是通过将 sender 替换为小写的带有 0x 前缀的十六进制格式的 sender 参数,并将 data 替换为带有 0x 前缀的十六进制格式的 callData 参数来组成的。

例如,如果合约在 OffchainLookup 中返回以下数据:

urls = ["https://example.com/gateway/{sender}/{data}.json"]
sender = "0xaabbccddeeaabbccddeeaabbccddeeaabbccddee"
callData = "0x00112233"

要查询的请求 URL 是 https://example.com/gateway/0xaabbccddeeaabbccddeeaabbccddeeaabbccddeeaabbccddee/0x00112233.json

如果 URL 模板包含 {data} 替换参数,则客户端必须在如上所述替换替换参数后发送 GET 请求。

如果 URL 模板不包含 {data} 替换参数,则客户端必须在如上所述替换替换参数后发送 POST 请求。POST 请求必须使用 application/json 的 Content-Type 发送,并且有效负载必须与以下架构匹配:

{
    "type": "object",
    "properties": {
        "data": {
            "type": "string",
            "description": "0x-prefixed hex string containing the `callData` from the contract"
        },
        "sender": {
            "type": "string",
            "description": "0x-prefixed hex string containing the `sender` parameter from the contract"
        }
    }
}

兼容的网关必须使用 application/json 的 Content-Type 进行响应,正文必须符合以下 JSON 架构:

{
    "type": "object",
    "properties": {
        "data": {
            "type": "string",
            "description: "0x-prefixed hex string containing the result data."
        }
    }
}

不成功的请求必须返回适当的 HTTP 状态代码 - 例如,如果此网关不支持 sender 地址,则返回 404,如果 callData 的格式无效,则返回 400,如果服务器遇到内部错误,则返回 500,依此类推。如果 4xx 或 5xx 响应的 Content-Type 是 application/json,则它必须符合以下 JSON 架构:

{
    "type": "object",
    "properties": {
        "message": {
            "type": "string",
            "description: "A human-readable error message."
        }
    }
}

例子

GET 请求

# 客户端返回一个 URL 模板 `https://example.com/gateway/{sender}/{data}.json`
# 请求
curl -D - https://example.com/gateway/0x226159d592E2b063810a10Ebf6dcbADA94Ed68b8/0xd5fa2b00.json

# 成功结果
    HTTP/2 200
    content-type: application/json; charset=UTF-8
    ...
    
    {"data": "0xdeadbeefdecafbad"}

# 错误结果
    HTTP/2 404
    content-type: application/json; charset=UTF-8
    ...

    {"message": "Gateway address not supported."}
}

POST 请求

# 客户端返回一个 URL 模板 `https://example.com/gateway/{sender}.json`
# 请求
curl -D - -X POST -H "Content-Type: application/json" --data '{"data":"0xd5fa2b00","sender":"0x226159d592E2b063810a10Ebf6dcbADA94Ed68b8"}' https://example.com/gateway/0x226159d592E2b063810a10Ebf6dcbADA94Ed68b8.json

# 成功结果
    HTTP/2 200
    content-type: application/json; charset=UTF-8
    ...
    
    {"data": "0xdeadbeefdecafbad"}

# 错误结果
    HTTP/2 404
    content-type: application/json; charset=UTF-8
    ...

    {"message": "Gateway address not supported."}
}

客户端必须支持 GET 和 POST 请求。网关可以根据需要实现其中一个或两个。

客户端查找协议

支持 CCIP read 的客户端必须使用以下过程进行合约调用:

  1. data 设置为要提供给合约的调用数据,并将 to 设置为要调用的合约的地址。
  2. 正常调用地址 to 处的合约函数,提供 data 作为输入数据。如果该函数返回成功结果,则将其返回给调用者并停止。
  3. 如果该函数返回 OffchainLookup 以外的错误,则以通常的方式将其返回给调用者。
  4. 否则,从 OffchainLookup 错误中解码 senderurlscallDatacallbackFunctionextraData 参数。
  5. 如果 sender 字段与被调用的合约的地址不匹配,则向调用者返回错误并停止。
  6. 通过将每个 sender 替换为小写的带有 0x 前缀的十六进制格式的 sender 参数,并将每个 data 替换为带有 0x 前缀的十六进制格式的 callData 参数,来构造请求 URL。客户端可以选择尝试哪些 URL 以及按什么顺序尝试,但应优先考虑列表中较早的 URL,而不是列表中较晚的 URL。
  7. 向请求 URL 发出 HTTP GET 请求。
  8. 如果步骤 (7) 中的响应代码在 400-499 范围内,则向调用者返回错误并停止。
  9. 如果步骤 (7) 中的响应代码在 500-599 范围内,则返回到步骤 (5) 并选择不同的 URL,如果没有其他 URL 可尝试,则停止。
  10. 否则,将 data 替换为对由 4 字节选择器 callbackFunction 指定的合约函数的 ABI 编码调用,提供从步骤 (7) 返回的数据和从步骤 (4) 返回的 extraData,然后返回到步骤 (1)。

客户端必须适当地处理 HTTP 状态代码,采用错误报告和重试的最佳实践。

客户端必须适当地处理内容类型不是 application/json 的 HTTP 4xx 和 5xx 错误响应;它们不得尝试将响应正文解析为 JSON。

此协议可能会导致同一合约请求多次查找。客户端必须对单个合约调用允许的查找次数实施限制,并且此限制应至少为 4。

客户端的查找协议用以下伪代码描述:

async function httpcall(urls, to, callData) {
    const args = {sender: to.toLowerCase(), data: callData.toLowerCase()};
    for(const url of urls) {
        const queryUrl = url.replace(/\{([^}]*)\}/g, (match, p1) => args[p1]);
        // First argument is URL to fetch, second is optional data for a POST request.
        // 第一个参数是要获取的 URL,第二个参数是 POST 请求的可选数据。
        const response = await fetch(queryUrl, url.includes('{data}') ? undefined : args);
        const result = await response.text();
        if(result.statusCode >= 400 && result.statusCode <= 499) {
            throw new Error(data.error.message);
        }
        if(result.statusCode >= 200 && result.statusCode <= 299) {
            return result;
        }
    }
}
async function durin_call(provider, to, data) {
    for(let i = 0; i < 4; i++) {
        try {
            return await provider.call(to, data);
        } catch(error) {
            if(error.code !== "CALL_EXCEPTION") {
                throw(error);
            }
            const {sender, urls, callData, callbackFunction, extraData} = error.data;
            if(sender !== to) {
                throw new Error("Cannot handle OffchainLookup raised inside nested call");
            }
            const result = httpcall(urls, to, callData);
            data = abi.encodeWithSelector(callbackFunction, result, extraData);
        }
    }
    throw new Error("Too many CCIP read redirects");
}

其中:

  • provider 是一个提供程序对象,它有助于以太坊区块链函数调用。
  • to 是要调用的合约的地址。
  • data 是合约的调用数据。

如果被调用的函数是标准合约函数,则该过程在初始调用后终止,返回与常规调用相同的结果。否则,将使用由 OffchainLookup 错误返回的 callData 调用来自 urls 的网关,并且期望返回有效响应。然后将响应和 extraData 传递给指定的回调函数。如果回调函数返回另一个 OffchainLookup 错误,则可以重复此过程。

使用 CCIP read 进行交易

虽然上面的规范是针对只读合约调用(例如,eth_call),但使用此方法发送需要链下数据的交易(例如,eth_sendTransactioneth_sendRawTransaction)很简单。虽然使用 eth_estimateGaseth_call “预检”交易,但接收到 OffchainLookup revert 操作的客户端可以按照上面 客户端查找协议 中描述的过程进行操作,并在最后一步中用交易替换调用。此功能非常适合诸如在链下证明数据支持下进行链上声明之类的应用程序。

词汇表

  • 客户端:一个进程,例如在 Web 浏览器中执行的 JavaScript 或后端服务,它希望查询区块链以获取数据。客户端了解如何使用 CCIP read 获取数据。
  • 合约:存在于以太坊或另一个区块链上的智能合约。
  • 网关:一种服务,通常通过 HTTPS 回答特定于应用程序的 CCIP read 查询。

理由

使用 revert 来传达调用信息

为了使链下数据查找按预期工作,客户端必须以某种方式知道函数依赖于此规范才能实现功能 - 例如,函数 ABI 中的说明符 - 否则,必须有一种方法让合约向客户端发出信号,表明需要从其他地方获取数据。

虽然在 ABI 中指定调用类型是一种可能的解决方案,但这使得改造现有接口以支持链下数据变得笨拙,并且要么导致合约具有与原始规范相同的名称和参数,但具有不同的返回数据 - 这将导致客户端不希望出现的解码错误 - 或者使用不同的名称复制每个需要支持链下数据的函数(例如,balanceOf -> offchainBalanceOf)。这两种解决方案都不是特别令人满意。

使用 revert 操作,并在 revert 数据中传递所需的信息,允许任何函数被改造以支持通过 CCIP read 进行查找,只要客户端理解规范即可,从而有助于将现有规范转换为使用链下数据。

将合约地址传递给网关服务

address 传递给网关是为了方便编写通用网关,从而减少合约作者提供自己的网关实现的负担。提供 address 允许网关对原始合约执行查找以获取帮助解析所需的信息,从而可以为一个实现相同接口的任意数量的合约运行一个网关。

extraData 参数的存在

extraData 允许原始合约函数将信息传递给后续调用。由于合约不是持久的,因此如果没有此数据,合约将没有来自先前调用的状态。除了允许在两个调用之间传播任意上下文信息之外,这还允许合约验证网关回答的查询实际上是合约最初请求的查询。

对网关接口使用 GET 和 POST 请求

使用 GET 请求,并将查询数据编码在 URL 中,可以最大限度地降低复杂性,并实现网关的完全静态实现 - 在某些应用程序中,网关可以只是 HTTP 服务器或具有文本文件中静态响应集的 IPFS 实例。

但是,URL 的大小限制为 2 千字节,这会给 CCIP read 的更复杂用途带来问题。因此,我们提供了一个使用 POST 数据的选项。这是根据合约的判断(通过 URL 模板的选择)进行的,以便在需要时保留仅使用 GET 运行静态网关的能力。

向后兼容性

不希望使用此规范的现有合约不受影响。客户端可以为所有合约调用添加对 CCIP read 的支持,而不会引入任何新的开销或不兼容性。

需要 CCIP read 的合约将无法与未实现此规范的客户端一起使用。尝试从不兼容的客户端调用这些合约将导致合约抛出一个传播给用户的异常。

安全注意事项

网关响应数据验证

为了防止恶意网关导致意外的副作用或错误的结果,合约必须在 extraData 参数中包含足够的信息,以使其能够验证网关响应的相关性和有效性。例如,如果合约根据提供给原始调用的 address 请求信息,则必须在 extraData 中包含该地址,以便回调可以验证网关是否没有提供对不同查询的答案。

合约还必须对网关返回的数据进行充分的验证,以确保其有效。所需的验证是特定于应用程序的,不能在全球范围内指定。示例包括验证 L2 或其他默克尔化状态的包含的默克尔证明,或验证受信任签名者对响应数据的签名。

客户端额外数据验证

为了防止恶意客户端在使用 CCIP read 进行交易时引起意外的影响,合约必须对回调中返回给它们的 extraData 进行适当的检查。对初始调用的输入数据执行的任何健全性/权限检查都必须在通过回调中的 extraData 字段传递的数据上重复进行。例如,如果交易只能由授权帐户执行,则必须在回调中完成该授权检查;在初始调用中执行该检查并将授权地址嵌入到 extraData 中是不够的。

HTTP 请求 和 指纹识别攻击

因为 CCIP read 会导致用户的浏览器向合约控制的地址发出 HTTP 请求,所以有可能被用来识别用户 - 例如,将他们的钱包地址与其 IP 地址相关联。

这方面的影响是特定于应用程序的;当用户解析 ENS 域名时对其进行指纹识别可能对隐私影响不大,因为攻击者不会了解用户的钱包地址,只会了解用户正在从给定的 IP 地址解析给定的 ENS 名称 - 他们也可以通过运行 DNS 服务器来了解这些信息。另一方面,当用户尝试交易以转移 NFT 时对其进行指纹识别可能会让攻击者获得识别用户钱包 IP 地址所需的一切。

为了最大限度地减少这方面的安全影响,我们提出以下建议:

  1. 客户端库应为客户端提供一个钩子来覆盖 CCIP read 调用 - 可以通过重写它们以使用代理 服务,或者完全拒绝它们。应该编写此机制或另一种机制,以便于将域添加到允许列表或阻止列表。
  2. 默认情况下,客户端库应禁用交易的 CCIP read(但不能禁用调用),并要求调用者显式启用此功能。应该可以在每个合约、每个域或全局基础上启用。
  3. 应用程序作者不应为合约调用(“视图”操作)提供“from”地址,其中调用可能会执行不受信任的代码(即,未经应用程序作者编写或信任的代码)。作为一项预防原则,除非作者确定不会执行任何攻击者确定的智能合约代码,否则最好完全不提供此参数。
  4. 负责获取用户信息(例如,通过查询代币合约)的钱包作者应确保禁用交易 的 CCIP read,并且不使用提供的“from”地址进行任何合约调用,或者代表其用户运行代理,重写所有 CCIP read 调用以通过代理进行,或者两者兼而有之。

我们鼓励客户端库作者和钱包作者不要默认禁用 CCIP read,因为许多应用程序可以通过此功能进行透明地增强,如果在观察到上述预防措施的情况下,此功能非常安全。

版权

版权和相关权利通过 CC0 放弃。

Citation

Please cite this document as:

Nick Johnson (@arachnid), "ERC-3668: CCIP Read—安全的链下数据检索," Ethereum Improvement Proposals, no. 3668, July 2020. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3668.