diff --git a/lib/archethic/contracts/contract.ex b/lib/archethic/contracts/contract.ex index 0989a6894..6e5aea4f2 100644 --- a/lib/archethic/contracts/contract.ex +++ b/lib/archethic/contracts/contract.ex @@ -14,7 +14,7 @@ defmodule Archethic.Contracts.Contract do alias Archethic.TransactionChain.TransactionData defstruct triggers: %{}, - version: {0, 0, 1}, + version: 0, conditions: %{ transaction: %Conditions{}, inherit: %Conditions{}, @@ -32,7 +32,7 @@ defmodule Archethic.Contracts.Contract do triggers: %{ trigger_type() => Macro.t() }, - version: {integer(), integer(), integer()}, + version: integer(), conditions: %{ transaction: Conditions.t(), inherit: Conditions.t(), diff --git a/lib/archethic/contracts/interpreter.ex b/lib/archethic/contracts/interpreter.ex index 9771faf50..270682b06 100644 --- a/lib/archethic/contracts/interpreter.ex +++ b/lib/archethic/contracts/interpreter.ex @@ -11,7 +11,7 @@ defmodule Archethic.Contracts.Interpreter do alias Archethic.TransactionChain.Transaction - @type version() :: {integer(), integer(), integer()} + @type version() :: integer() @doc """ Dispatch through the correct interpreter. @@ -19,15 +19,18 @@ defmodule Archethic.Contracts.Interpreter do """ @spec parse(code :: binary()) :: {:ok, Contract.t()} | {:error, String.t()} def parse(code) when is_binary(code) do - case version(code) do - {{0, 0, 1}, code_without_version} -> - Version0.parse(code_without_version) - - {version = {1, _, _}, code_without_version} -> - Version1.parse(code_without_version, version) - - _ -> - {:error, "@version not supported"} + case sanitize_code(code) do + {:ok, block} -> + case block do + {:__block__, [], [{:@, _, [{{:atom, "version"}, _, [version]}]} | rest]} -> + Version1.parse({:__block__, [], rest}, version) + + _ -> + Version0.parse(block) + end + + {:error, reason} -> + {:error, reason} end end @@ -42,55 +45,15 @@ defmodule Archethic.Contracts.Interpreter do |> Code.string_to_quoted(static_atoms_encoder: &atom_encoder/2) end - @doc """ - Determine from the code, the version to use. - Return the version & the code where the version has been removed. - (should be private, but there are unit tests) - """ - @spec version(String.t()) :: {version(), String.t()} | :error - def version(code) do - regex_opts = [capture: :all_but_first] - - version_attr_regex = ~r/^\s*@version\s+"(\S+)"/ - - if Regex.match?(~r/^\s*@version/, code) do - case Regex.run(version_attr_regex, code, regex_opts) do - nil -> - # there is a @version but syntax is invalid (probably the quotes missing) - :error - - [capture] -> - case Regex.run(semver_regex(), capture, regex_opts) do - nil -> - # there is a @version but semver syntax is wrong - :error - - ["0", "0", "0"] -> - # there is a @version but it's 0.0.0 - :error - - [major, minor, patch] -> - { - {String.to_integer(major), String.to_integer(minor), String.to_integer(patch)}, - Regex.replace(version_attr_regex, code, "") - } - end - end - else - # no @version at all - {{0, 0, 1}, code} - end - end - @doc """ Return true if the given conditions are valid on the given constants """ @spec valid_conditions?(version(), Conditions.t(), map()) :: bool() - def valid_conditions?({0, _, _}, conditions, constants) do + def valid_conditions?(0, conditions, constants) do Version0.valid_conditions?(conditions, constants) end - def valid_conditions?({1, _, _}, conditions, constants) do + def valid_conditions?(1, conditions, constants) do Version1.valid_conditions?(conditions, constants) end @@ -99,11 +62,11 @@ defmodule Archethic.Contracts.Interpreter do May return a new transaction or nil """ @spec execute_trigger(version(), Macro.t(), map()) :: Transaction.t() | nil - def execute_trigger({0, _, _}, ast, constants) do + def execute_trigger(0, ast, constants) do Version0.execute_trigger(ast, constants) end - def execute_trigger({1, _, _}, ast, constants) do + def execute_trigger(1, ast, constants) do Version1.execute_trigger(ast, constants) end @@ -126,8 +89,33 @@ defmodule Archethic.Contracts.Interpreter do end def format_error_reason(ast_node = {_, metadata, _}, reason) do - # FIXME: Macro.to_string will not work on all nodes due to {:atom, bin()} - do_format_error_reason(reason, Macro.to_string(ast_node), metadata) + node_msg = + try do + Macro.to_string(ast_node) + rescue + _ -> + # {:atom, _} is not an atom so it breaks the Macro.to_string/1 + # here we replace it with :_var_ + {sanified_ast, variables} = + Macro.traverse( + ast_node, + [], + fn node, acc -> {node, acc} end, + fn + {:atom, bin}, acc -> {:_var_, [bin | acc]} + node, acc -> {node, acc} + end + ) + + # then we will replace all instances of _var_ in the string with the binary + variables + |> Enum.reverse() + |> Enum.reduce(Macro.to_string(sanified_ast), fn variable, acc -> + String.replace(acc, "_var_", variable, global: false) + end) + end + + do_format_error_reason(reason, node_msg, metadata) end def format_error_reason({{:atom, _}, {_, metadata, _}}, reason) do @@ -165,9 +153,4 @@ defmodule Archethic.Contracts.Interpreter do {:ok, {:atom, atom}} end end - - # source: https://semver.org/ - defp semver_regex() do - ~r/(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/ - end end diff --git a/lib/archethic/contracts/interpreter/ast_helper.ex b/lib/archethic/contracts/interpreter/ast_helper.ex new file mode 100644 index 000000000..50733cfe7 --- /dev/null +++ b/lib/archethic/contracts/interpreter/ast_helper.ex @@ -0,0 +1,180 @@ +defmodule Archethic.Contracts.Interpreter.ASTHelper do + @moduledoc """ + Helper functions to manipulate AST + """ + + @doc """ + Return wether the given ast is a keyword list. + Remember that we convert all keywords to maps in the prewalk. + + iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("[]") + iex> ASTHelper.is_keyword_list?(ast) + true + + iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("[sum: 1, product: 10]") + iex> ASTHelper.is_keyword_list?(ast) + true + + iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("[1,2,3]") + iex> ASTHelper.is_keyword_list?(ast) + false + """ + @spec is_keyword_list?(Macro.t()) :: boolean() + def is_keyword_list?(ast) when is_list(ast) do + Enum.all?(ast, fn + {{:atom, bin}, _value} when is_binary(bin) -> + true + + _ -> + false + end) + end + + def is_keyword_list?(_), do: false + + @doc """ + Return wether the given ast is a map + + iex> ast = quote do: %{"sum" => 1, "product" => 10} + iex> ASTHelper.is_map?(ast) + true + """ + @spec is_map?(Macro.t()) :: boolean() + def is_map?({:%{}, _, _}), do: true + def is_map?(_), do: false + + @doc """ + Return wether the given ast is an integer + + iex> ast = quote do: 1 + iex> ASTHelper.is_integer?(ast) + true + """ + @spec is_integer?(Macro.t()) :: boolean() + def is_integer?(node), do: is_integer(node) + + @doc ~S""" + Return wether the given ast is an binary + + iex> ast = quote do: "hello" + iex> ASTHelper.is_binary?(ast) + true + + iex> _hello = "hello" + iex> ast = quote do: "#{_hello} world" + iex> ASTHelper.is_binary?(ast) + true + """ + @spec is_binary?(Macro.t()) :: boolean() + def is_binary?(node) when is_binary(node), do: true + def is_binary?({:<<>>, _, _}), do: true + def is_binary?(_), do: false + + @doc """ + Return wether the given ast is an float + + iex> ast= quote do: 1.0 + iex> ASTHelper.is_float?(ast) + true + """ + @spec is_float?(Macro.t()) :: boolean() + def is_float?(node), do: is_float(node) + + @doc """ + Return wether the given ast is a a list + + iex> ast = quote do: [1, 2] + iex> ASTHelper.is_list?(ast) + true + """ + @spec is_list?(Macro.t()) :: boolean() + def is_list?(node), do: is_list(node) + + @doc """ + Return wether the given ast is a variable or a function call. + Useful because we pretty much accept this everywhere + """ + @spec is_variable_or_function_call?(Macro.t()) :: boolean() + def is_variable_or_function_call?(ast) do + is_variable?(ast) || is_function_call?(ast) + end + + @doc """ + Return wether the given ast is a variable. + Variable are transformed into {:get_in, _, _} in our prewalks + + TODO: find a elegant way to test this. + """ + @spec is_variable?(Macro.t()) :: boolean() + def is_variable?({:get_in, _, _}), do: true + def is_variable?(_), do: false + + @doc """ + Return wether the given ast is a function call or not + + iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("hello(12)") + iex> ASTHelper.is_function_call?(ast) + true + + iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("hello()") + iex> ASTHelper.is_function_call?(ast) + true + + iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("Module.hello()") + iex> ASTHelper.is_function_call?(ast) + true + + iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("hello") + iex> ASTHelper.is_function_call?(ast) + false + """ + @spec is_function_call?(Macro.t()) :: boolean() + def is_function_call?({{:atom, _}, _, list}) when is_list(list), do: true + def is_function_call?({{:., _, [{:__aliases__, _, [_]}, _]}, _, _}), do: true + def is_function_call?(_), do: false + + @doc """ + Convert a keyword AST into a map AST + + iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("[sum: 1, product: 10]") + iex> Macro.to_string(ASTHelper.keyword_to_map(ast)) + ~s(%{"sum" => 1, "product" => 10}) + """ + @spec keyword_to_map(Macro.t()) :: Macro.t() + def keyword_to_map(ast) do + proplist = + Enum.map(ast, fn {{:atom, atom_name}, value} -> + {atom_name, value} + end) + + {:%{}, [], proplist} + end + + @doc """ + Maybe wrap the AST in a block if it's not already a block + + We use this because do..end blocks have 2 forms: + - when there is a single expression in the block + ex: + {:if, _, _} (1) + - when there are multiple expression in the block + ex: + {:__block__, [], [ + {:if, _, _}, + {:if, _, _} + ]} + + We use it: + - in if/else, in order to always have a __block__ to pattern match + - in the ActionIntepreter's prewalk because we discard completely the rest of the code except the do..end block. + If we don't wrap in a block and the code is a single expression, it would be automatically whitelisted. + + iex> ASTHelper.wrap_in_block({:if, [], [true, [do: 1, else: 2]]}) + iex> {:__block__, [], [{:if, [], [true, [do: 1, else: 2]]}]} + + iex> ASTHelper.wrap_in_block({:__block__, [], [{:if, [], [true, [do: 1, else: 2]]}]}) + iex> {:__block__, [], [{:if, [], [true, [do: 1, else: 2]]}]} + """ + def wrap_in_block(ast = {:__block__, _, _}), do: ast + def wrap_in_block(ast), do: {:__block__, [], [ast]} +end diff --git a/lib/archethic/contracts/interpreter/version0.ex b/lib/archethic/contracts/interpreter/version0.ex index 0e6d5a98c..d8e366f3f 100644 --- a/lib/archethic/contracts/interpreter/version0.ex +++ b/lib/archethic/contracts/interpreter/version0.ex @@ -19,7 +19,7 @@ defmodule Archethic.Contracts.Interpreter.Version0 do ## Examples - iex> Version0.parse(" + iex> {:ok, ast} = Interpreter.sanitize_code(" ...> condition transaction: [ ...> content: regex_match?(\"^Mr.Y|Mr.X{1}$\"), ...> origin_family: biometric @@ -44,6 +44,7 @@ defmodule Archethic.Contracts.Interpreter.Version0 do ...> set_content \"uco price changed\" ...> end ...> ") + ...> Version0.parse(ast) {:ok, %Contract{ conditions: %{ @@ -151,32 +152,28 @@ defmodule Archethic.Contracts.Interpreter.Version0 do Returns an error when there are invalid trigger options - iex> Version0.parse(" + iex> {:ok, ast} = Interpreter.sanitize_code(" ...> actions triggered_by: datetime, at: 0000000 do ...> end ...> ") + ...> Version0.parse(ast) {:error, "invalid datetime's trigger"} Returns an error when a invalid term is provided - iex> Version0.parse(" + iex> {:ok, ast} = Interpreter.sanitize_code(" ...> actions triggered_by: transaction do ...> System.user_home ...> end ...> ") + ...> Version0.parse(ast) {:error, "unexpected term - System - L2"} """ - @spec parse(code :: binary()) :: {:ok, Contract.t()} | {:error, reason :: binary()} - def parse(code) when is_binary(code) do - with {:ok, ast} <- Interpreter.sanitize_code(code), - {:ok, contract} <- parse_contract(ast, %Contract{}) do - {:ok, %{contract | version: {0, 0, 1}}} - else - {:error, {meta, {_, info}, token}} -> - {:error, Interpreter.format_error_reason({token, meta, []}, info)} - - {:error, {meta, info, token}} -> - {:error, Interpreter.format_error_reason({token, meta, []}, info)} + @spec parse(ast :: Macro.t()) :: {:ok, Contract.t()} | {:error, reason :: binary()} + def parse(ast) do + case parse_contract(ast, %Contract{}) do + {:ok, contract} -> + {:ok, %{contract | version: 0}} {:error, {:unexpected_term, ast}} -> {:error, Interpreter.format_error_reason(ast, "unexpected term")} diff --git a/lib/archethic/contracts/interpreter/version0/condition_interpreter.ex b/lib/archethic/contracts/interpreter/version0/condition_interpreter.ex index 5ca23440f..d05f2a9b1 100644 --- a/lib/archethic/contracts/interpreter/version0/condition_interpreter.ex +++ b/lib/archethic/contracts/interpreter/version0/condition_interpreter.ex @@ -421,11 +421,7 @@ defmodule Archethic.Contracts.Interpreter.Version0.ConditionInterpreter do defp do_aggregate_condition(condition, subject_scope, subject) when is_list(condition) do {:==, [], [ - {:get_in, [], - [ - {:scope, [], nil}, - [subject_scope, subject] - ]}, + {:get_in, [], [{:scope, [], nil}, [subject_scope, subject]]}, condition ]} end diff --git a/lib/archethic/contracts/interpreter/version0/library.ex b/lib/archethic/contracts/interpreter/version0/library.ex index 90473a27f..3011cc2fd 100644 --- a/lib/archethic/contracts/interpreter/version0/library.ex +++ b/lib/archethic/contracts/interpreter/version0/library.ex @@ -237,7 +237,7 @@ defmodule Archethic.Contracts.Interpreter.Version0.Library do @doc """ Provide a token id which uniquely identify the token base on it's properties and genesis address. """ - @spec get_token_id(binary()) :: {:error, binary()} | {:ok, binary()} + @spec get_token_id(binary()) :: {:error, binary()} | binary() def get_token_id(address) do address = UtilsInterpreter.get_address(address, :get_token_id) t1 = Task.async(fn -> Archethic.fetch_genesis_address_remotely(address) end) diff --git a/lib/archethic/contracts/interpreter/version1.ex b/lib/archethic/contracts/interpreter/version1.ex index 71a1c9785..75aff6d86 100644 --- a/lib/archethic/contracts/interpreter/version1.ex +++ b/lib/archethic/contracts/interpreter/version1.ex @@ -3,16 +3,27 @@ defmodule Archethic.Contracts.Interpreter.Version1 do alias Archethic.Contracts.Contract alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.Interpreter + + alias __MODULE__.ActionInterpreter + alias __MODULE__.ConditionInterpreter + alias __MODULE__.ConditionValidator alias Archethic.TransactionChain.Transaction @doc """ Parse the code and return the parsed contract. """ - @spec parse(binary(), {integer(), integer(), integer()}) :: + @spec parse(Macro.t(), integer()) :: {:ok, Contract.t()} | {:error, String.t()} - def parse(code, {1, 0, 0}) when is_binary(code) do - {:ok, %Contract{version: {1, 0, 0}}} + def parse(ast, version = 1) do + case parse_contract(ast, %Contract{}) do + {:ok, contract} -> + {:ok, %{contract | version: version}} + + {:error, node, reason} -> + {:error, Interpreter.format_error_reason(node, reason)} + end end def parse(_, _), do: {:error, "@version not supported"} @@ -21,8 +32,8 @@ defmodule Archethic.Contracts.Interpreter.Version1 do Return true if the given conditions are valid on the given constants """ @spec valid_conditions?(Conditions.t(), map()) :: bool() - def valid_conditions?(_conditions, _constants) do - false + def valid_conditions?(conditions, constants) do + ConditionValidator.valid_conditions?(conditions, constants) end @doc """ @@ -30,7 +41,57 @@ defmodule Archethic.Contracts.Interpreter.Version1 do May return a new transaction or nil """ @spec execute_trigger(Macro.t(), map()) :: Transaction.t() | nil - def execute_trigger(_ast, _constants) do - nil + def execute_trigger(ast, constants \\ %{}) do + ActionInterpreter.execute(ast, constants) + end + + # ------------------------------------------------------------ + # _ _ + # _ __ _ __(___ ____ _| |_ ___ + # | '_ \| '__| \ \ / / _` | __/ _ \ + # | |_) | | | |\ V | (_| | || __/ + # | .__/|_| |_| \_/ \__,_|\__\___| + # |_| + # ------------------------------------------------------------ + defp parse_contract({:__block__, _, ast}, contract) do + parse_ast_block(ast, contract) + end + + defp parse_contract(ast, contract) do + parse_ast(ast, contract) + end + + defp parse_ast_block([ast | rest], contract) do + case parse_ast(ast, contract) do + {:ok, contract} -> + parse_ast_block(rest, contract) + + {:error, _, _} = e -> + e + end end + + defp parse_ast_block([], contract), do: {:ok, contract} + + defp parse_ast(ast = {{:atom, "condition"}, _, _}, contract) do + case ConditionInterpreter.parse(ast) do + {:ok, condition_type, condition} -> + {:ok, Contract.add_condition(contract, condition_type, condition)} + + {:error, _, _} = e -> + e + end + end + + defp parse_ast(ast = {{:atom, "actions"}, _, _}, contract) do + case ActionInterpreter.parse(ast) do + {:ok, trigger_type, actions} -> + {:ok, Contract.add_trigger(contract, trigger_type, actions)} + + {:error, _, _} = e -> + e + end + end + + defp parse_ast(ast, _), do: {:error, ast, "unexpected term"} end diff --git a/lib/archethic/contracts/interpreter/version1/action_interpreter.ex b/lib/archethic/contracts/interpreter/version1/action_interpreter.ex new file mode 100644 index 000000000..20f7593a2 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/action_interpreter.ex @@ -0,0 +1,215 @@ +defmodule Archethic.Contracts.Interpreter.Version1.ActionInterpreter do + @moduledoc false + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + alias Archethic.Contracts.Interpreter.Version1.CommonInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library + alias Archethic.Contracts.Interpreter.Version1.Scope + + @doc """ + Parse the given node and return the trigger and the actions block. + """ + @spec parse(any()) :: {:ok, atom(), any()} | {:error, any(), String.t()} + def parse({{:atom, "actions"}, _, [keyword, [do: block]]}) do + trigger_type = extract_trigger(keyword) + + # We only parse the do..end block with the macro.traverse + # this help us keep a clean accumulator that is used only for scoping. + actions_ast = parse_block(AST.wrap_in_block(block)) + + {:ok, trigger_type, actions_ast} + catch + {:error, node} -> + {:error, node, "unexpected term"} + + {:error, node, reason} -> + {:error, node, reason} + end + + def parse(node) do + {:error, node, "unexpected term"} + end + + @doc """ + Execute actions code and returns either the next transaction or nil + """ + @spec execute(any(), map()) :: Transaction.t() | nil + def execute(ast, constants \\ %{}) do + :ok = Macro.validate(ast) + + # initiate a transaction that will be use by the "Contract" module + next_tx = %Transaction{data: %TransactionData{}} + + # we use the process dictionary to store our scope + # because it is mutable. + # + # constants should already contains the global variables: + # - "contract": current contract transaction + # - "transaction": the incoming transaction (when trigger=transaction) + Scope.init(Map.put(constants, "next_transaction", next_tx)) + + # we can ignore the result & binding + # - `result` would be the returned value of the AST + # - `binding` would be the variables (none since everything is written to the process dictionary) + {_result, _binding} = Code.eval_quoted(ast) + + # look at the next_transaction from the scope + # return nil if it did not change + case Scope.read_global(["next_transaction"]) do + ^next_tx -> nil + result_next_transaction -> result_next_transaction + end + end + + # ---------------------------------------------------------------------- + # _ _ + # _ __ _ __(___ ____ _| |_ ___ + # | '_ \| '__| \ \ / / _` | __/ _ \ + # | |_) | | | |\ V | (_| | || __/ + # | .__/|_| |_| \_/ \__,_|\__\___| + # |_| + # ---------------------------------------------------------------------- + defp extract_trigger([{{:atom, "triggered_by"}, {{:atom, "transaction"}, _, nil}}]) do + :transaction + end + + defp extract_trigger([{{:atom, "triggered_by"}, {{:atom, "oracle"}, _, nil}}]) do + :oracle + end + + defp extract_trigger([ + {{:atom, "triggered_by"}, {{:atom, "interval"}, _, nil}}, + {{:atom, "at"}, cron_interval} + ]) + when is_binary(cron_interval) do + {:interval, cron_interval} + end + + defp extract_trigger([ + {{:atom, "triggered_by"}, {{:atom, "datetime"}, _, nil}}, + {{:atom, "at"}, timestamp} + ]) + when is_number(timestamp) do + datetime = DateTime.from_unix!(timestamp) + {:datetime, datetime} + end + + defp parse_block(ast) do + # here the accumulator is an list of parent scopes & current scope + # where we can access variables from all of them + # `acc = [ref1]` means read variable from scope.ref1 or scope + # `acc = [ref1, ref2]` means read variable from scope.ref1.ref2 or scope.ref1 or scope + acc = [] + + {new_ast, _} = + Macro.traverse( + ast, + acc, + fn node, acc -> + prewalk(node, acc) + end, + fn node, acc -> + postwalk(node, acc) + end + ) + + new_ast + end + + # ---------------------------------------------------------------------- + # _ _ + # _ __ _ __ _____ ____ _| | | __ + # | '_ \| '__/ _ \ \ /\ / / _` | | |/ / + # | |_) | | | __/\ V V | (_| | | < + # | .__/|_| \___| \_/\_/ \__,_|_|_|\_\ + # |_| + # ---------------------------------------------------------------------- + # autorize the use of Contract module + defp prewalk( + node = {:__aliases__, _, [atom: "Contract"]}, + acc + ) do + {node, acc} + end + + defp prewalk( + node, + acc + ) do + CommonInterpreter.prewalk(node, acc) + end + + # ---------------------------------------------------------------------- + # _ _ _ + # _ __ ___ ___| |___ ____ _| | | __ + # | '_ \ / _ \/ __| __\ \ /\ / / _` | | |/ / + # | |_) | (_) \__ | |_ \ V V | (_| | | < + # | .__/ \___/|___/\__| \_/\_/ \__,_|_|_|\_\ + # |_| + # ---------------------------------------------------------------------- + # Contract.get_calls() => Contract.get_calls(contract.address) + defp postwalk( + _node = + {{:., _meta, [{:__aliases__, _, [atom: "Contract"]}, {:atom, "get_calls"}]}, _, []}, + acc + ) do + # contract is one of the "magic" variables that we expose to the user's code + # it is bound in the root scope + new_node = + quote do + Archethic.Contracts.Interpreter.Version1.Library.Contract.get_calls( + Scope.read_global(["contract", "address"]) + ) + end + + {new_node, acc} + end + + # handle the Contract module + # here we have 2 things to do: + # - feed the `next_transaction` as the 1st function parameter + # - update the `next_transaction` in scope + defp postwalk( + node = + {{:., _meta, [{:__aliases__, _, [atom: "Contract"]}, {:atom, function_name}]}, _, args}, + acc + ) do + absolute_module_atom = Archethic.Contracts.Interpreter.Version1.Library.Contract + + # check function exists + unless Library.function_exists?(absolute_module_atom, function_name) do + throw({:error, node, "unknown function: Contract.#{function_name}"}) + end + + # check function is available with given arity + # (we add 1 to arity because we add the contract as 1st argument implicitely) + unless Library.function_exists?(absolute_module_atom, function_name, length(args) + 1) do + throw({:error, node, "invalid arity for function Contract.#{function_name}"}) + end + + function_atom = String.to_existing_atom(function_name) + + # check the type of the args + unless absolute_module_atom.check_types(function_atom, args) do + throw({:error, node, "invalid arguments for function Contract.#{function_name}"}) + end + + new_node = + quote do + Scope.update_global( + ["next_transaction"], + &apply(unquote(absolute_module_atom), unquote(function_atom), [&1 | unquote(args)]) + ) + end + + {new_node, acc} + end + + # --------------- catch all ------------------- + defp postwalk(node, acc) do + CommonInterpreter.postwalk(node, acc) + end +end diff --git a/lib/archethic/contracts/interpreter/version1/common_interpreter.ex b/lib/archethic/contracts/interpreter/version1/common_interpreter.ex new file mode 100644 index 000000000..e218591e4 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/common_interpreter.ex @@ -0,0 +1,367 @@ +defmodule Archethic.Contracts.Interpreter.Version1.CommonInterpreter do + @moduledoc """ + The prewalk and postwalk functions receive an `acc` for convenience. + They should see it as an opaque variable and just forward it. + + This way we can use this interpreter inside other interpreters, and each deal with the acc how they want to. + """ + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + alias Archethic.Contracts.Interpreter.Version1.Library + alias Archethic.Contracts.Interpreter.Version1.Scope + + @modules_whitelisted Library.list_common_modules() + + # ---------------------------------------------------------------------- + # _ _ + # _ __ _ __ _____ ____ _| | | __ + # | '_ \| '__/ _ \ \ /\ / / _` | | |/ / + # | |_) | | | __/\ V V | (_| | | < + # | .__/|_| \___| \_/\_/ \__,_|_|_|\_\ + # |_| + # ---------------------------------------------------------------------- + # the atom marker (set by sanitize_code) + def prewalk(:atom, acc), do: {:atom, acc} + + # expressions + def prewalk(node = {:+, _, _}, acc), do: {node, acc} + def prewalk(node = {:-, _, _}, acc), do: {node, acc} + def prewalk(node = {:/, _, _}, acc), do: {node, acc} + def prewalk(node = {:*, _, _}, acc), do: {node, acc} + def prewalk(node = {:>, _, _}, acc), do: {node, acc} + def prewalk(node = {:<, _, _}, acc), do: {node, acc} + def prewalk(node = {:>=, _, _}, acc), do: {node, acc} + def prewalk(node = {:<=, _, _}, acc), do: {node, acc} + def prewalk(node = {:|>, _, _}, acc), do: {node, acc} + def prewalk(node = {:==, _, _}, acc), do: {node, acc} + def prewalk(node = {:!=, _, _}, acc), do: {node, acc} + def prewalk(node = {:++, _, _}, acc), do: {node, acc} + def prewalk(node = {:!, _, _}, acc), do: {node, acc} + def prewalk(node = {:&&, _, _}, acc), do: {node, acc} + def prewalk(node = {:||, _, _}, acc), do: {node, acc} + + # ranges + def prewalk(node = {:.., _, _}, acc), do: {node, acc} + + # enter block == new scope + def prewalk( + _node = {:__block__, meta, expressions}, + acc + ) do + # create a "ref" for each block + # references are not AST valid, so we convert them to binary + # (ps: charlist is a slow alternative because the Macro.traverse will step into every character) + ref = :erlang.list_to_binary(:erlang.ref_to_list(make_ref())) + new_acc = acc ++ [ref] + + # create the child scope in parent scope + create_scope_ast = + quote do + Scope.create(unquote(new_acc)) + end + + { + {:__block__, meta, [create_scope_ast | expressions]}, + new_acc + } + end + + # blocks + def prewalk(node = {:do, _}, acc), do: {node, acc} + def prewalk(node = :do, acc), do: {node, acc} + + # literals + # it is fine allowing atoms since the users can't create them (this avoid whitelisting functions/modules we use in the prewalk) + def prewalk(node, acc) when is_atom(node), do: {node, acc} + def prewalk(node, acc) when is_boolean(node), do: {node, acc} + def prewalk(node, acc) when is_number(node), do: {node, acc} + def prewalk(node, acc) when is_binary(node), do: {node, acc} + + # converts all keywords to maps + def prewalk(node, acc) when is_list(node) do + if AST.is_keyword_list?(node) do + new_node = AST.keyword_to_map(node) + {new_node, acc} + else + {node, acc} + end + end + + # pairs (used in maps) + def prewalk(node = {key, _}, acc) when is_binary(key), do: {node, acc} + + # maps (required because we create maps for each scope in the ActionInterpreter's prewalk) + def prewalk(node = {:%{}, _, _}, acc), do: {node, acc} + + # variables + def prewalk(node = {{:atom, var_name}, _, nil}, acc) when is_binary(var_name), do: {node, acc} + def prewalk(node = {:atom, var_name}, acc) when is_binary(var_name), do: {node, acc} + + # module call + def prewalk(node = {{:., _, [{:__aliases__, _, _}, _]}, _, _}, acc), do: {node, acc} + def prewalk(node = {:., _, [{:__aliases__, _, _}, _]}, acc), do: {node, acc} + + # whitelisted modules + def prewalk(node = {:__aliases__, _, [atom: module_name]}, acc) + when module_name in @modules_whitelisted, + do: {node, acc} + + # internal modules (Process/Scope/Kernel) + def prewalk(node = {:__aliases__, _, [atom]}, acc) when is_atom(atom), do: {node, acc} + + # internal functions + def prewalk(node = {:put_in, _, _}, acc), do: {node, acc} + def prewalk(node = {:get_in, _, _}, acc), do: {node, acc} + def prewalk(node = {:update_in, _, _}, acc), do: {node, acc} + + # if + def prewalk(_node = {:if, meta, [predicate, do_else_keyword]}, acc) do + # wrap the do/else blocks + do_else_keyword = + Enum.map(do_else_keyword, fn {key, value} -> + {key, AST.wrap_in_block(value)} + end) + + new_node = {:if, meta, [predicate, do_else_keyword]} + {new_node, acc} + end + + # else (no wrap needed since it's done in the if) + def prewalk(node = {:else, _}, acc), do: {node, acc} + def prewalk(node = :else, acc), do: {node, acc} + + # string interpolation + def prewalk(node = {{:., _, [Kernel, :to_string]}, _, _}, acc), do: {node, acc} + def prewalk(node = {:., _, [Kernel, :to_string]}, acc), do: {node, acc} + def prewalk(node = {:binary, _, nil}, acc), do: {node, acc} + def prewalk(node = {:<<>>, _, _}, acc), do: {node, acc} + def prewalk(node = {:"::", _, [{{:., _, [Kernel, :to_string]}, _, _}, _]}, acc), do: {node, acc} + + # forbid "if" as an expression + def prewalk( + node = {:=, _, [_, {:if, _, _}]}, + _acc + ) do + throw({:error, node, "Forbidden to use if as an expression."}) + end + + # forbid "for" as an expression + def prewalk( + node = + {:=, _, + [ + {{:atom, _}, _, nil}, + {{:atom, "for"}, _, _} + ]}, + _acc + ) do + throw({:error, node, "Forbidden to use for as an expression."}) + end + + # whitelist assignation & write them to scope + # this is done in the prewalk because it must be done before the "variable are read from scope" step + def prewalk( + _node = {:=, _, [{{:atom, var_name}, _, nil}, value]}, + acc + ) do + new_node = + quote do + Scope.write_cascade(unquote(acc), unquote(var_name), unquote(value)) + end + + { + new_node, + acc + } + end + + # Dot access non-nested (x.y) + def prewalk(_node = {{:., _, [{{:atom, map_name}, _, nil}, {:atom, key_name}]}, _, _}, acc) do + new_node = + quote do + Scope.read(unquote(acc), unquote(map_name), unquote(key_name)) + end + + {new_node, acc} + end + + # Dot access nested (x.y.z) + def prewalk({{:., _, [first_arg = {{:., _, _}, _, _}, {:atom, key_name}]}, _, []}, acc) do + {nested, new_acc} = prewalk(first_arg, acc) + + new_node = + quote do + get_in(unquote(nested), [unquote(key_name)]) + end + + {new_node, new_acc} + end + + # Map access non-nested (x[y]) + def prewalk( + _node = {{:., _, [Access, :get]}, _, [{{:atom, map_name}, _, nil}, accessor]}, + acc + ) do + # accessor can be a variable, a function call, a dot access, a string + new_node = + quote do + Scope.read(unquote(acc), unquote(map_name), unquote(accessor)) + end + + {new_node, acc} + end + + # Map access nested (x[y][z]) + def prewalk( + _node = {{:., _, [Access, :get]}, _, [first_arg = {{:., _, _}, _, _}, accessor]}, + acc + ) do + {nested, new_acc} = prewalk(first_arg, acc) + + new_node = + quote do + get_in(unquote(nested), [unquote(accessor)]) + end + + {new_node, new_acc} + end + + # for var in list + def prewalk( + _node = + {{:atom, "for"}, meta, + [ + {:in, _, + [ + {{:atom, var_name}, _, nil}, + list + ]}, + [do: block] + ]}, + acc + ) do + ast = + {{:atom, "for"}, meta, + [ + # we change the "var in list" to "var: list" (which will be automatically converted to %{var => list}) + # to avoid the "var" interpreted as a variable (which would have been converted to get_in/2) + [{{:atom, var_name}, list}], + # wrap in a block to be able to pattern match it to create a scope + [do: AST.wrap_in_block(block)] + ]} + + {ast, acc} + end + + # blacklist rest + def prewalk(node, _acc), do: throw({:error, node, "unexpected term"}) + + # ---------------------------------------------------------------------- + # _ _ _ + # _ __ ___ ___| |___ ____ _| | | __ + # | '_ \ / _ \/ __| __\ \ /\ / / _` | | |/ / + # | |_) | (_) \__ | |_ \ V V | (_| | | < + # | .__/ \___/|___/\__| \_/\_/ \__,_|_|_|\_\ + # |_| + # ---------------------------------------------------------------------- + # exit block == set parent scope + def postwalk( + node = {:__block__, _, _}, + acc + ) do + {node, List.delete_at(acc, -1)} + end + + # common modules call + def postwalk( + node = + {{:., meta, [{:__aliases__, _, [atom: module_name]}, {:atom, function_name}]}, _, args}, + acc + ) + when module_name in @modules_whitelisted do + absolute_module_atom = + Code.ensure_loaded!( + String.to_existing_atom( + "Elixir.Archethic.Contracts.Interpreter.Version1.Library.Common.#{module_name}" + ) + ) + + # check function exists + unless Library.function_exists?(absolute_module_atom, function_name) do + throw({:error, node, "unknown function: #{module_name}.#{function_name}"}) + end + + # check function is available with given arity + unless Library.function_exists?(absolute_module_atom, function_name, length(args)) do + throw({:error, node, "invalid arity for function #{module_name}.#{function_name}"}) + end + + module_atom = String.to_existing_atom(module_name) + function_atom = String.to_existing_atom(function_name) + + # check the type of the args + unless absolute_module_atom.check_types(function_atom, args) do + throw({:error, node, "invalid arguments for function #{module_name}.#{function_name}"}) + end + + meta_with_alias = Keyword.put(meta, :alias, absolute_module_atom) + + new_node = + {{:., meta, [{:__aliases__, meta_with_alias, [module_atom]}, function_atom]}, meta, args} + + {new_node, acc} + end + + # variable are read from scope + def postwalk( + _node = {{:atom, var_name}, _, nil}, + acc + ) do + new_node = + quote do + Scope.read(unquote(acc), unquote(var_name)) + end + + {new_node, acc} + end + + # for var in list + def postwalk( + _node = + {{:atom, "for"}, _, + [ + {:%{}, _, [{var_name, list}]}, + [do: block] + ]}, + acc + ) do + # FIXME: here acc is already the parent acc, it is not the acc of the do block + # FIXME: this means that our `var_name` will live in the parent scope + # FIXME: it works (since we can read from parent) but it will override the parent binding if there's one + + # transform the for-loop into Enum.each + # and create a variable in the scope + new_node = + quote do + Enum.each(unquote(list), fn x -> + Scope.write_at(unquote(acc), unquote(var_name), x) + + unquote(block) + end) + end + + {new_node, acc} + end + + # whitelist rest + def postwalk(node, acc), do: {node, acc} + + # ---------------------------------------------------------------------- + # _ _ + # _ __ _ __(___ ____ _| |_ ___ + # | '_ \| '__| \ \ / / _` | __/ _ \ + # | |_) | | | |\ V | (_| | || __/ + # | .__/|_| |_| \_/ \__,_|\__\___| + # |_| + # ---------------------------------------------------------------------- +end diff --git a/lib/archethic/contracts/interpreter/version1/condition_interpreter.ex b/lib/archethic/contracts/interpreter/version1/condition_interpreter.ex new file mode 100644 index 000000000..1d8d1b74d --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/condition_interpreter.ex @@ -0,0 +1,195 @@ +defmodule Archethic.Contracts.Interpreter.Version1.ConditionInterpreter do + @moduledoc false + + alias Archethic.Contracts.Interpreter.Version1.CommonInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library + alias Archethic.Contracts.Interpreter.Version1.Scope + alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + + @modules_whitelisted Library.list_common_modules() + + @type condition_type :: :transaction | :inherit | :oracle + + @doc """ + Parse the given node and return the trigger and the actions block. + """ + @spec parse(any()) :: + {:ok, condition_type(), Conditions.t()} | {:error, any(), String.t()} + def parse({{:atom, "condition"}, _, [[{{:atom, condition_name}, keyword}]]}) do + {condition_type, global_variable} = + case condition_name do + "transaction" -> {:transaction, "transaction"} + "inherit" -> {:inherit, "next"} + "oracle" -> {:oracle, "transaction"} + _ -> throw({:error, "invalid condition type"}) + end + + # no need to traverse the condition block + # we'll traverse every block individually + {:%{}, _, proplist} = AST.keyword_to_map(keyword) + + conditions = + Enum.reduce(proplist, %Conditions{}, fn {key, value}, acc -> + # todo: throw if unknown key + new_value = to_boolean_expression([global_variable, key], value) + Map.put(acc, String.to_existing_atom(key), new_value) + end) + + {:ok, condition_type, conditions} + catch + {:error, node} -> + {:error, node, "unexpected term"} + + {:error, node, reason} -> + {:error, node, reason} + end + + def parse(node) do + {:error, node, "unexpected term"} + end + + # ---------------------------------------------------------------------- + # _ _ + # _ __ _ __(___ ____ _| |_ ___ + # | '_ \| '__| \ \ / / _` | __/ _ \ + # | |_) | | | |\ V | (_| | || __/ + # | .__/|_| |_| \_/ \__,_|\__\___| + # |_| + # ---------------------------------------------------------------------- + defp to_boolean_expression(_subject, bool) when is_boolean(bool) do + bool + end + + # `subject` is the "accessor" to the transaction's property for this expression + # + # Example: + # + # condition inherit: [ + # content: "ciao" + # ] + # + # - `subject == ["next", "content"]` + # - `value == "ciao"` + # + defp to_boolean_expression(subject, value) + when is_binary(value) or is_integer(value) or is_float(value) do + quote do + unquote(value) == Scope.read_global(unquote(subject)) + end + end + + defp to_boolean_expression(subject, ast) do + # here the accumulator is an list of parent scopes & current scope + # where we can access variables from all of them + # `acc = [ref1]` means read variable from scope.ref1 or scope + # `acc = [ref1, ref2]` means read variable from scope.ref1.ref2 or scope.ref1 or scope + acc = [] + + {new_ast, _} = + Macro.traverse( + AST.wrap_in_block(ast), + acc, + fn node, acc -> + prewalk(subject, node, acc) + end, + fn node, acc -> + postwalk(subject, node, acc) + end + ) + + new_ast + end + + # ---------------------------------------------------------------------- + # _ _ + # _ __ _ __ _____ ____ _| | | __ + # | '_ \| '__/ _ \ \ /\ / / _` | | |/ / + # | |_) | | | __/\ V V | (_| | | < + # | .__/|_| \___| \_/\_/ \__,_|_|_|\_\ + # |_| + # ---------------------------------------------------------------------- + defp prewalk(_subject, node, acc) do + CommonInterpreter.prewalk(node, acc) + end + + # ---------------------------------------------------------------------- + # _ _ _ + # _ __ ___ ___| |___ ____ _| | | __ + # | '_ \ / _ \/ __| __\ \ /\ / / _` | | |/ / + # | |_) | (_) \__ | |_ \ V V | (_| | | < + # | .__/ \___/|___/\__| \_/\_/ \__,_|_|_|\_\ + # |_| + # ---------------------------------------------------------------------- + # Override Module.function call + # because we might need to inject the contract as first argument + defp postwalk( + subject, + node = + {{:., meta, [{:__aliases__, _, [atom: module_name]}, {:atom, function_name}]}, _, args}, + acc + ) + when module_name in @modules_whitelisted do + # if function exist with arity => node + arity = length(args) + + absolute_module_atom = + String.to_existing_atom( + "Elixir.Archethic.Contracts.Interpreter.Version1.Library.Common.#{module_name}" + ) + + new_node = + cond do + # check function is available with given arity + Library.function_exists?(absolute_module_atom, function_name, arity) -> + {new_node, _} = CommonInterpreter.postwalk(node, acc) + new_node + + # if function exist with arity+1 => prepend the key to args + Library.function_exists?(absolute_module_atom, function_name, arity + 1) -> + ast = + quote do + Scope.read_global(unquote(subject)) + end + + # add it as first function argument + node_with_key_appended = + {{:., meta, [{:__aliases__, meta, [atom: module_name]}, {:atom, function_name}]}, + meta, [ast | args]} + + {new_node, _} = CommonInterpreter.postwalk(node_with_key_appended, acc) + new_node + + # check function exists + Library.function_exists?(absolute_module_atom, function_name) -> + throw({:error, node, "invalid arity for function #{module_name}.#{function_name}"}) + + true -> + throw({:error, node, "unknown function: #{module_name}.#{function_name}"}) + end + + {new_node, acc} + end + + # Override Contract.get_calls() + defp postwalk( + _subject = [_global_variable, _], + node = + {{:., _meta, [{:__aliases__, _, [atom: "Contract"]}, {:atom, "get_calls"}]}, _, []}, + _acc + ) do + # new_node = + # quote do + # Archethic.Contracts.Interpreter.Version1.Library.Contract.get_calls( + # Scope.read_global([unquote(global_variable), "address"]) + # ) + # end + + # {new_node, acc} + throw({:error, node, "Contract.get_calls() not yet implemented in the conditions"}) + end + + defp postwalk(_subject, node, acc) do + CommonInterpreter.postwalk(node, acc) + end +end diff --git a/lib/archethic/contracts/interpreter/version1/condition_validator.ex b/lib/archethic/contracts/interpreter/version1/condition_validator.ex new file mode 100644 index 000000000..cb79749d1 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/condition_validator.ex @@ -0,0 +1,143 @@ +defmodule Archethic.Contracts.Interpreter.Version1.ConditionValidator do + @moduledoc """ + This is pretty much a copy of Version0.ConditionInterpreter. + The difference is where the scope is stored (process dict VS global variable) + + """ + alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.ContractConstants, as: Constants + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.Scope + + require Logger + + @doc """ + Determines if the conditions of a contract are valid from the given constants + """ + @spec valid_conditions?(Conditions.t(), map()) :: boolean() + def valid_conditions?(conditions = %Conditions{}, constants = %{}) do + constants = + constants + |> Enum.map(fn {subset, constants} -> + {subset, Constants.stringify(constants)} + end) + |> Enum.into(%{}) + + conditions + |> Map.from_struct() + |> Enum.all?(fn {field, condition} -> + field = Atom.to_string(field) + + case validate_condition({field, condition}, constants) do + {_, true} -> + true + + {_, false} -> + value = get_constant_value(constants, field) + + Logger.debug( + "Invalid condition for `#{inspect(field)}` with the given value: `#{inspect(value)}` - condition: #{inspect(condition)}" + ) + + false + end + end) + end + + defp get_constant_value(constants, field) do + case get_in(constants, [ + Access.key("transaction", %{}), + Access.key(field, "") + ]) do + "" -> + get_in(constants, ["next", field]) + + value -> + value + end + end + + defp validate_condition({"origin_family", _}, _) do + # Skip the verification + # The Proof of Work algorithm will use this condition to verify the transaction + {"origin_family", true} + end + + defp validate_condition({"address", nil}, _) do + # Skip the verification as the address changes for each transaction + {"address", true} + end + + defp validate_condition({"previous_public_key", nil}, _) do + # Skip the verification as the previous public key changes for each transaction + {"previous_public_key", true} + end + + defp validate_condition({"timestamp", nil}, _) do + # Skip the verification as timestamp changes for each transaction + {"timestamp", true} + end + + defp validate_condition({"type", nil}, %{"next" => %{"type" => "transfer"}}) do + # Skip the verification when it's the default type + {"type", true} + end + + defp validate_condition({"content", nil}, %{"next" => %{"content" => ""}}) do + # Skip the verification when it's the default type + {"content", true} + end + + defp validate_condition( + {"code", nil}, + %{ + "next" => %{"code" => next_code}, + "previous" => %{"code" => prev_code} + } + ) do + {"code", + Interpreter.sanitize_code(prev_code || "") == Interpreter.sanitize_code(next_code || "")} + end + + # Validation rules for inherit constraints + defp validate_condition({field, nil}, %{"previous" => prev, "next" => next}) do + {field, Map.get(prev, field) == Map.get(next, field)} + end + + defp validate_condition({field, condition}, constants = %{"next" => next}) do + result = evaluate_condition(condition, constants) + + if is_boolean(result) do + {field, result} + else + {field, Map.get(next, field) == result} + end + end + + # Validation rules for incoming transaction + defp validate_condition({field, nil}, %{"transaction" => _}) do + # Skip the validation if no transaction conditions are provided + {field, true} + end + + defp validate_condition( + {field, condition}, + constants = %{"transaction" => transaction} + ) do + result = evaluate_condition(condition, constants) + + if is_boolean(result) do + {field, result} + else + {field, Map.get(transaction, field) == result} + end + end + + defp evaluate_condition(ast, constants) do + # reset scope and set constants + Scope.init(constants) + + {result, _} = Code.eval_quoted(ast) + result + end +end diff --git a/lib/archethic/contracts/interpreter/version1/library.ex b/lib/archethic/contracts/interpreter/version1/library.ex new file mode 100644 index 000000000..cf7d6065a --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library.ex @@ -0,0 +1,47 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library do + @moduledoc false + + @doc """ + Check the types of given parameters for the given function. + This is AST manipulation. + We cannot check everything (variable or return of fn), but we can at least forbid what's really wrong. + """ + @callback check_types(atom(), list(Macro.t())) :: boolean() + + @doc """ + Checks if a function exists in given module + """ + @spec function_exists?(module(), binary()) :: boolean() + def function_exists?(module, functionName) do + functionName in Enum.map(get_module_functions_as_string(module), &elem(&1, 0)) + end + + @doc """ + Checks if a function with given arity exists in given module + """ + @spec function_exists?(module(), binary(), integer) :: boolean() + def function_exists?(module, functionName, arity) do + arity in :proplists.get_all_values( + functionName, + get_module_functions_as_string(module) + ) + end + + @doc """ + Returns the list of common modules available. + + This function is also used to create the atoms of the modules + """ + def list_common_modules() do + [:Map, :List, :Regex, :Json, :Time, :Chain, :Crypto, :Token, :String] + |> Enum.map(&Atom.to_string/1) + end + + # ---------------------------------------- + defp get_module_functions_as_string(module) do + module.__info__(:functions) + |> Enum.map(fn {name, arity} -> + {Atom.to_string(name), arity} + end) + end +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/chain.ex b/lib/archethic/contracts/interpreter/version1/library/common/chain.ex new file mode 100644 index 000000000..1d7c38bd5 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/chain.ex @@ -0,0 +1,37 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.Chain do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + alias Archethic.Contracts.Interpreter.Version0 + + @spec get_genesis_address(binary()) :: binary() + defdelegate get_genesis_address(address), + to: Version0.Library, + as: :get_genesis_address + + @spec get_first_transaction_address(binary()) :: binary() + defdelegate get_first_transaction_address(address), + to: Version0.Library, + as: :get_first_transaction_address + + @spec get_genesis_public_key(binary()) :: binary() + defdelegate get_genesis_public_key(public_key), + to: Version0.Library, + as: :get_genesis_public_key + + @spec check_types(atom(), list()) :: boolean() + def check_types(:get_genesis_address, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:get_first_transaction_address, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:get_genesis_public_key, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/crypto.ex b/lib/archethic/contracts/interpreter/version1/library/common/crypto.ex new file mode 100644 index 000000000..c29288363 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/crypto.ex @@ -0,0 +1,24 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.Crypto do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + alias Archethic.Contracts.Interpreter.Version0 + + @spec hash(binary(), binary()) :: binary() + defdelegate hash(content, algo \\ "sha256"), + to: Version0.Library, + as: :hash + + @spec check_types(atom(), list()) :: boolean() + def check_types(:hash, [first, second]) do + (AST.is_binary?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:hash, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/json.ex b/lib/archethic/contracts/interpreter/version1/library/common/json.ex new file mode 100644 index 000000000..17ef4bd5a --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/json.ex @@ -0,0 +1,56 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.Json do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + alias Archethic.Contracts.Interpreter.Version0 + + @spec path_extract(String.t(), String.t()) :: String.t() + defdelegate path_extract(text, path), + to: Version0.Library, + as: :json_path_extract + + @spec path_match?(String.t(), String.t()) :: boolean() + defdelegate path_match?(text, path), + to: Version0.Library, + as: :json_path_match? + + @spec to_string(any()) :: String.t() + defdelegate to_string(term), + to: Jason, + as: :encode! + + @spec is_valid?(String.t()) :: boolean() + def is_valid?(str) do + case Jason.decode(str) do + {:ok, _} -> true + {:error, _} -> false + end + end + + @spec check_types(atom(), list()) :: boolean() + def check_types(:path_extract, [first, second]) do + (AST.is_binary?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:path_match?, [first, second]) do + (AST.is_binary?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:to_string, [first]) do + AST.is_binary?(first) || + AST.is_variable_or_function_call?(first) || + AST.is_map?(first) || + AST.is_list?(first) || + AST.is_float?(first) || + AST.is_integer?(first) + end + + def check_types(:is_valid?, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/list.ex b/lib/archethic/contracts/interpreter/version1/library/common/list.ex new file mode 100644 index 000000000..9144ad9ed --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/list.ex @@ -0,0 +1,83 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.List do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + + @spec at(list(), integer()) :: any() + defdelegate at(list, idx), + to: Enum, + as: :at + + @spec size(list()) :: integer() + defdelegate size(list), + to: Kernel, + as: :length + + @spec in?(list(), any()) :: boolean() + defdelegate in?(list, element), + to: Enum, + as: :member? + + @spec empty?(list()) :: boolean() + defdelegate empty?(list), + to: Enum, + as: :empty? + + @spec concat(list(list())) :: list() + defdelegate concat(list), + to: Enum, + as: :concat + + @spec join(list(), String.t()) :: String.t() + defdelegate join(list, separator), + to: Enum, + as: :join + + @spec append(list(), any()) :: list() + def append(list, element) do + list ++ [element] + end + + @spec prepend(list(), any()) :: list() + def prepend(list, element) do + [element | list] + end + + @spec check_types(atom(), list()) :: boolean() + def check_types(:at, [first, second]) do + (AST.is_list?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_integer?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:size, [first]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:in?, [first, _second]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:empty?, [first]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:concat, [first]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:join, [first, second]) do + (AST.is_list?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:append, [first, _second]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:prepend, [first, _second]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/map.ex b/lib/archethic/contracts/interpreter/version1/library/common/map.ex new file mode 100644 index 000000000..1aa80da5b --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/map.ex @@ -0,0 +1,68 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.Map do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + + @spec new() :: map() + defdelegate new(), + to: Map + + @spec keys(map()) :: list(String.t()) + defdelegate keys(map), + to: Map + + @spec values(map()) :: list(any()) + defdelegate values(map), + to: Map + + @spec size(map()) :: integer() + def size(map) do + length(Map.keys(map)) + end + + @spec get(map(), binary(), any()) :: any() + defdelegate get(map, key, default \\ nil), + to: Map + + @spec set(map(), binary(), any()) :: map() + def set(map, key, value) do + Map.update(map, key, value, fn _ -> + value + end) + end + + @spec check_types(atom(), list()) :: boolean() + def check_types(:new, []) do + true + end + + def check_types(:keys, [first]) do + AST.is_map?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:values, [first]) do + AST.is_map?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:size, [first]) do + AST.is_map?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:get, [first, second]) do + (AST.is_map?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:get, [first, second, _third]) do + (AST.is_map?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:set, [first, second, _third]) do + (AST.is_map?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/regex.ex b/lib/archethic/contracts/interpreter/version1/library/common/regex.ex new file mode 100644 index 000000000..195f154de --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/regex.ex @@ -0,0 +1,71 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.Regex do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + alias Archethic.Contracts.Interpreter.Version0 + + @spec match?(binary(), binary()) :: boolean() + defdelegate match?(text, pattern), + to: Version0.Library, + as: :regex_match? + + @spec extract(binary(), binary()) :: binary() + defdelegate extract(text, pattern), + to: Version0.Library, + as: :regex_extract + + @doc """ + Extract data from string using capture groups + (multiline flag is activated) + + ps: the number of antislash is doubled because this is a doctest + + ## Examples + + iex> Regex.scan("foo", "bar") + [] + + iex> Regex.scan("toto,123\\ntutu,456\\n", "toto,([0-9]+)") + ["123"] + + iex> Regex.scan("toto,123\\ntutu,456\\n", "t.t.,([0-9]+)") + ["123", "456"] + + iex> Regex.scan("A0B1C2,123\\nD3E4F5,456\\n", "^(\\\\w+),(\\\\d+)$") + [["A0B1C2", "123"], ["D3E4F5", "456"]] + + """ + @spec scan(binary(), binary()) :: list(binary()) + def scan(text, pattern) when is_binary(text) and is_binary(pattern) do + case Regex.compile(pattern, "m") do + {:ok, pattern} -> + Regex.scan(pattern, text, capture: :all_but_first) + |> Enum.map(fn + [item] -> item + other -> other + end) + + _ -> + [] + end + end + + @spec check_types(atom(), list()) :: boolean() + def check_types(:extract, [first, second]) do + (AST.is_binary?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:match?, [first, second]) do + (AST.is_binary?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:scan, [first, second]) do + (AST.is_binary?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/string.ex b/lib/archethic/contracts/interpreter/version1/library/common/string.ex new file mode 100644 index 000000000..4fcab5e70 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/string.ex @@ -0,0 +1,64 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.String do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + + @spec size(String.t()) :: integer() + defdelegate size(str), + to: String, + as: :length + + @spec in?(String.t(), String.t()) :: boolean() + defdelegate in?(str, substr), + to: String, + as: :contains? + + @spec to_int(String.t()) :: integer() + defdelegate to_int(str), + to: String, + as: :to_integer + + @spec from_int(integer()) :: String.t() + defdelegate from_int(int), + to: Integer, + as: :to_string + + @spec to_float(String.t()) :: float() + defdelegate to_float(str), + to: String, + as: :to_float + + @spec from_float(float()) :: String.t() + defdelegate from_float(float), + to: Float, + as: :to_string + + @spec check_types(atom(), list()) :: boolean() + def check_types(:size, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:in?, [first, second]) do + (AST.is_binary?(first) || AST.is_variable_or_function_call?(first)) && + (AST.is_binary?(second) || AST.is_variable_or_function_call?(second)) + end + + def check_types(:to_int, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:from_int, [first]) do + AST.is_integer?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:to_float, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:from_float, [first]) do + AST.is_float?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/time.ex b/lib/archethic/contracts/interpreter/version1/library/common/time.ex new file mode 100644 index 000000000..c691dd809 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/time.ex @@ -0,0 +1,17 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.Time do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + @doc """ + Returns current time in unix timestamp format. + (number of seconds since epoch) + """ + @spec now() :: integer() + def now() do + DateTime.utc_now() |> DateTime.to_unix() + end + + @spec check_types(atom(), list()) :: boolean() + def check_types(:now, []), do: true + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/common/token.ex b/lib/archethic/contracts/interpreter/version1/library/common/token.ex new file mode 100644 index 000000000..05f4550da --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/common/token.ex @@ -0,0 +1,19 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.Token do + @moduledoc false + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + alias Archethic.Contracts.Interpreter.Version0 + + @spec fetch_id_from_address(binary()) :: binary() + defdelegate fetch_id_from_address(address), + to: Version0.Library, + as: :get_token_id + + @spec check_types(atom(), list()) :: boolean() + def check_types(:fetch_id_from_address, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/library/contract.ex b/lib/archethic/contracts/interpreter/version1/library/contract.ex new file mode 100644 index 000000000..0efc02440 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/library/contract.ex @@ -0,0 +1,124 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Contract do + @moduledoc """ + We are delegating to the version 0 transaction statements. + This is fine as long as we don't need to change anything. + If there's something to change for version 1, do the change in here, not in version 0. + """ + @behaviour Archethic.Contracts.Interpreter.Version1.Library + + alias Archethic.Contracts.Interpreter.ASTHelper, as: AST + alias Archethic.TransactionChain.Transaction + alias Archethic.Contracts.Interpreter.Version0 + alias Archethic.Contracts.Interpreter.Version0.TransactionStatements + + # get_calls has it's own postwalk (to inject the address), + # it does not require a check_types + @spec get_calls(binary()) :: list(map()) + defdelegate get_calls(contract_address), + to: Version0.Library + + @spec set_type(Transaction.t(), binary()) :: Transaction.t() + defdelegate set_type(next_tx, type), + to: TransactionStatements + + @spec set_content(Transaction.t(), binary() | integer() | float()) :: Transaction.t() + defdelegate set_content(next_tx, content), + to: TransactionStatements + + @spec set_code(Transaction.t(), binary()) :: Transaction.t() + defdelegate set_code(next_tx, args), + to: TransactionStatements + + @spec add_recipient(Transaction.t(), binary()) :: Transaction.t() + defdelegate add_recipient(next_tx, args), + to: TransactionStatements + + @spec add_recipients(Transaction.t(), list(binary())) :: Transaction.t() + defdelegate add_recipients(next_tx, args), + to: TransactionStatements + + @spec add_uco_transfer(Transaction.t(), map()) :: Transaction.t() + def add_uco_transfer(next_tx, args) do + TransactionStatements.add_uco_transfer(next_tx, Map.to_list(args)) + end + + @spec add_uco_transfers(Transaction.t(), list(map())) :: Transaction.t() + def add_uco_transfers(next_tx, args) do + casted_args = Enum.map(args, &Map.to_list/1) + TransactionStatements.add_uco_transfers(next_tx, casted_args) + end + + @spec add_token_transfer(Transaction.t(), map()) :: Transaction.t() + def add_token_transfer(next_tx, args) do + TransactionStatements.add_token_transfer(next_tx, Map.to_list(args)) + end + + @spec add_token_transfers(Transaction.t(), list(map())) :: Transaction.t() + def add_token_transfers(next_tx, args) do + casted_args = Enum.map(args, &Map.to_list/1) + TransactionStatements.add_token_transfers(next_tx, casted_args) + end + + @spec add_ownership(Transaction.t(), map()) :: Transaction.t() + def add_ownership(next_tx, args) do + TransactionStatements.add_ownership(next_tx, Map.to_list(args)) + end + + @spec add_ownerships(Transaction.t(), list(map())) :: Transaction.t() + def add_ownerships(next_tx, args) do + casted_args = Enum.map(args, &Map.to_list/1) + TransactionStatements.add_ownerships(next_tx, casted_args) + end + + # We do not need to check the transaction argument because _we_ are feeding it (after this step) + @spec check_types(atom(), list()) :: boolean() + def check_types(:set_type, [first]) do + AST.is_variable_or_function_call?(first) || + Transaction.types() + |> Enum.map(&Atom.to_string/1) + |> Enum.member?(first) + end + + def check_types(:set_content, [first]) do + AST.is_binary?(first) || AST.is_integer?(first) || AST.is_float?(first) || + AST.is_variable_or_function_call?(first) + end + + def check_types(:add_uco_transfer, [first]) do + AST.is_map?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:add_token_transfer, [first]) do + AST.is_map?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:set_code, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:add_ownership, [first]) do + AST.is_map?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:add_recipient, [first]) do + AST.is_binary?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:add_recipients, [first]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:add_ownerships, [first]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:add_token_transfers, [first]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(:add_uco_transfers, [first]) do + AST.is_list?(first) || AST.is_variable_or_function_call?(first) + end + + def check_types(_, _), do: false +end diff --git a/lib/archethic/contracts/interpreter/version1/scope.ex b/lib/archethic/contracts/interpreter/version1/scope.ex new file mode 100644 index 000000000..75b5691f4 --- /dev/null +++ b/lib/archethic/contracts/interpreter/version1/scope.ex @@ -0,0 +1,144 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Scope do + @moduledoc """ + Helper functions to deal with scopes + """ + + @doc """ + Initialize the scope with given map + """ + @spec init(map()) :: :ok + def init(global_variables \\ %{}) do + Process.put( + :scope, + global_variables + ) + + :ok + end + + @doc """ + Create a new nested scope + """ + @spec create(list(String.t())) :: :ok + def create(scope_hierarchy) do + Process.put( + :scope, + put_in(Process.get(:scope), scope_hierarchy, %{}) + ) + + :ok + end + + @doc """ + Write the variable in the most relevant scope (cascade to all parent) + Fallback to current scope if variable doesn't exist anywhere + """ + @spec write_cascade(list(String.t()), String.t(), any()) :: :ok + def write_cascade(scope_hierarchy, var_name, value) do + Process.put( + :scope, + put_in( + Process.get(:scope), + where_is(scope_hierarchy, var_name) ++ [var_name], + value + ) + ) + + :ok + end + + @doc """ + Write the variable at given scope + """ + @spec write_at(list(String.t()), String.t(), any()) :: :ok + def write_at(scope_hierarchy, var_name, value) do + Process.put( + :scope, + put_in( + Process.get(:scope), + scope_hierarchy ++ [var_name], + value + ) + ) + + :ok + end + + @doc """ + Update the global variable (or prop) at path with given function + """ + @spec update_global(list(String.t()), (any() -> any())) :: :ok + def update_global(path, update_fn) do + Process.put( + :scope, + update_in( + Process.get(:scope), + path, + update_fn + ) + ) + + :ok + end + + @doc """ + Read the global variable (or prop) at path + """ + @spec read_global(list(String.t())) :: any() + def read_global(path) do + get_in( + Process.get(:scope), + path + ) + end + + @doc """ + Read the variable starting at given scope and cascading until the root + """ + @spec read(list(String.t()), String.t()) :: any() + def read(scopes_hierarchy, var_name) do + get_in( + Process.get(:scope), + where_is(scopes_hierarchy, var_name) ++ [var_name] + ) + end + + @doc """ + Read the map's property starting at given scope and cascading until the root + """ + @spec read(list(String.t()), String.t(), String.t()) :: any() + def read(scopes_hierarchy, map_name, key_name) do + get_in( + Process.get(:scope), + where_is(scopes_hierarchy, map_name) ++ [map_name, key_name] + ) + end + + # Return the path where to assign/read a variable. + # It will recurse from the deepest path to the root path until it finds a match. + # If no match it will return the current path. + defp where_is(current_path, variable_name) do + do_where_is(current_path, variable_name, current_path) + end + + defp do_where_is(current_path, variable_name, []) do + # there are magic variables at the root of scope (contract/transaction/next/previous) + case get_in(Process.get(:scope), [variable_name]) do + nil -> + current_path + + _ -> + [] + end + end + + defp do_where_is(current_path, variable_name, acc) do + case get_in(Process.get(:scope), acc ++ [variable_name]) do + nil -> + do_where_is(current_path, variable_name, List.delete_at(acc, -1)) + + _ -> + acc + end + end +end diff --git a/test/archethic/contracts/interpreter/ast_helper_test.exs b/test/archethic/contracts/interpreter/ast_helper_test.exs new file mode 100644 index 000000000..b40129baf --- /dev/null +++ b/test/archethic/contracts/interpreter/ast_helper_test.exs @@ -0,0 +1,6 @@ +defmodule Archethic.Contracts.Interpreter.ASTHelperTest do + use ArchethicCase + alias Archethic.Contracts.Interpreter.ASTHelper + + doctest ASTHelper +end diff --git a/test/archethic/contracts/interpreter/version0_test.exs b/test/archethic/contracts/interpreter/version0_test.exs index 77a192fce..fcd06c733 100644 --- a/test/archethic/contracts/interpreter/version0_test.exs +++ b/test/archethic/contracts/interpreter/version0_test.exs @@ -4,6 +4,7 @@ defmodule Archethic.Contracts.Interpreter.Version0Test do alias Archethic.Contracts.Contract + alias Archethic.Contracts.Interpreter alias Archethic.Contracts.Interpreter.Version0 alias Archethic.TransactionChain.Transaction @@ -17,17 +18,17 @@ defmodule Archethic.Contracts.Interpreter.Version0Test do """ abc """ - |> Version0.parse() + |> sanitize_and_parse() assert {:error, _} = """ condition """ - |> Version0.parse() + |> sanitize_and_parse() end test "should return an error for unexpected term" do - assert {:error, "unexpected term - @1 - L1"} = "@1" |> Version0.parse() + assert {:error, "unexpected term - @1 - L1"} = "@1" |> sanitize_and_parse() end end @@ -55,7 +56,7 @@ defmodule Archethic.Contracts.Interpreter.Version0Test do end end """ - |> Version0.parse() + |> sanitize_and_parse() end test "schedule transfers parsing" do @@ -72,6 +73,13 @@ defmodule Archethic.Contracts.Interpreter.Version0Test do add_uco_transfer to: "0000D574D171A484F8DEAC2D61FC3F7CC984BEB52465D69B3B5F670090742CBF5CC", amount: 100000000 end """ - |> Version0.parse() + |> sanitize_and_parse() + end + + defp sanitize_and_parse(code) do + code + |> Interpreter.sanitize_code() + |> elem(1) + |> Version0.parse() end end diff --git a/test/archethic/contracts/interpreter/version1/action_interpreter_test.exs b/test/archethic/contracts/interpreter/version1/action_interpreter_test.exs new file mode 100644 index 000000000..d147cb5a6 --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/action_interpreter_test.exs @@ -0,0 +1,900 @@ +defmodule Archethic.Contracts.Interpreter.Version1.ActionInterpreterTest do + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest ActionInterpreter + + # ---------------------------------------------- + # parse/1 + # ---------------------------------------------- + describe "parse/1" do + test "should not be able to parse when there is a non-whitelisted module" do + code = ~S""" + actions triggered_by: transaction do + String.to_atom "hello" + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use whitelisted module existing function" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content "hello" + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to have comments" do + code = ~S""" + actions triggered_by: transaction do + # this is a comment + "hello contract" + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should return the correct trigger type" do + code = ~S""" + actions triggered_by: oracle do + end + """ + + assert {:ok, :oracle, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + + code = ~S""" + actions triggered_by: interval, at: "* * * * *" do + end + """ + + assert {:ok, {:interval, "* * * * *"}, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + + code = ~S""" + actions triggered_by: datetime, at: 1676282771 do + end + """ + + assert {:ok, {:datetime, ~U[2023-02-13 10:06:11Z]}, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should not be able to use whitelisted module non existing function" do + code = ~S""" + actions triggered_by: transaction do + Contract.non_existing_fn() + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + + code = ~S""" + actions triggered_by: transaction do + Contract.set_content("hello", "hola") + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to create variables" do + code = ~S""" + actions triggered_by: transaction do + content = "hello" + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to create lists" do + code = ~S""" + actions triggered_by: transaction do + list = [1,2,3] + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to create keywords" do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use common functions" do + code = ~S""" + actions triggered_by: transaction do + numbers = [1,2,3] + List.at(numbers, 1) + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should not be able to use non existing functions" do + code = ~S""" + actions triggered_by: transaction do + numbers = [1,2,3] + List.at(numbers, 1, 2, 3) + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + + code = ~S""" + actions triggered_by: transaction do + numbers = [1,2,3] + List.non_existing_function() + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should not be able to use wrong types in common functions" do + code = ~S""" + actions triggered_by: transaction do + numbers = [1,2,3] + List.at(1, numbers) + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use the result of a function call as a parameter" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content Json.to_string([1,2,3]) + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should not be able to use wrong types in contract functions" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content [1,2,3] + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should not be able to use if as an expression" do + code = ~S""" + actions triggered_by: transaction do + var = if true do + "foo" + else + "bar" + end + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should not be able to use for as an expression" do + code = ~S""" + actions triggered_by: transaction do + var = for i in [1,2] do + i + end + end + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use nested ." do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + var = [numbers: numbers] + + Contract.set_content var.numbers.one + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + + code = ~S""" + actions triggered_by: transaction do + a = [b: [c: [d: [e: [f: [g: [h: "hello"]]]]]]] + + Contract.set_content a.b.c.d.e.f.g.h + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use [] access with a string" do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + + Contract.set_content numbers["one"] + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use [] access with a variable" do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + x = "one" + + Contract.set_content numbers[x] + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use [] access with a dot access" do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + x = [value: "one"] + + Contract.set_content numbers[x.value] + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use [] access with a fn call" do + code = ~S""" + actions triggered_by: transaction do + numbers = ["1": 1, two: 2, three: 3] + + Contract.set_content numbers[String.from_int 1] + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use nested [] access" do + code = ~S""" + actions triggered_by: transaction do + a = [b: [c: [d: [e: [f: [g: [h: "hello"]]]]]]] + + Contract.set_content a["b"]["c"]["d"]["e"]["f"]["g"]["h"] + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use loop" do + code = ~S""" + actions triggered_by: transaction do + result = 0 + + for i in [1,2,3] do + result = result + i + end + + Contract.set_content result + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should be able to use ranges" do + code = ~S""" + actions triggered_by: transaction do + range = 1..10 + end + """ + + assert {:ok, :transaction, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + end + + # ---------------------------------------------- + # execute/2 + # ---------------------------------------------- + + describe "execute/2" do + test "should be able to call the Contract module" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content "hello" + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + + test "should be able to change the contract even if there are code after" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content "hello" + some = "code" + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + + test "should be able to use a variable" do + code = ~S""" + actions triggered_by: transaction do + content = "hello" + Contract.set_content content + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + + test "should be able to use a function call as parameter" do + code = ~S""" + actions triggered_by: transaction do + content = "hello" + Contract.set_content Json.to_string(content) + end + """ + + assert %Transaction{data: %TransactionData{content: "\"hello\""}} = + sanitize_parse_execute(code) + end + + test "should be able to use a keyword as a map" do + code = ~S""" + actions triggered_by: transaction do + content = [text: "hello"] + Contract.set_content content.text + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + + test "should be able to use a common module" do + code = ~S""" + actions triggered_by: transaction do + content = [1,2,3] + two = List.at(content, 1) + Contract.set_content two + end + """ + + assert %Transaction{data: %TransactionData{content: "2"}} = sanitize_parse_execute(code) + end + + test "should evaluate actions based on if statement" do + code = ~S""" + actions triggered_by: transaction do + if true do + Contract.set_content "yes" + else + Contract.set_content "no" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "yes"}} = sanitize_parse_execute(code) + end + + test "should consider the ! (not) keyword" do + code = ~S""" + actions triggered_by: transaction do + if !false do + Contract.set_content "yes" + else + Contract.set_content "no" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "yes"}} = sanitize_parse_execute(code) + end + + test "should not parse if trying to access an undefined variable" do + code = ~S""" + actions triggered_by: transaction do + if true do + content = "hello" + end + Contract.set_content content + end + """ + + # TODO: we want a parsing error not a runtime error + assert_raise FunctionClauseError, fn -> + sanitize_parse_execute(code) + end + end + + test "should be able to access a parent scope variable" do + code = ~S""" + actions triggered_by: transaction do + content = "hello" + if true do + Contract.set_content content + else + Contract.set_content "should not happen" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + content = "hello" + if true do + Contract.set_content "#{content} world" + else + Contract.set_content "should not happen" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "hello world"}} = + sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + content = "hello" + if true do + if true do + Contract.set_content content + end + else + Contract.set_content "should not happen" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + + test "should be able to have variable in block" do + code = ~S""" + actions triggered_by: transaction do + if true do + content = "hello" + Contract.set_content content + else + Contract.set_content "should not happen" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + if true do + content = "hello" + Contract.set_content "#{content} world" + else + Contract.set_content "should not happen" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "hello world"}} = + sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + if true do + content = "hello" + if true do + Contract.set_content content + end + else + Contract.set_content "should not happen" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + + test "should be able to update a parent scope variable" do + code = ~S""" + actions triggered_by: transaction do + content = "" + + if true do + content = "hello" + end + + Contract.set_content content + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + content = "" + + if false do + content = "hello" + end + + Contract.set_content content + end + """ + + assert nil == sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + content = "" + + if false do + content = "should not happen" + else + content = "hello" + end + + Contract.set_content content + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + content = "" + + if true do + content = "layer 1" + if true do + content = "layer 2" + if true do + content = "layer 3" + end + end + end + + Contract.set_content content + end + """ + + assert %Transaction{data: %TransactionData{content: "layer 3"}} = + sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + content = "" + + if true do + if true do + if true do + content = "layer 3" + end + end + end + + Contract.set_content content + end + """ + + assert %Transaction{data: %TransactionData{content: "layer 3"}} = + sanitize_parse_execute(code) + end + + test "should be able to use nested ." do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + var = [numbers: numbers] + + Contract.set_content var.numbers.one + end + """ + + assert %Transaction{data: %TransactionData{content: "1"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + a = [b: [c: [d: [e: [f: [g: [h: "hello"]]]]]]] + + Contract.set_content a.b.c.d.e.f.g.h + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + + if true do + a = [b: [c: [d: [e: [f: [g: [h: "hello"]]]]]]] + if true do + Contract.set_content a.b.c.d.e.f.g.h + end + end + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + + test "should be able to use for loop" do + code = ~S""" + actions triggered_by: transaction do + result = 0 + + for var in [1,2,3] do + result = result + var + end + + Contract.set_content result + end + """ + + assert %Transaction{data: %TransactionData{content: "6"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + result = 0 + list = [1,2,3] + + for num in list do + result = result + num + end + + Contract.set_content result + end + """ + + assert %Transaction{data: %TransactionData{content: "6"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + result = 0 + + for num in [1,2,3] do + y = num + result = result + num + y + end + + Contract.set_content result + end + """ + + assert %Transaction{data: %TransactionData{content: "12"}} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + for num in [1,2,3] do + if num == 2 do + Contract.set_content "ok" + end + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + + test "should be able to use ranges" do + code = ~S""" + actions triggered_by: transaction do + text = "" + for num in 1..4 do + text = "#{text}#{num}\n" + end + Contract.set_content text + end + """ + + assert %Transaction{data: %TransactionData{content: "1\n2\n3\n4\n"}} = + sanitize_parse_execute(code) + end + + test "should be able to use [] access with a string" do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + + Contract.set_content numbers["one"] + end + """ + + assert %Transaction{data: %TransactionData{content: "1"}} = sanitize_parse_execute(code) + end + + test "should be able to use [] access with a variable" do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + x = "one" + + Contract.set_content numbers[x] + end + """ + + assert %Transaction{data: %TransactionData{content: "1"}} = sanitize_parse_execute(code) + end + + test "should be able to use [] access with a dot access" do + code = ~S""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + x = [value: "one"] + + Contract.set_content numbers[x.value] + end + """ + + assert %Transaction{data: %TransactionData{content: "1"}} = sanitize_parse_execute(code) + end + + test "should be able to use [] access with a fn call" do + code = ~S""" + actions triggered_by: transaction do + numbers = ["1": 1, two: 2, three: 3] + + Contract.set_content numbers[String.from_int 1] + end + """ + + assert %Transaction{data: %TransactionData{content: "1"}} = sanitize_parse_execute(code) + end + + test "should be able to use nested [] access" do + code = ~S""" + actions triggered_by: transaction do + a = [b: [c: [d: [e: [f: [g: [h: "hello"]]]]]]] + d = "d" + + Contract.set_content a["b"]["c"][d]["e"]["f"]["g"]["h"] + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/condition_interpreter_test.exs b/test/archethic/contracts/interpreter/version1/condition_interpreter_test.exs new file mode 100644 index 000000000..a6feeb30e --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/condition_interpreter_test.exs @@ -0,0 +1,503 @@ +defmodule Archethic.Contracts.Interpreter.Version1.ConditionInterpreterTest do + use ArchethicCase + + alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ConditionInterpreter + alias Archethic.Contracts.Interpreter.Version1.ConditionValidator + + doctest ConditionInterpreter + + describe "parse/1" do + test "parse a condition inherit" do + code = ~s""" + condition inherit: [ ] + """ + + assert {:ok, :inherit, %Conditions{}} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "parse a condition oracle" do + code = ~s""" + condition oracle: [ ] + """ + + assert {:ok, :oracle, %Conditions{}} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "parse a condition transaction" do + code = ~s""" + condition transaction: [ ] + """ + + assert {:ok, :transaction, %Conditions{}} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "does not parse anything else" do + code = ~s""" + condition foo: [ ] + """ + + assert {:error, _, _} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + end + + describe "parse/1 field" do + test "parse strict value" do + code = ~s""" + condition transaction: [ + content: "Hello" + ] + """ + + assert {:ok, :transaction, %Conditions{content: ast}} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + + assert is_tuple(ast) && :ok == Macro.validate(ast) + end + + test "parse library functions" do + code = ~s""" + condition transaction: [ + uco_transfers: Map.size() > 0 + ] + """ + + assert {:ok, :transaction, %Conditions{uco_transfers: ast}} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + + assert is_tuple(ast) && :ok == Macro.validate(ast) + end + + test "parse true" do + code = ~s""" + condition transaction: [ + content: true + ] + """ + + assert {:ok, :transaction, %Conditions{content: true}} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "parse false" do + code = ~s""" + condition transaction: [ + content: false + ] + """ + + assert {:ok, :transaction, %Conditions{content: false}} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "parse AST" do + code = ~s""" + condition transaction: [ + content: if true do "Hello" else "World" end + ] + """ + + assert {:ok, :transaction, %Conditions{content: {:__block__, _, _}}} = + code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + end + + describe "valid_conditions?/2" do + test "should return true if the transaction's conditions are valid" do + code = ~s""" + condition transaction: [ + type: "transfer" + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "transaction" => %{ + "type" => "transfer" + } + }) + end + + test "should return true if the inherit's conditions are valid" do + code = ~s""" + condition inherit: [ + content: "Hello" + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "next" => %{ + "content" => "Hello" + } + }) + end + + test "should return true if the oracle's conditions are valid" do + code = ~s""" + condition oracle: [ + content: "Hello" + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "transaction" => %{ + "content" => "Hello" + } + }) + end + + test "should return true with a flexible condition" do + code = ~s""" + condition inherit: [ + content: true + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "next" => %{ + "content" => "Hello" + } + }) + end + + test "should return false if modifying a value not in the condition" do + code = ~s""" + condition inherit: [] + """ + + refute code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "next" => %{ + "content" => "Hello" + } + }) + end + + test "should be able to use boolean expression in inherit" do + code = ~s""" + condition inherit: [ + uco_transfers: Map.size() == 1 + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "next" => %{ + "uco_transfers" => %{"@addr" => 265_821} + } + }) + + code = ~s""" + condition inherit: [ + uco_transfers: Map.size() == 3 + ] + """ + + refute code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "next" => %{ + "uco_transfers" => %{} + } + }) + end + + test "should be able to use boolean expression in transaction" do + code = ~s""" + condition transaction: [ + uco_transfers: Map.size() > 0 + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "transaction" => %{ + "uco_transfers" => %{"@addr" => 265_821} + } + }) + + code = ~s""" + condition transaction: [ + uco_transfers: Map.size() == 1 + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "transaction" => %{ + "uco_transfers" => %{"@addr" => 265_821} + } + }) + + code = ~s""" + condition transaction: [ + uco_transfers: Map.size() == 2 + ] + """ + + refute code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "transaction" => %{ + "uco_transfers" => %{} + } + }) + + code = ~s""" + condition transaction: [ + uco_transfers: Map.size() < 10 + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "transaction" => %{ + "uco_transfers" => %{"@addr" => 265_821} + } + }) + end + + test "should be able to use dot access" do + code = ~s""" + condition inherit: [ + content: previous.content == next.content + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "previous" => %{"content" => "zoubida"}, + "next" => %{"content" => "zoubida"} + }) + + code = ~s""" + condition inherit: [ + content: previous.content == next.content + ] + """ + + refute code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "previous" => %{"content" => "lavabo"}, + "next" => %{"content" => "bidet"} + }) + end + + test "should be able to use nested dot access" do + code = ~s""" + condition inherit: [ + content: previous.content.y == "foobar" + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "previous" => %{"content" => %{"y" => "foobar"}}, + "next" => %{} + }) + end + + test "should evaluate AST" do + code = ~s""" + condition inherit: [ + content: if true do "Hello" else "World" end + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "previous" => %{}, + "next" => %{ + "content" => "Hello" + } + }) + + code = ~s""" + condition inherit: [ + content: if false do "Hello" else "World" end + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "previous" => %{}, + "next" => %{ + "content" => "World" + } + }) + + code = ~s""" + condition inherit: [ + content: if false do "Hello" else "World" end + ] + """ + + refute code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "previous" => %{}, + "next" => %{ + "content" => "Hello" + } + }) + end + + test "should be able to use variables in the AST" do + code = ~s""" + condition inherit: [ + content: ( + x = 1 + if true do + x == 1 + end + ) + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "previous" => %{}, + "next" => %{} + }) + end + + test "should be able to use for loops" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + condition inherit: [ + uco_transfers: ( + found = false + + # search for a transfer of 1 uco to address + for address in Map.keys(next.uco_transfers) do + if address == "#{Base.encode16(address)}" && next.uco_transfers[address] == 1 do + found = true + end + end + + found + ) + ] + """ + + assert code + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionValidator.valid_conditions?(%{ + "previous" => %{}, + "next" => %{ + "uco_transfers" => %{ + address => 1 + } + } + }) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/chain_test.exs b/test/archethic/contracts/interpreter/version1/library/common/chain_test.exs new file mode 100644 index 000000000..acad063c6 --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/chain_test.exs @@ -0,0 +1,153 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.ChainTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.Chain + + alias Archethic.Crypto + + alias Archethic.P2P + alias Archethic.P2P.Message.FirstPublicKey + alias Archethic.P2P.Message.FirstTransactionAddress + alias Archethic.P2P.Message.GetFirstPublicKey + alias Archethic.P2P.Message.GetFirstTransactionAddress + alias Archethic.P2P.Message.GenesisAddress + alias Archethic.P2P.Message.GetGenesisAddress + alias Archethic.P2P.Message.NotFound + alias Archethic.P2P.Node + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + import Mox + + doctest Chain + + setup do + P2P.add_and_connect_node(%Node{ + ip: {127, 0, 0, 1}, + port: 3000, + first_public_key: Crypto.last_node_public_key(), + last_public_key: Crypto.last_node_public_key(), + network_patch: "AAA", + geo_patch: "AAA", + authorized?: true, + authorization_date: DateTime.utc_now() + }) + + P2P.add_and_connect_node(%Node{ + ip: {127, 0, 0, 1}, + port: 3000, + first_public_key: :crypto.strong_rand_bytes(32), + last_public_key: :crypto.strong_rand_bytes(32), + network_patch: "AAA", + geo_patch: "AAA", + available?: true, + authorized?: true, + authorization_date: DateTime.utc_now() + }) + end + + # ---------------------------------------- + describe "get_genesis_address/1" do + test "should work" do + tx_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + genesis_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + Contract.set_content Chain.get_genesis_address("#{Base.encode16(tx_address)}") + end + """ + + MockClient + |> stub(:send_message, fn + _, %GetGenesisAddress{address: ^tx_address}, _ -> + {:ok, %GenesisAddress{address: genesis_address}} + end) + + assert %Transaction{data: %TransactionData{content: content}} = sanitize_parse_execute(code) + assert content == Base.encode16(genesis_address) + end + end + + # ---------------------------------------- + describe "get_first_transaction_address/1" do + test "should work when there is a first transaction" do + tx_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + first_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + Contract.set_content Chain.get_first_transaction_address("#{Base.encode16(tx_address)}") + end + """ + + MockClient + |> stub(:send_message, fn + _, %GetFirstTransactionAddress{address: ^tx_address}, _ -> + {:ok, %FirstTransactionAddress{address: first_address}} + end) + + assert %Transaction{data: %TransactionData{content: content}} = sanitize_parse_execute(code) + assert content == Base.encode16(first_address) + end + + test "should raise if there are no transaction" do + # DISCUSS: I don't like this behaviour but it's what's done in version0 + tx_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + Contract.set_content Chain.get_first_transaction_address("#{Base.encode16(tx_address)}") + end + """ + + MockClient + |> stub(:send_message, fn + _, %GetFirstTransactionAddress{address: ^tx_address}, _ -> + {:ok, %NotFound{}} + end) + + assert_raise(RuntimeError, fn -> + sanitize_parse_execute(code) + end) + end + end + + # ---------------------------------------- + describe "get_genesis_public_key/1" do + test "should work" do + {genesis_pub_key, _} = Crypto.generate_deterministic_keypair("seed") + {pub_key, _} = Crypto.derive_keypair("seed", 19) + + code = ~s""" + actions triggered_by: transaction do + Contract.set_content Chain.get_genesis_public_key("#{Base.encode16(pub_key)}") + end + """ + + MockClient + |> stub(:send_message, fn + _, %GetFirstPublicKey{public_key: ^pub_key}, _ -> + {:ok, %FirstPublicKey{public_key: genesis_pub_key}} + end) + + assert %Transaction{data: %TransactionData{content: content}} = sanitize_parse_execute(code) + assert content == Base.encode16(genesis_pub_key) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/crypto_test.exs b/test/archethic/contracts/interpreter/version1/library/common/crypto_test.exs new file mode 100644 index 000000000..cb5f775bb --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/crypto_test.exs @@ -0,0 +1,55 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.CryptoTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.Crypto + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest Crypto + + # ---------------------------------------- + describe "hash/1" do + test "should work without algo" do + text = "wu-tang" + + code = ~s""" + actions triggered_by: transaction do + Contract.set_content Crypto.hash("#{text}") + end + """ + + assert %Transaction{data: %TransactionData{content: content}} = sanitize_parse_execute(code) + assert content == Base.encode16(:crypto.hash(:sha256, text)) + end + end + + describe "hash/2" do + test "should work with algo" do + text = "wu-tang" + + code = ~s""" + actions triggered_by: transaction do + Contract.set_content Crypto.hash("#{text}", "sha512") + end + """ + + assert %Transaction{data: %TransactionData{content: content}} = sanitize_parse_execute(code) + assert content == Base.encode16(:crypto.hash(:sha512, text)) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/json_test.exs b/test/archethic/contracts/interpreter/version1/library/common/json_test.exs new file mode 100644 index 000000000..4439bd43a --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/json_test.exs @@ -0,0 +1,108 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.JsonTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.Json + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest Json + + # ---------------------------------------- + describe "to_string/1" do + test "should work with float" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content Json.to_string(1.0) + end + """ + + assert %Transaction{data: %TransactionData{content: "1.0"}} = sanitize_parse_execute(code) + end + + test "should work with integer" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content Json.to_string(1) + end + """ + + assert %Transaction{data: %TransactionData{content: "1"}} = sanitize_parse_execute(code) + end + + test "should work with string" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content Json.to_string("hello") + end + """ + + assert %Transaction{data: %TransactionData{content: "\"hello\""}} = + sanitize_parse_execute(code) + end + + test "should work with list" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content Json.to_string([1,2,3]) + end + """ + + assert %Transaction{data: %TransactionData{content: "[1,2,3]"}} = + sanitize_parse_execute(code) + end + + test "should work with map" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content Json.to_string([foo: "bar"]) + end + """ + + assert %Transaction{data: %TransactionData{content: "{\"foo\":\"bar\"}"}} = + sanitize_parse_execute(code) + end + + test "should work with variable" do + code = ~S""" + actions triggered_by: transaction do + variable = [foo: "bar"] + Contract.set_content Json.to_string(variable) + end + """ + + assert %Transaction{data: %TransactionData{content: "{\"foo\":\"bar\"}"}} = + sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "is_valid?/1" do + test "should work" do + code = ~S""" + actions triggered_by: transaction do + x = Json.to_string(hello: "world", foo: "bar") + if Json.is_valid?(x) do + Contract.set_content "ok" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/list_test.exs b/test/archethic/contracts/interpreter/version1/library/common/list_test.exs new file mode 100644 index 000000000..caf925640 --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/list_test.exs @@ -0,0 +1,186 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.ListTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.List + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest List + + # ---------------------------------------- + describe "at/2" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + list = ["Jennifer", "John", "Jean", "Julie"] + Contract.set_content List.at(list, 2) + end + """ + + assert %Transaction{data: %TransactionData{content: "Jean"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "size/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + list = ["Jennifer", "John", "Jean", "Julie"] + Contract.set_content List.size(list) + end + """ + + assert %Transaction{data: %TransactionData{content: "4"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "in?/2" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + list = ["Jennifer", "John", "Jean", "Julie"] + if List.in?(list, "Julie") do + Contract.set_content "ok" + else + Contract.set_content "ko" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + + code = ~s""" + actions triggered_by: transaction do + list = ["Jennifer", "John", "Jean", "Julie"] + if List.in?(list, "Julia") do + Contract.set_content "ko" + else + Contract.set_content "ok" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "empty?/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + list = [] + if List.empty?(list) do + Contract.set_content "ok" + else + Contract.set_content "ko" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + + code = ~s""" + actions triggered_by: transaction do + list = ["Jennifer", "John", "Jean", "Julie"] + if List.empty?(list) do + Contract.set_content "ko" + else + Contract.set_content "ok" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "concat/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + list = [1,2] + if true do + list = List.concat([list, [3,4]]) + end + + Contract.set_content Json.to_string(list) + end + """ + + assert %Transaction{data: %TransactionData{content: "[1,2,3,4]"}} = + sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "append/2" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + list = [1,2] + if true do + list = List.append(list, 3) + end + + Contract.set_content Json.to_string(list) + end + """ + + assert %Transaction{data: %TransactionData{content: "[1,2,3]"}} = + sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "prepend/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + list = [1,2] + if true do + list = List.prepend(list, 0) + end + + Contract.set_content Json.to_string(list) + end + """ + + assert %Transaction{data: %TransactionData{content: "[0,1,2]"}} = + sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "join/2" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + list = ["Emma", "Joseph", "Emily"] + Contract.set_content List.join(list, ", ") + end + """ + + assert %Transaction{data: %TransactionData{content: "Emma, Joseph, Emily"}} = + sanitize_parse_execute(code) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/map_test.exs b/test/archethic/contracts/interpreter/version1/library/common/map_test.exs new file mode 100644 index 000000000..4c56141f9 --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/map_test.exs @@ -0,0 +1,148 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.MapTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.Map + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest Map + # ---------------------------------------- + describe "new/0" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + Contract.set_content Map.size(Map.new()) + end + """ + + assert %Transaction{data: %TransactionData{content: "0"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "size/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + Contract.set_content Map.size([one: 1, two: 2, three: 3]) + end + """ + + assert %Transaction{data: %TransactionData{content: "3"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "get/2" do + test "should return value when key exist" do + code = ~s""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + Contract.set_content Map.get(numbers, "one") + end + """ + + assert %Transaction{data: %TransactionData{content: "1"}} = sanitize_parse_execute(code) + end + + test "should return nil when key not found" do + code = ~s""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + if nil == Map.get(numbers, "four") do + Contract.set_content "ok" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "get/3" do + test "should return value when key exist" do + code = ~s""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + Contract.set_content Map.get(numbers, "one", 12) + end + """ + + assert %Transaction{data: %TransactionData{content: "1"}} = sanitize_parse_execute(code) + end + + test "should return default when key not found" do + code = ~s""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2, three: 3] + Contract.set_content Map.get(numbers, "four", 4) + end + """ + + assert %Transaction{data: %TransactionData{content: "4"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "set/3" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + numbers = [one: 1] + numbers = Map.set(numbers, "two", 2) + Contract.set_content Json.to_string(numbers) + end + """ + + assert %Transaction{data: %TransactionData{content: "{\"one\":1,\"two\":2}"}} = + sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "keys/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2] + keys = Map.keys(numbers) + Contract.set_content Json.to_string(keys) + end + """ + + assert %Transaction{data: %TransactionData{content: "[\"one\",\"two\"]"}} = + sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "values/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + numbers = [one: 1, two: 2] + values = Map.values(numbers) + Contract.set_content Json.to_string(values) + end + """ + + assert %Transaction{data: %TransactionData{content: "[1,2]"}} = sanitize_parse_execute(code) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/regex_test.exs b/test/archethic/contracts/interpreter/version1/library/common/regex_test.exs new file mode 100644 index 000000000..7ba852fc1 --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/regex_test.exs @@ -0,0 +1,112 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.RegexTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.Regex + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest Regex + + # ---------------------------------------- + describe "match?/2" do + test "should match" do + code = ~s""" + actions triggered_by: transaction do + if Regex.match?("lorem ipsum", "lorem") do + Contract.set_content "match" + else + Contract.set_content "no match" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "match"}} = sanitize_parse_execute(code) + end + + test "should not match" do + code = ~s""" + actions triggered_by: transaction do + if Regex.match?("lorem ipsum", "LOREM") do + Contract.set_content "match" + else + Contract.set_content "no match" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "no match"}} = + sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "extract/2" do + test "should extract" do + code = ~s""" + actions triggered_by: transaction do + # doubled escape chars because it's in a string + Contract.set_content Regex.extract("Michael,12", "\\\\d+") + end + """ + + assert %Transaction{data: %TransactionData{content: "12"}} = sanitize_parse_execute(code) + end + + test "should return empty when no match" do + code = ~s""" + actions triggered_by: transaction do + x = Regex.extract("Michael,twelve", "\\\\d+") + if x == "" do + # FIXME: I can't do set_content("") apparently + Contract.set_content "no match" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "no match"}} = + sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "scan/2" do + test "should work with single capture group" do + code = ~s""" + actions triggered_by: transaction do + # doubled escape chars because it's in a string + Contract.set_content Json.to_string(Regex.scan("Michael,12", "(\\\\d+)")) + end + """ + + assert %Transaction{data: %TransactionData{content: "[\"12\"]"}} = + sanitize_parse_execute(code) + end + + test "should work with multiple capture group" do + code = ~s""" + actions triggered_by: transaction do + # doubled escape chars because it's in a string + Contract.set_content Json.to_string(Regex.scan("Michael,12", "(\\\\w+),(\\\\d+)")) + end + """ + + assert %Transaction{data: %TransactionData{content: "[[\"Michael\",\"12\"]]"}} = + sanitize_parse_execute(code) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/string_test.exs b/test/archethic/contracts/interpreter/version1/library/common/string_test.exs new file mode 100644 index 000000000..052038ad5 --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/string_test.exs @@ -0,0 +1,127 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.StringTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.String + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest String + + # ---------------------------------------- + describe "size/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + Contract.set_content String.size("hello") + end + """ + + assert %Transaction{data: %TransactionData{content: "5"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "in?/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + if String.in?("bob,alice", "bob") do + Contract.set_content "ok" + else + Contract.set_content "ko" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + + code = ~s""" + actions triggered_by: transaction do + if String.in?("bob,alice", "robert") do + Contract.set_content "ko" + else + Contract.set_content "ok" + + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "to_int/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + if String.to_int("14") == 14 do + Contract.set_content "ok" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "from_int/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + if String.from_int(14) == "14" do + Contract.set_content "ok" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "to_float/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + if String.to_float("0.1") == 0.1 do + Contract.set_content "ok" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "from_float/1" do + test "should work" do + code = ~s""" + actions triggered_by: transaction do + if String.from_float(0.1) == "0.1" do + Contract.set_content "ok" + end + end + """ + + assert %Transaction{data: %TransactionData{content: "ok"}} = sanitize_parse_execute(code) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/time_test.exs b/test/archethic/contracts/interpreter/version1/library/common/time_test.exs new file mode 100644 index 000000000..7af2c51e5 --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/time_test.exs @@ -0,0 +1,44 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.TimeTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.Time + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest Time + + # ---------------------------------------- + describe "now/0" do + test "should work" do + now = DateTime.to_unix(DateTime.utc_now()) + + code = ~s""" + actions triggered_by: transaction do + now = Time.now() + Contract.set_content now + end + """ + + assert %Transaction{data: %TransactionData{content: timestamp}} = + sanitize_parse_execute(code) + + # we validate the test if now is approximately now + assert String.to_integer(timestamp) - now < 10 + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/common/token_test.exs b/test/archethic/contracts/interpreter/version1/library/common/token_test.exs new file mode 100644 index 000000000..f1e748caf --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/common/token_test.exs @@ -0,0 +1,82 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.Common.TokenTest do + @moduledoc """ + Here we test the module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Common.Token + + alias Archethic.Crypto + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + alias Archethic.Utils + + import Mox + + doctest Token + + # ---------------------------------------- + describe "fetch_id_from_address/1" do + test "should work" do + seed = "s3cr3t" + {genesis_pub_key, _} = Crypto.generate_deterministic_keypair(seed) + genesis_address = Crypto.derive_address(genesis_pub_key) + + {pub_key, _} = Crypto.derive_keypair(seed, 24) + token_address = Crypto.derive_address(pub_key) + + code = ~s""" + actions triggered_by: transaction do + id = Token.fetch_id_from_address("#{Base.encode16(token_address)}") + Contract.set_content id + end + """ + + tx = + Transaction.new( + :token, + %TransactionData{ + content: + Jason.encode!(%{ + supply: 300_000_000, + name: "MyToken", + type: "non-fungible", + symbol: "MTK", + properties: %{ + global: "property" + }, + collection: [ + %{image: "link", value: "link"}, + %{image: "link", value: "link"}, + %{image: "link", value: "link"} + ] + }) + }, + seed, + 0 + ) + + MockDB + |> expect(:get_genesis_address, fn ^token_address -> genesis_address end) + |> stub(:get_transaction, fn ^token_address, _, _ -> {:ok, tx} end) + + {:ok, %{id: token_id}} = Utils.get_token_properties(genesis_address, tx) + + assert %Transaction{data: %TransactionData{content: content}} = sanitize_parse_execute(code) + assert content == token_id + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/library/contract_test.exs b/test/archethic/contracts/interpreter/version1/library/contract_test.exs new file mode 100644 index 000000000..462d477f3 --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/library/contract_test.exs @@ -0,0 +1,558 @@ +defmodule Archethic.Contracts.Interpreter.Version1.Library.ContractTest do + @moduledoc """ + Here we test the contract module within the action block. Because there is AST modification (such as keywords to maps) + in the ActionInterpreter and we want to test the whole thing. + """ + + use ArchethicCase + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Interpreter.Version1.ActionInterpreter + alias Archethic.Contracts.Interpreter.Version1.Library.Contract + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + alias Archethic.TransactionChain.TransactionData.Ledger + alias Archethic.TransactionChain.TransactionData.TokenLedger + alias Archethic.TransactionChain.TransactionData.TokenLedger.Transfer, as: TokenTransfer + alias Archethic.TransactionChain.TransactionData.Ownership + alias Archethic.TransactionChain.TransactionData.UCOLedger + alias Archethic.TransactionChain.TransactionData.UCOLedger.Transfer, as: UCOTransfer + alias Archethic.TransactionChain.TransactionInput + alias Archethic.TransactionChain.VersionedTransactionInput + + import Mox + + doctest Contract + + # ---------------------------------------- + describe "set_type/2" do + test "should set the type of the contract" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_type "transfer" + end + """ + + assert %Transaction{type: :transfer} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + Contract.set_type "contract" + end + """ + + assert %Transaction{type: :contract} = sanitize_parse_execute(code) + + code = ~S""" + actions triggered_by: transaction do + variable = "transfer" + Contract.set_type variable + end + """ + + assert %Transaction{type: :transfer} = sanitize_parse_execute(code) + end + + test "should not parse if the type is unknown" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_type "invalid" + end + """ + + assert {:error, _, _} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "set_content/2" do + test "should work with binary" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content "hello" + end + """ + + assert %Transaction{data: %TransactionData{content: "hello"}} = sanitize_parse_execute(code) + end + + test "should work with integer" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content 12 + end + """ + + assert %Transaction{data: %TransactionData{content: "12"}} = sanitize_parse_execute(code) + end + + test "should work with float" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_content 13.0 + end + """ + + assert %Transaction{data: %TransactionData{content: "13.0"}} = sanitize_parse_execute(code) + end + + test "should work with variable" do + code = ~S""" + actions triggered_by: transaction do + value = "foo" + Contract.set_content value + end + """ + + assert %Transaction{data: %TransactionData{content: "foo"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "set_code/2" do + test "should work with binary" do + code = ~S""" + actions triggered_by: transaction do + Contract.set_code "hello" + end + """ + + assert %Transaction{data: %TransactionData{code: "hello"}} = sanitize_parse_execute(code) + end + + test "should work with variable" do + code = ~S""" + actions triggered_by: transaction do + value = "foo" + Contract.set_code value + end + """ + + assert %Transaction{data: %TransactionData{code: "foo"}} = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "add_uco_transfer/2" do + test "should work with keyword" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + Contract.add_uco_transfer(to: "#{Base.encode16(address)}", amount: 9000) + end + """ + + assert %Transaction{ + data: %TransactionData{ + ledger: %Ledger{ + uco: %UCOLedger{ + transfers: [ + %UCOTransfer{amount: 9000, to: ^address} + ] + } + } + } + } = sanitize_parse_execute(code) + end + + test "should work with variable" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + transfer = [to: "#{Base.encode16(address)}", amount: 9000] + Contract.add_uco_transfer transfer + end + """ + + assert %Transaction{ + data: %TransactionData{ + ledger: %Ledger{ + uco: %UCOLedger{ + transfers: [ + %UCOTransfer{amount: 9000, to: ^address} + ] + } + } + } + } = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "add_token_transfer/2" do + test "should work with keyword" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + token_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + Contract.add_token_transfer(to: "#{Base.encode16(address)}", amount: 14, token_address: "#{Base.encode16(token_address)}") + end + """ + + assert %Transaction{ + data: %TransactionData{ + ledger: %Ledger{ + token: %TokenLedger{ + transfers: [ + %TokenTransfer{ + to: ^address, + amount: 14, + token_address: ^token_address, + token_id: 0 + } + ] + } + } + } + } = sanitize_parse_execute(code) + end + + test "should work with variable" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + token_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + transfer = [to: "#{Base.encode16(address)}", amount: 15, token_id: 1, token_address: "#{Base.encode16(token_address)}"] + Contract.add_token_transfer transfer + end + """ + + assert %Transaction{ + data: %TransactionData{ + ledger: %Ledger{ + token: %TokenLedger{ + transfers: [ + %TokenTransfer{ + to: ^address, + amount: 15, + token_address: ^token_address, + token_id: 1 + } + ] + } + } + } + } = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "add_recipient/2" do + test "should work with keyword" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + Contract.add_recipient("#{Base.encode16(address)}") + end + """ + + assert %Transaction{ + data: %TransactionData{ + recipients: [^address] + } + } = sanitize_parse_execute(code) + end + + test "should work when called multiple times" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + address2 = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + Contract.add_recipient("#{Base.encode16(address)}") + Contract.add_recipient("#{Base.encode16(address2)}") + end + """ + + assert %Transaction{ + data: %TransactionData{ + recipients: [^address2, ^address] + } + } = sanitize_parse_execute(code) + end + + test "should work with variable" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + transfer = "#{Base.encode16(address)}" + Contract.add_recipient transfer + end + """ + + assert %Transaction{ + data: %TransactionData{ + recipients: [^address] + } + } = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "add_ownership/2" do + test "should work with keyword" do + {pub_key1, _} = Archethic.Crypto.generate_deterministic_keypair("seed") + + code = ~s""" + actions triggered_by: transaction do + Contract.add_ownership(secret: "ENCODED_SECRET1", authorized_public_keys: ["#{Base.encode16(pub_key1)}"], secret_key: "___") + end + """ + + assert %Transaction{ + data: %TransactionData{ + ownerships: [ + %Ownership{ + authorized_keys: %{ + ^pub_key1 => _ + }, + secret: "ENCODED_SECRET1" + } + ] + } + } = sanitize_parse_execute(code) + end + + test "should work when called multiple times" do + {pub_key1, _} = Archethic.Crypto.generate_deterministic_keypair("seed") + {pub_key2, _} = Archethic.Crypto.generate_deterministic_keypair("seed2") + + code = ~s""" + actions triggered_by: transaction do + Contract.add_ownership(secret: "ENCODED_SECRET1", authorized_public_keys: ["#{Base.encode16(pub_key1)}"], secret_key: "___") + Contract.add_ownership(secret: "ENCODED_SECRET2", authorized_public_keys: ["#{Base.encode16(pub_key2)}"], secret_key: "___") + end + """ + + assert %Transaction{ + data: %TransactionData{ + ownerships: [ + %Ownership{ + authorized_keys: %{ + ^pub_key2 => _ + }, + secret: "ENCODED_SECRET2" + }, + %Ownership{ + authorized_keys: %{ + ^pub_key1 => _ + }, + secret: "ENCODED_SECRET1" + } + ] + } + } = sanitize_parse_execute(code) + end + + test "should work with variable" do + {pub_key1, _} = Archethic.Crypto.generate_deterministic_keypair("seed") + + code = ~s""" + actions triggered_by: transaction do + ownership = [secret: "ENCODED_SECRET1", authorized_public_keys: ["#{Base.encode16(pub_key1)}"], secret_key: "___"] + Contract.add_ownership(ownership) + end + """ + + assert %Transaction{ + data: %TransactionData{ + ownerships: [ + %Ownership{ + authorized_keys: %{ + ^pub_key1 => _ + }, + secret: "ENCODED_SECRET1" + } + ] + } + } = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "add_uco_transfers/2" do + test "should work" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + address2 = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + transfers = [ + [to: "#{Base.encode16(address)}", amount: 1234], + [to: "#{Base.encode16(address2)}", amount: 5678] + ] + Contract.add_uco_transfers(transfers) + end + """ + + assert %Transaction{ + data: %TransactionData{ + ledger: %Ledger{ + uco: %UCOLedger{ + transfers: [ + %UCOTransfer{amount: 5678, to: ^address2}, + %UCOTransfer{amount: 1234, to: ^address} + ] + } + } + } + } = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "add_token_transfers/2" do + test "should work" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + address2 = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + token_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + transfers = [ + [to: "#{Base.encode16(address)}", amount: 14, token_address: "#{Base.encode16(token_address)}"], + [to: "#{Base.encode16(address2)}", amount: 3,token_id: 4, token_address: "#{Base.encode16(token_address)}"] + ] + Contract.add_token_transfers(transfers) + end + """ + + assert %Transaction{ + data: %TransactionData{ + ledger: %Ledger{ + token: %TokenLedger{ + transfers: [ + %TokenTransfer{ + to: ^address2, + amount: 3, + token_address: ^token_address, + token_id: 4 + }, + %TokenTransfer{ + to: ^address, + amount: 14, + token_address: ^token_address, + token_id: 0 + } + ] + } + } + } + } = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "add_recipients/2" do + test "should work" do + address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + address2 = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + recipients = ["#{Base.encode16(address)}", "#{Base.encode16(address2)}"] + Contract.add_recipients(recipients) + end + """ + + assert %Transaction{ + data: %TransactionData{ + recipients: [^address2, ^address] + } + } = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "add_ownerships/2" do + test "should work" do + {pub_key1, _} = Archethic.Crypto.generate_deterministic_keypair("seed") + {pub_key2, _} = Archethic.Crypto.generate_deterministic_keypair("seed2") + + code = ~s""" + actions triggered_by: transaction do + ownerships = [ + [secret: "ENCODED_SECRET1", authorized_public_keys: ["#{Base.encode16(pub_key1)}"], secret_key: "___"], + [secret: "ENCODED_SECRET2", authorized_public_keys: ["#{Base.encode16(pub_key2)}"], secret_key: "___"] + ] + Contract.add_ownerships(ownerships) + end + """ + + assert %Transaction{ + data: %TransactionData{ + ownerships: [ + %Ownership{ + authorized_keys: %{ + ^pub_key2 => _ + }, + secret: "ENCODED_SECRET2" + }, + %Ownership{ + authorized_keys: %{ + ^pub_key1 => _ + }, + secret: "ENCODED_SECRET1" + } + ] + } + } = sanitize_parse_execute(code) + end + end + + # ---------------------------------------- + describe "get_calls/1" do + test "should work" do + contract_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + call_address = <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + + code = ~s""" + actions triggered_by: transaction do + calls = Contract.get_calls() + Contract.set_content List.size(calls) + end + """ + + MockDB + |> expect(:get_inputs, fn :call, ^contract_address -> + [ + %VersionedTransactionInput{ + input: %TransactionInput{ + from: call_address, + timestamp: DateTime.utc_now() + }, + protocol_version: ArchethicCase.current_protocol_version() + } + ] + end) + |> expect(:get_transaction, fn ^call_address, _, :io -> + {:ok, %Transaction{data: %TransactionData{}}} + end) + + assert %Transaction{ + data: %TransactionData{ + content: "1" + } + } = + sanitize_parse_execute(code, %{ + "contract" => %{ + "address" => Base.encode16(contract_address) + } + }) + end + end + + defp sanitize_parse_execute(code, constants \\ %{}) do + with {:ok, sanitized_code} <- Interpreter.sanitize_code(code), + {:ok, _, action_ast} <- ActionInterpreter.parse(sanitized_code) do + ActionInterpreter.execute(action_ast, constants) + end + end +end diff --git a/test/archethic/contracts/interpreter/version1/scope_test.exs b/test/archethic/contracts/interpreter/version1/scope_test.exs new file mode 100644 index 000000000..a8ec7f67e --- /dev/null +++ b/test/archethic/contracts/interpreter/version1/scope_test.exs @@ -0,0 +1,59 @@ +defmodule Archethic.Contracts.Interpreter.Version1.ScopeTest do + use ArchethicCase + + alias Archethic.Contracts.Interpreter.Version1.Scope + + doctest Scope + + test "global variables" do + Scope.init(%{"var1" => %{"prop" => 1}}) + Scope.write_at([], "var2", 2) + assert Scope.read_global(["var1", "prop"]) == 1 + assert Scope.read_global(["var2"]) == 2 + end + + test "write_at/3" do + Scope.init() + + hierarchy = ["depth1"] + Scope.create(hierarchy) + Scope.write_at(hierarchy, "var1", 1) + assert Scope.read(hierarchy, "var1") == 1 + + hierarchy = ["depth1", "depth2"] + Scope.create(hierarchy) + Scope.write_at(hierarchy, "var2", 2) + assert Scope.read(hierarchy, "var2") == 2 + end + + test "write_cascade/3" do + Scope.init() + + hierarchy1 = ["depth1"] + Scope.create(hierarchy1) + Scope.write_cascade(hierarchy1, "var1", 1) + + hierarchy2 = ["depth1", "depth2"] + Scope.create(hierarchy2) + Scope.write_cascade(hierarchy2, "var1", 2) + + assert Scope.read(hierarchy1, "var1") == 2 + assert Scope.read(hierarchy2, "var1") == 2 + end + + test "read/3" do + Scope.init(%{"map" => %{"key" => 1}}) + assert Scope.read([], "map", "key") == 1 + + hierarchy = ["xx"] + Scope.create(hierarchy) + Scope.write_at(hierarchy, "map2", %{"key2" => 2}) + assert Scope.read(hierarchy, "map2", "key2") == 2 + end + + test "update_global/2" do + Scope.init(%{"transaction" => %{"content" => "cat"}}) + Scope.update_global(["transaction"], fn t -> %{t | "content" => "dog"} end) + assert Scope.read_global(["transaction", "content"]) == "dog" + end +end diff --git a/test/archethic/contracts/interpreter/version1_test.exs b/test/archethic/contracts/interpreter/version1_test.exs index 0b35793b9..8f6ed0d33 100644 --- a/test/archethic/contracts/interpreter/version1_test.exs +++ b/test/archethic/contracts/interpreter/version1_test.exs @@ -1,8 +1,47 @@ defmodule Archethic.Contracts.Interpreter.Version1Test do - @moduledoc false use ArchethicCase + @version 1 + + alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.Contract alias Archethic.Contracts.Interpreter.Version1 doctest Version1 + + describe "parse/1" do + test "should return an error if there are unexpected terms" do + assert {:error, _} = + """ + condition transaction: [ + uco_transfers: List.size() > 0 + ] + + some_unexpected_code + + actions triggered_by: transaction do + set_content "hello" + end + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> Version1.parse(@version) + end + + test "should return the contract if format is OK" do + assert {:ok, %Contract{}} = + """ + condition transaction: [ + uco_transfers: List.size() > 0 + ] + + actions triggered_by: transaction do + Contract.set_content "hello" + end + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> Version1.parse(@version) + end + end end diff --git a/test/archethic/contracts/interpreter_test.exs b/test/archethic/contracts/interpreter_test.exs index 46650c3fd..7685357b0 100644 --- a/test/archethic/contracts/interpreter_test.exs +++ b/test/archethic/contracts/interpreter_test.exs @@ -15,12 +15,12 @@ defmodule Archethic.Contracts.InterpreterTest do test "should return an error if version does not exist yet" do code_v0 = ~s""" - @version "0.144.233" + @version 20 #{ContractFactory.valid_version0_contract()} """ code_v1 = ~s""" - @version "1.377.610" + @version 20 #{ContractFactory.valid_version1_contract(version_attribute: false)} """ @@ -30,43 +30,11 @@ defmodule Archethic.Contracts.InterpreterTest do test "should return an error if version is invalid" do code_v0 = ~s""" - @version 12 + @version 1.5 #{ContractFactory.valid_version0_contract()} """ assert {:error, "@version not supported"} = Interpreter.parse(code_v0) end end - - describe "version/1" do - test "should return 0.0.1 if there is no interpreter tag" do - code = ~s(some code) - assert {{0, 0, 1}, ^code} = Interpreter.version(code) - end - - test "should return the correct version if specified" do - assert {{0, 0, 1}, "\n my_code"} = Interpreter.version(~s(@version "0.0.1"\n my_code)) - assert {{0, 1, 0}, " \n my_code"} = Interpreter.version(~s(@version "0.1.0" \n my_code)) - assert {{0, 1, 1}, ""} = Interpreter.version(~s(@version "0.1.1")) - assert {{1, 0, 0}, _} = Interpreter.version(~s(@version "1.0.0")) - assert {{1, 0, 1}, _} = Interpreter.version(~s(@version "1.0.1")) - assert {{1, 1, 0}, _} = Interpreter.version(~s(@version "1.1.0")) - assert {{1, 1, 1}, _} = Interpreter.version(~s(@version "1.1.1")) - end - - test "should work even if there are some whitespaces" do - assert {{0, 1, 0}, _} = Interpreter.version(~s(\n \n @version "0.1.0" \n \n)) - assert {{1, 1, 2}, _} = Interpreter.version(~s(\n \n @version "1.1.2" \n \n)) - assert {{3, 105, 0}, _} = Interpreter.version(~s(\n \n @version "3.105.0" \n \n)) - end - - test "should return error if version is not formatted as expected" do - assert :error = Interpreter.version(~s(@version "0")) - assert :error = Interpreter.version(~s(@version "1")) - assert :error = Interpreter.version(~s(@version "0.0")) - assert :error = Interpreter.version(~s(@version "1.1")) - assert :error = Interpreter.version(~s(@version "0.0.0")) - assert :error = Interpreter.version(~s(@version 1.1.1)) - end - end end diff --git a/test/support/contract_factory.ex b/test/support/contract_factory.ex index 4f3c58105..ca902031b 100644 --- a/test/support/contract_factory.ex +++ b/test/support/contract_factory.ex @@ -18,7 +18,7 @@ defmodule Archethic.ContractFactory do if Keyword.get(opts, :version_attribute, true) do """ - @version "1.0.0" + @version 1 #{code} """ else