diff --git a/CHANGELOG.md b/CHANGELOG.md index f31003a..fe13223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Enhancements + +- Add ERC-165 contract and behaviour +- Add `skip_docs` option for contract module doc and typespec generation + ## v0.4.3 (2024-04-05) ### Bug fixes diff --git a/README.md b/README.md index 274e517..f2dc4c9 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ Ethers already includes some of the well-known contract interface standards for Here is a list of them. - [ERC20](https://hexdocs.pm/ethers/Ethers.Contracts.ERC20.html) - The well know fungible token standard +- [ERC165](https://hexdocs.pm/ethers/Ethers.Contracts.ERC165.html) - Standard Interface detection - [ERC721](https://hexdocs.pm/ethers/Ethers.Contracts.ERC721.html) - Non-Fungible tokens (NFTs) standard - [ERC777](https://hexdocs.pm/ethers/Ethers.Contracts.ERC777.html) - Improved fungible token standard - [ERC1155](https://hexdocs.pm/ethers/Ethers.Contracts.ERC1155.html) - Multi-Token standard (Fungible, Non-Fungible or Semi-Fungible) diff --git a/lib/ethers/contract.ex b/lib/ethers/contract.ex index 152cb77..7ac7681 100644 --- a/lib/ethers/contract.ex +++ b/lib/ethers/contract.ex @@ -35,6 +35,10 @@ defmodule Ethers.Contract do - `abi`: Used to pass in the decoded (or even encoded json binay) ABI of contract. - `abi_file`: Used to pass in the file path to the json ABI of contract. - `default_address`: Default contract deployed address to include in the parameters. (Optional) + - `skip_docs`: Determines if Ethers should skip generating docs and typespecs. (Default: false) + - `true`: Skip docs and typespecs for all functions. + - `false`: Generate docs and typespecs for all functions. + - `[{function_name :: atom(), skip_docs :: boolean()}]`: Specify for each function. """ require Ethers.ContractHelpers @@ -79,6 +83,7 @@ defmodule Ethers.Contract do {:ok, abi} = read_abi(opts) contract_binary = maybe_read_contract_binary(opts) default_address = Keyword.get(opts, :default_address) + skip_docs = Keyword.get(opts, :skip_docs, false) function_selectors = ABI.parse_specification(abi, include_events?: true) @@ -100,22 +105,24 @@ defmodule Ethers.Contract do } end) + impl_opts = [skip_docs: skip_docs] + constructor_ast = function_selectors_with_meta |> Enum.find(@default_constructor, &(&1.type == :constructor)) - |> impl(module) + |> impl(module, impl_opts) functions_ast = function_selectors_with_meta |> Enum.filter(&(&1.type == :function and not is_nil(&1.function))) - |> Enum.map(&impl(&1, module)) + |> Enum.map(&impl(&1, module, impl_opts)) events_mod_name = Module.concat(module, EventFilters) events = function_selectors_with_meta |> Enum.filter(&(&1.type == :event)) - |> Enum.map(&impl(&1, module)) + |> Enum.map(&impl(&1, module, impl_opts)) events_module_ast = quote context: module do @@ -132,7 +139,7 @@ defmodule Ethers.Contract do error_modules_ast = function_selectors_with_meta |> Enum.filter(&(&1.type == :error)) - |> Enum.map(&impl(&1, module)) + |> Enum.map(&impl(&1, module, impl_opts)) errors_module_impl = errors_impl(function_selectors_with_meta, module) @@ -171,7 +178,7 @@ defmodule Ethers.Contract do ## Helpers - defp impl(%{type: :constructor, selectors: [selector]} = abi, mod) do + defp impl(%{type: :constructor, selectors: [selector]} = abi, mod, opts) do func_args = generate_arguments(mod, abi.arity, selector.input_names) func_input_types = @@ -179,16 +186,19 @@ defmodule Ethers.Contract do |> Enum.map(&Ethers.Types.to_elixir_type/1) quote context: mod, location: :keep do - @doc """ - Prepares contract constructor values for deployment. + if unquote(generate_docs?(:constructor, opts[:skip_docs])) do + @doc """ + Prepares contract constructor values for deployment. + + To deploy a contracts use `Ethers.deploy/2` and pass the result of this function as + `:encoded_constructor` option. - To deploy a contracts use `Ethers.deploy/2` and pass the result of this function as - `:encoded_constructor` option. + ## Parameters + #{unquote(document_types(selector.types, selector.input_names))} + """ + @spec constructor(unquote_splicing(func_input_types)) :: binary() + end - ## Parameters - #{unquote(document_types(selector.types, selector.input_names))} - """ - @spec constructor(unquote_splicing(func_input_types)) :: binary() def constructor(unquote_splicing(func_args)) do args = unquote(func_args) @@ -202,7 +212,7 @@ defmodule Ethers.Contract do end end - defp impl(%{type: :function} = abi, mod) do + defp impl(%{type: :function} = abi, mod, opts) do name = abi.function |> Macro.underscore() @@ -216,16 +226,19 @@ defmodule Ethers.Contract do func_input_types = generate_typespecs(abi.selectors) quote context: mod, location: :keep do - @doc """ - Prepares `#{unquote(human_signature(abi.selectors))}` call parameters on the contract. + if unquote(generate_docs?(name, opts[:skip_docs])) do + @doc """ + Prepares `#{unquote(human_signature(abi.selectors))}` call parameters on the contract. + + #{unquote(document_help_message(abi.selectors))} - #{unquote(document_help_message(abi.selectors))} + #{unquote(document_parameters(abi.selectors))} - #{unquote(document_parameters(abi.selectors))} + #{unquote(document_returns(abi.selectors))} + """ + @spec unquote(name)(unquote_splicing(func_input_types)) :: Ethers.TxData.t() + end - #{unquote(document_returns(abi.selectors))} - """ - @spec unquote(name)(unquote_splicing(func_input_types)) :: Ethers.TxData.t() def unquote(name)(unquote_splicing(func_args)) do {selector, raw_args} = find_selector!(unquote(Macro.escape(abi.selectors)), unquote(func_args)) @@ -241,7 +254,7 @@ defmodule Ethers.Contract do end end - defp impl(%{type: :event} = abi, mod) do + defp impl(%{type: :event} = abi, mod, opts) do name = abi.function |> Macro.underscore() @@ -254,17 +267,20 @@ defmodule Ethers.Contract do func_typespec = generate_event_typespecs(abi.selectors, abi.arity) quote context: mod, location: :keep do - @doc """ - Create event filter for `#{unquote(human_signature(abi.selectors))}` + if unquote(generate_docs?(name, opts[:skip_docs])) do + @doc """ + Create event filter for `#{unquote(human_signature(abi.selectors))}` + + For each indexed parameter you can either pass in the value you want to + filter or `nil` if you don't want to filter. - For each indexed parameter you can either pass in the value you want to - filter or `nil` if you don't want to filter. + #{unquote(document_parameters(abi.selectors))} - #{unquote(document_parameters(abi.selectors))} + #{unquote(document_returns(abi.selectors))} + """ + @spec unquote(name)(unquote_splicing(func_typespec)) :: Ethers.EventFilter.t() + end - #{unquote(document_returns(abi.selectors))} - """ - @spec unquote(name)(unquote_splicing(func_typespec)) :: Ethers.EventFilter.t() def unquote(name)(unquote_splicing(func_args)) do {selector, raw_args} = find_selector!(unquote(Macro.escape(abi.selectors)), unquote(func_args)) @@ -275,7 +291,7 @@ defmodule Ethers.Contract do end end - defp impl(%{type: :error, selectors: [selector_abi]} = abi, mod) do + defp impl(%{type: :error, selectors: [selector_abi]} = abi, mod, _opts) do error_module = Module.concat([mod, Errors, abi.function]) aggregated_arg_names = aggregate_input_names(abi.selectors) @@ -347,4 +363,16 @@ defmodule Ethers.Contract do defp error_mappings, do: unquote(error_mappings) end end + + defp generate_docs?(_name, true = _skip_docs), do: false + defp generate_docs?(_name, false = _skip_docs), do: true + defp generate_docs?(_name, nil = _skip_docs), do: true + + defp generate_docs?(name, skip_docs) do + case Keyword.get(skip_docs, name) do + nil -> true + false -> true + true -> false + end + end end diff --git a/lib/ethers/contracts/erc1155.ex b/lib/ethers/contracts/erc1155.ex index aa49387..83284df 100644 --- a/lib/ethers/contracts/erc1155.ex +++ b/lib/ethers/contracts/erc1155.ex @@ -2,8 +2,16 @@ defmodule Ethers.Contracts.ERC1155 do @moduledoc """ ERC1155 token interface - More info: https://ethereum.org/en/developers/docs/standards/tokens/erc-1155/ + More info: https://eips.ethereum.org/EIPS/eip-1155 """ use Ethers.Contract, abi: :erc1155 + + @behaviour Ethers.Contracts.ERC165 + + # ERC-165 Interface ID + @interface_id Ethers.Utils.hex_decode!("0xd9b67a26") + + @impl Ethers.Contracts.ERC165 + def erc165_interface_id, do: @interface_id end diff --git a/lib/ethers/contracts/erc165.ex b/lib/ethers/contracts/erc165.ex new file mode 100644 index 0000000..0c00f41 --- /dev/null +++ b/lib/ethers/contracts/erc165.ex @@ -0,0 +1,79 @@ +defmodule Ethers.Contracts.ERC165 do + @moduledoc """ + ERC-165 Standard Interface Detection + + More info: https://eips.ethereum.org/EIPS/eip-165 + + ## Modules as Interface IDs + + Contract modules can opt to implement EIP-165 behaviour so that their name can be used + directly with the `supports_interface/1` function in this module. See below example: + + ```elixir + defmodule MyEIP165CompatibleContract do + use Ethers.Contract, abi: ... + @behaviour Ethers.Contracts.ERC165 + + @impl true + def erc165_interface_id, do: Ethers.Utils.hex_decode("[interface_id]") + end + ``` + + Now module name can be used instead of interface_id and will have the same result. + + ```elixir + iex> Ethers.Contracts.ERC165.supports_interface("[interface_id]") == + Ethers.Contracts.ERC165.supports_interface(MyEIP165CompatibleContract) + true + ``` + """ + use Ethers.Contract, abi: :erc165, skip_docs: true + + @behaviour __MODULE__ + + @callback erc165_interface_id() :: <<_::32>> + + @interface_id Ethers.Utils.hex_decode!("0x01ffc9a7") + + defmodule NotERC165CompatibleError do + defexception [:message] + end + + @impl __MODULE__ + def erc165_interface_id, do: @interface_id + + @doc """ + Prepares `supportsInterface(bytes4 interfaceId)` call parameters on the contract. + + This function also accepts a module that implements the ERC165 behaviour as input. Example: + + ```elixir + iex> #{Macro.to_string(__MODULE__)}.supports_interface(Ethers.Contracts.ERC721) + #Ethers.TxData + ``` + + This function should only be called for result and never in a transaction on + its own. (Use Ethers.call/2) + + State mutability: view + + ## Function Parameter Types + + - interfaceId: `{:bytes, 4}` + + ## Return Types (when called with `Ethers.call/2`) + + - :bool + """ + @spec supports_interface(<<_::32>> | atom()) :: Ethers.TxData.t() + def supports_interface(module_or_interface_id) + + def supports_interface(module) when is_atom(module) do + supports_interface(module.erc165_interface_id()) + rescue + UndefinedFunctionError -> + reraise NotERC165CompatibleError, + "module #{module} does not implement ERC165 behaviour", + __STACKTRACE__ + end +end diff --git a/lib/ethers/contracts/erc721.ex b/lib/ethers/contracts/erc721.ex index d3dc58d..e65d9df 100644 --- a/lib/ethers/contracts/erc721.ex +++ b/lib/ethers/contracts/erc721.ex @@ -2,8 +2,16 @@ defmodule Ethers.Contracts.ERC721 do @moduledoc """ ERC721 token interface - More info: https://ethereum.org/en/developers/docs/standards/tokens/erc-721/ + More info: https://eips.ethereum.org/EIPS/eip-721 """ use Ethers.Contract, abi: :erc721 + + @behaviour Ethers.Contracts.ERC165 + + # ERC-165 Interface ID + @interface_id Ethers.Utils.hex_decode!("0x80ac58cd") + + @impl Ethers.Contracts.ERC165 + def erc165_interface_id, do: @interface_id end diff --git a/priv/abi/erc165.json b/priv/abi/erc165.json new file mode 100644 index 0000000..50dd916 --- /dev/null +++ b/priv/abi/erc165.json @@ -0,0 +1,23 @@ +[ + { + "inputs": + [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": + [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +]