Skip to content

Commit

Permalink
Implement CCIP Read (#158)
Browse files Browse the repository at this point in the history
* Implement CCIP Read

* Remove extra function

* Improve Ethers.deploy/2

* Add tests for CCIP read

* Update Changelog

* Improve coverage
  • Loading branch information
alisinabh authored Dec 5, 2024
1 parent 13e47e9 commit 893e0cb
Show file tree
Hide file tree
Showing 14 changed files with 369 additions and 9 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### Enhancements

- Improve `Ethers.deploy/2` error handling
- Implement `Ethers.CcipRead` to support EIP-3668

## v0.5.5 (2024-12-03)

### Enhancements
Expand Down
1 change: 1 addition & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Config
4 changes: 3 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ import Config

config :ethereumex, url: "http://localhost:8545"

config :ethers, :ignore_error_consolidation?, true
config :ethers, ignore_error_consolidation?: true

config :ethers, ccip_req_opts: [plug: {Req.Test, Ethers.CcipReq}, retry: false]
10 changes: 5 additions & 5 deletions lib/ethers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,11 @@ defmodule Ethers do
def deploy(contract_module_or_binary, overrides \\ [])

def deploy(contract_module, overrides) when is_atom(contract_module) do
with true <- function_exported?(contract_module, :__contract_binary__, 0),
bin when not is_nil(bin) <- contract_module.__contract_binary__() do
deploy(bin, overrides)
else
_error ->
case contract_module.__contract_binary__() do
bin when is_binary(bin) ->
deploy(bin, overrides)

nil ->
{:error, :binary_not_found}
end
end
Expand Down
117 changes: 117 additions & 0 deletions lib/ethers/ccip_read.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
defmodule Ethers.CcipRead do
@moduledoc """
CCIP Read ([EIP-3668](https://eips.ethereum.org/EIPS/eip-3668)) implementation
NOTE: Currently supports URLs with "https" scheme only
"""

require Logger

alias Ethers.Contracts.CcipRead.Errors.OffchainLookup
alias Ethers.TxData
alias Ethers.Utils

@error_selector "0x556f1830"
@error_selector_bin Utils.hex_decode!(@error_selector)
@supported_schemas ["https"]

@doc """
Same as `Ethers.call/2` but will handle `Ethers.Contacts.CcipRead.Errors.OffchainLookup` errors
and performs an offchain lookup as per [EIP-3668](https://eips.ethereum.org/EIPS/eip-3668) specs.
## Options and Overrides
Accepts same options as `Ethers.call/2`
"""
@spec call(TxData.t(), Keyword.t()) :: {:ok, [term()] | term()} | {:error, term()}
def call(tx_data, opts) do
case Ethers.call(tx_data, opts) do
{:ok, result} ->
{:ok, result}

{:error, %_{} = error} ->
if offchain_lookup_error?(error) do
ccip_resolve(error, tx_data, opts)
else
{:error, error}
end

{:error, %{"data" => <<@error_selector, _::binary>> = error_data}} ->
with {:ok, decoded_error} <- Utils.hex_decode(error_data),
{:ok, lookup_error} <- OffchainLookup.decode(decoded_error) do
ccip_resolve(lookup_error, tx_data, opts)
end

{:error, reason} ->
{:error, reason}
end
end

defp ccip_resolve(error, tx_data, opts) do
with {:ok, data} <-
error.urls
|> Enum.filter(fn url ->
url |> String.downcase() |> String.starts_with?(@supported_schemas)
end)
|> resolve_first(error) do
data = ABI.TypeEncoder.encode([data, error.extra_data], [:bytes, :bytes])
tx_data = %{tx_data | data: Utils.hex_encode(error.callback_function <> data)}
Ethers.call(tx_data, opts)
end
end

defp resolve_first([], _), do: {:error, :ccip_read_failed}

defp resolve_first([url | rest], error) do
case do_resolve_single(url, error) do
{:ok, data} ->
{:ok, data}

{:error, reason} ->
Logger.error("CCIP READ: failed resolving #{url} error: #{inspect(reason)}")

resolve_first(rest, error)
end
end

defp do_resolve_single(url_template, error) do
sender = Ethers.Utils.hex_encode(error.sender)
data = Ethers.Utils.hex_encode(error.call_data)

req_opts =
if String.contains?(url_template, "{data}") do
[method: :get]
else
[method: :post, json: %{data: data, sender: sender}]
end

url = url_template |> String.replace("{sender}", sender) |> String.replace("{data}", data)
req_opts = req_opts |> Keyword.put(:url, url) |> Keyword.merge(ccip_req_opts())

Logger.debug("CCIP READ: trying #{url}")

case Req.request(req_opts) do
{:ok, %Req.Response{status: 200, body: %{"data" => data}}} ->
case Utils.hex_decode(data) do
{:ok, hex} -> {:ok, hex}
:error -> {:error, :hex_decode_failed}
end

{:ok, resp} ->
{:error, resp}

{:error, reason} ->
{:error, reason}
end
end

defp offchain_lookup_error?(%mod{}) do
mod.function_selector().method_id == @error_selector_bin
rescue
UndefinedFunctionError ->
false
end

defp ccip_req_opts do
Application.get_env(:ethers, :ccip_req_opts, [])
end
end
7 changes: 7 additions & 0 deletions lib/ethers/contracts/ccip_read.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule Ethers.Contracts.CcipRead do
@moduledoc """
CCIP Read ([EIP-3668](https://eips.ethereum.org/EIPS/eip-3668)) contract
"""

use Ethers.Contract, abi: :ccip_read
end
4 changes: 3 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ defmodule Ethers.MixProject do
{:ex_secp256k1, "~> 0.7.2", optional: true},
{:excoveralls, "~> 0.10", only: :test},
{:idna, "~> 6.1"},
{:jason, "~> 1.4"}
{:jason, "~> 1.4"},
{:plug, ">= 1.0.0", only: :test},
{:req, "~> 0.5"}
]
end

