diff --git a/CHANGELOG.md b/CHANGELOG.md index f34df27..93c3efc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config/prod.exs b/config/prod.exs index e69de29..becde76 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -0,0 +1 @@ +import Config diff --git a/config/test.exs b/config/test.exs index c855b21..d01e49a 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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] diff --git a/lib/ethers.ex b/lib/ethers.ex index d2f72f1..462b57e 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -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 diff --git a/lib/ethers/ccip_read.ex b/lib/ethers/ccip_read.ex new file mode 100644 index 0000000..991a481 --- /dev/null +++ b/lib/ethers/ccip_read.ex @@ -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 diff --git a/lib/ethers/contracts/ccip_read.ex b/lib/ethers/contracts/ccip_read.ex new file mode 100644 index 0000000..6d3fddf --- /dev/null +++ b/lib/ethers/contracts/ccip_read.ex @@ -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 diff --git a/mix.exs b/mix.exs index 5902690..77cdf39 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index a33ccc1..d0561f6 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/priv/abi/ccip_read.json b/priv/abi/ccip_read.json new file mode 100644 index 0000000..490940b --- /dev/null +++ b/priv/abi/ccip_read.json @@ -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" + } +] diff --git a/test/ethers/ccip_read_test.exs b/test/ethers/ccip_read_test.exs new file mode 100644 index 0000000..09a2534 --- /dev/null +++ b/test/ethers/ccip_read_test.exs @@ -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 diff --git a/test/ethers_test.exs b/test/ethers_test.exs index 54a8cea..8de5f2f 100644 --- a/test/ethers_test.exs +++ b/test/ethers_test.exs @@ -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) diff --git a/test/support/contracts.ex b/test/support/contracts.ex index 4afb568..43f00ab 100644 --- a/test/support/contracts.ex +++ b/test/support/contracts.ex @@ -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 diff --git a/test/support/contracts/ccip_read.sol b/test/support/contracts/ccip_read.sol new file mode 100644 index 0000000..5d42846 --- /dev/null +++ b/test/support/contracts/ccip_read.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +contract CcipReadTest { + error OffchainLookup( + address sender, + string[] urls, + bytes callData, + bytes4 callbackFunction, + bytes extraData + ); + + error InvalidValue(); + + function getValue(uint256 value) external view returns (uint256) { + string[] memory urls = new string[](3); + urls[0] = "invalid://example.com/ccip/{sender}/{data}"; + urls[1] = "https://example.com/ccip/{sender}/{data}"; + urls[2] = "https://backup.example.com/ccip"; + + if (value == 0) { + revert InvalidValue(); + } + + revert OffchainLookup( + address(this), + urls, + abi.encode(value), + this.handleResponse.selector, + bytes("testing") + ); + } + + function handleResponse(bytes calldata response, bytes calldata extraData) + external + pure + returns (uint256) + { + // Validate extraData + require(keccak256(abi.encodePacked(extraData)) == keccak256(bytes("testing"))); + + // Decode the response - in real contract you'd validate this + return abi.decode(response, (uint256)); + } + + // Helper function to test non-CCIP functionality + function getDirectValue() external pure returns (string memory) { + return "direct value"; + } +} diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..6a0af57 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1 @@ -ExUnit.start() +ExUnit.start(capture_log: true)