Expand Down
3 changes: 3 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"req": {:hex, :req, "0.5.1", "90584216d064389a4ff2d4279fe2c11ff6c812ab00fa01a9fb9d15457f65ba70", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7ea96a1a95388eb0fefa92d89466cdfedba24032794e5c1147d78ec90db7edca"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.1", "8afe0b6f3a9a677ada046cdd23e3f4c6399618b91a6122289324774961281e1e", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "90b8c2297bf7959cfa1c927b2881faad7bb0707183124955369991b76177a166"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
Expand Down
33 changes: 33 additions & 0 deletions priv/abi/ccip_read.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[
{
"inputs": [
{
"internalType": "address",
"name": "sender",
"type": "address"
},
{
"internalType": "string[]",
"name": "urls",
"type": "string[]"
},
{
"internalType": "bytes",
"name": "callData",
"type": "bytes"
},
{
"internalType": "bytes4",
"name": "callbackFunction",
"type": "bytes4"
},
{
"internalType": "bytes",
"name": "extraData",
"type": "bytes"
}
],
"name": "OffchainLookup",
"type": "error"
}
]
131 changes: 131 additions & 0 deletions test/ethers/ccip_read_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
defmodule Ethers.CcipReadTest do
use ExUnit.Case

import Ethers.TestHelpers

alias Ethers.CcipRead
alias Ethers.Contract.Test.CcipReadTestContract
alias Ethers.Utils

@from "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"

setup do
address = deploy(CcipReadTestContract, from: @from)
[address: address]
end

describe "call/2" do
test "returns successful result when no offchain lookup is needed", %{address: address} do
assert {:ok, "direct value"} =
CcipReadTestContract.get_direct_value()
|> CcipRead.call(to: address)
end

test "handles OffchainLookup error and performs offchain lookup", %{address: address} do
Req.Test.expect(Ethers.CcipReq, fn conn ->
assert ["ccip", sender, data] = conn.path_info

# Verify the request parameters
assert String.starts_with?(sender, "0x")
assert String.starts_with?(data, "0x")

Req.Test.json(conn, %{data: data})
end)

assert {:ok, 100} =
CcipReadTestContract.get_value(100)
|> CcipRead.call(to: address)
end

test "filters out non-https URLs from the lookup list", %{address: address} do
# The contract provides both https and non-https URLs
# Our implementation should only try the https ones
Req.Test.expect(Ethers.CcipReq, fn conn ->
assert conn.scheme == :https
assert ["ccip", _sender, data] = conn.path_info
Req.Test.json(conn, %{data: data})
end)

assert {:ok, 300} =
CcipReadTestContract.get_value(300)
|> CcipRead.call(to: address)
end

test "tries next URL when first URL fails", %{address: address} do
# First request fails
Req.Test.expect(Ethers.CcipReq, 2, fn conn ->
if conn.host == "example.com" do
conn
|> Plug.Conn.put_status(500)
|> Req.Test.json(%{data: "0x"})
else
# Second URL succeeds
Req.Test.json(conn, %{
data: ABI.TypeEncoder.encode([700], [{:uint, 256}]) |> Utils.hex_encode()
})
end
end)

assert {:ok, 700} =
CcipReadTestContract.get_value(400)
|> CcipRead.call(to: address)
end

test "returns error when all URLs fail", %{address: address} do
# Both URLs fail
Req.Test.stub(Ethers.CcipReq, fn conn ->
Plug.Conn.put_status(conn, 500)
|> Req.Test.text("Failed")
end)

assert {:error, :ccip_read_failed} =
CcipReadTestContract.get_value(500)
|> CcipRead.call(to: address)
end

test "returns error when response is not 200", %{address: address} do
Req.Test.stub(Ethers.CcipReq, fn conn ->
conn
|> Plug.Conn.put_status(404)
|> Req.Test.json(%{error: "Not found"})
end)

assert {:error, :ccip_read_failed} =
CcipReadTestContract.get_value(600)
|> CcipRead.call(to: address)
end

test "returns error when response body is invalid", %{address: address} do
Req.Test.stub(Ethers.CcipReq, fn conn ->
conn
|> Plug.Conn.put_status(200)
|> Req.Test.text("invalid json")
end)

assert {:error, :ccip_read_failed} =
CcipReadTestContract.get_value(700)
|> CcipRead.call(to: address)
end

test "returns error when hex decoding fails", %{address: address} do
Req.Test.stub(Ethers.CcipReq, fn conn ->
Req.Test.json(conn, %{data: "invalid hex"})
end)

assert {:error, :ccip_read_failed} =
CcipReadTestContract.get_value(800)
|> CcipRead.call(to: address)
end

test "returns original error when it's not an OffchainLookup error", %{address: address} do
assert {:error, %Ethers.Contract.Test.CcipReadTestContract.Errors.InvalidValue{}} =
CcipReadTestContract.get_value(0)
|> CcipRead.call(to: address)

# Sending value to a non-payable function should return the original error
assert {:error, %{"code" => 3}} =
CcipReadTestContract.get_value(1)
|> CcipRead.call(to: address, value: 1000)
end
end
end
4 changes: 3 additions & 1 deletion test/ethers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ defmodule EthersTest do
end

test "returns error if the module does not include the binary" do
assert {:error, :binary_not_found} = Ethers.deploy(NotFoundContract, from: @from)
assert_raise UndefinedFunctionError, fn ->
assert {:error, :binary_not_found} = Ethers.deploy(NotFoundContract, from: @from)
end

assert {:error, :binary_not_found} =
Ethers.deploy(Ethers.Contracts.ERC20, from: @from)
Expand Down
5 changes: 5 additions & 0 deletions test/support/contracts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ defmodule Ethers.Contract.Test.RevertContract do
@moduledoc false
use Ethers.Contract, abi_file: "tmp/revert_abi.json"
end

defmodule Ethers.Contract.Test.CcipReadTestContract do
@moduledoc false
use Ethers.Contract, abi_file: "tmp/ccip_read_abi.json"
end
Loading

0 comments on commit 893e0cb

Please sign in to comment.