From 0d5fbd630670b0bf1bef7503afeedf86d94b2081 Mon Sep 17 00:00:00 2001 From: Samuel Manzanera Date: Fri, 2 Dec 2022 17:25:53 +0100 Subject: [PATCH] Improve smart contract interpreter(#628) To simplify the interpreter, it is now built of two interpreters: * Add Condition's interpreter * Add Action's interpreter Hence, the validation is easier to handle and to customize. This PR brings other improvements: * Update interpreters * Uniform the binary/hex handling in smart contract * Fix SC call inputs serialization * Clean inputs ETS table for SC calls * Clear pending transactions table Co-authored-by: Neylix --- .dialyzer_ignore.exs | 2 - .github/workflows/ci.yml | 2 +- lib/archethic/account/mem_tables_loader.ex | 4 + lib/archethic/contracts.ex | 44 +- lib/archethic/contracts/contract.ex | 54 +- .../contracts/contract/conditions.ex | 5 +- lib/archethic/contracts/contract/constants.ex | 67 +- lib/archethic/contracts/contract/trigger.ex | 19 - lib/archethic/contracts/interpreter.ex | 1387 +---------------- lib/archethic/contracts/interpreter/action.ex | 338 ++++ .../contracts/interpreter/condition.ex | 601 +++++++ .../contracts/interpreter/library.ex | 35 +- .../interpreter/transaction_statements.ex | 16 +- lib/archethic/contracts/interpreter/utils.ex | 547 +++++++ lib/archethic/contracts/loader.ex | 63 +- lib/archethic/contracts/transaction_lookup.ex | 72 +- lib/archethic/contracts/worker.ex | 132 +- lib/archethic/db.ex | 6 +- lib/archethic/db/embedded_impl.ex | 7 +- .../db/embedded_impl/inputs_reader.ex | 2 +- .../db/embedded_impl/inputs_writer.ex | 9 +- .../mining/pending_transaction_validation.ex | 2 +- lib/archethic/mining/proof_of_work.ex | 13 +- lib/archethic/p2p/message.ex | 7 +- lib/archethic/transaction_chain.ex | 6 + .../transaction_chain/mem_tables_loader.ex | 4 +- .../transaction/validation_stamp.ex | 24 +- .../transaction_chain/transaction_input.ex | 2 +- .../contracts/interpreter/action_test.exs | 346 ++++ .../contracts/interpreter/condition_test.exs | 280 ++++ test/archethic/contracts/interpreter_test.exs | 668 +------- test/archethic/contracts/loader_test.exs | 9 +- test/archethic/contracts/worker_test.exs | 21 +- test/archethic/contracts_test.exs | 19 +- 34 files changed, 2596 insertions(+), 2217 deletions(-) delete mode 100644 .dialyzer_ignore.exs delete mode 100644 lib/archethic/contracts/contract/trigger.ex create mode 100644 lib/archethic/contracts/interpreter/action.ex create mode 100644 lib/archethic/contracts/interpreter/condition.ex create mode 100644 lib/archethic/contracts/interpreter/utils.ex create mode 100644 test/archethic/contracts/interpreter/action_test.exs create mode 100644 test/archethic/contracts/interpreter/condition_test.exs diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs deleted file mode 100644 index 0d4f101c7..000000000 --- a/.dialyzer_ignore.exs +++ /dev/null @@ -1,2 +0,0 @@ -[ -] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fc8a0690..8aefa25ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,4 +70,4 @@ jobs: mkdir -p priv/plts mix dialyzer --plt - name: Run dialyzer - run: mix dialyzer --no-check + run: mix dialyzer --no-check --ignore-exit-status diff --git a/lib/archethic/account/mem_tables_loader.ex b/lib/archethic/account/mem_tables_loader.ex index 282da484d..f86528b03 100644 --- a/lib/archethic/account/mem_tables_loader.ex +++ b/lib/archethic/account/mem_tables_loader.ex @@ -162,6 +162,10 @@ defmodule Archethic.Account.MemTablesLoader do } } -> TokenLedger.add_unspent_output(address, unspent_output) + + _ -> + # Ignore smart contract calls + :ignore end) end diff --git a/lib/archethic/contracts.ex b/lib/archethic/contracts.ex index b9f91cac2..402618e65 100644 --- a/lib/archethic/contracts.ex +++ b/lib/archethic/contracts.ex @@ -5,9 +5,9 @@ defmodule Archethic.Contracts do """ alias __MODULE__.Contract - alias __MODULE__.Contract.Conditions - alias __MODULE__.Contract.Constants - alias __MODULE__.Contract.Trigger + alias __MODULE__.ContractConditions, as: Conditions + alias __MODULE__.ContractConstants, as: Constants + alias __MODULE__.ConditionInterpreter alias __MODULE__.Interpreter alias __MODULE__.Loader alias __MODULE__.TransactionLookup @@ -59,9 +59,8 @@ defmodule Archethic.Contracts do contract: nil, transaction: nil }, - triggers: [ - %Trigger{ - actions: {:__block__, [], [ + triggers: %{ + {:datetime, ~U[2020-09-25 13:18:43Z]} => {:__block__, [], [ { :=, [line: 7], @@ -79,11 +78,9 @@ defmodule Archethic.Contracts do ] } ]}, - opts: [at: ~U[2020-09-25 13:18:43Z]], - type: :datetime } - ] - }} + } + } """ @spec parse(binary()) :: {:ok, Contract.t()} | {:error, binary()} def parse(contract_code) when is_binary(contract_code) do @@ -100,11 +97,10 @@ defmodule Archethic.Contracts do }) cond do - Enum.any?(triggers, &(&1.type == :transaction)) and - Conditions.empty?(transaction_conditions) -> + Map.has_key?(triggers, :transaction) and Conditions.empty?(transaction_conditions) -> {:error, "missing transaction conditions"} - Enum.any?(triggers, &(&1.type == :oracle)) and Conditions.empty?(oracle_conditions) -> + Map.has_key?(triggers, :oracle) and Conditions.empty?(oracle_conditions) -> {:error, "missing oracle conditions"} true -> @@ -168,7 +164,7 @@ defmodule Archethic.Contracts do end defp validate_conditions(inherit_conditions, constants) do - if Interpreter.valid_conditions?(inherit_conditions, constants) do + if ConditionInterpreter.valid_conditions?(inherit_conditions, constants) do :ok else Logger.error("Inherit constraints not respected") @@ -176,10 +172,12 @@ defmodule Archethic.Contracts do end end - defp validate_triggers([], _, _), do: :ok + defp validate_triggers(triggers, _next_tx, _date) when map_size(triggers) == 0, do: :ok defp validate_triggers(triggers, next_tx, date) do - if Enum.any?(triggers, &valid_from_trigger?(&1, next_tx, date)) do + if Enum.any?(triggers, fn {trigger_type, _} -> + valid_from_trigger?(trigger_type, next_tx, date) + end) do :ok else Logger.error("Transaction not processed by a valid smart contract trigger") @@ -188,7 +186,7 @@ defmodule Archethic.Contracts do end defp valid_from_trigger?( - %Trigger{type: :datetime, opts: [at: datetime]}, + {:datetime, datetime}, %Transaction{}, validation_date = %DateTime{} ) do @@ -198,21 +196,25 @@ defmodule Archethic.Contracts do end defp valid_from_trigger?( - %Trigger{type: :interval, opts: [at: interval]}, + {:interval, interval}, %Transaction{}, validation_date = %DateTime{} ) do interval - |> CronParser.parse!(true) + |> CronParser.parse!() |> CronDateChecker.matches_date?(DateTime.to_naive(validation_date)) end - defp valid_from_trigger?(%Trigger{type: :transaction}, _, _), do: true + defp valid_from_trigger?(_, _, _), do: true @doc """ List the address of the transaction which has contacted a smart contract """ - @spec list_contract_transactions(binary()) :: list({binary(), DateTime.t()}) + @spec list_contract_transactions(contract_address :: binary()) :: + list( + {transaction_address :: binary(), transaction_timestamp :: DateTime.t(), + protocol_version :: non_neg_integer()} + ) defdelegate list_contract_transactions(address), to: TransactionLookup, as: :list_contract_transactions diff --git a/lib/archethic/contracts/contract.ex b/lib/archethic/contracts/contract.ex index c562859e7..d566901cc 100644 --- a/lib/archethic/contracts/contract.ex +++ b/lib/archethic/contracts/contract.ex @@ -3,9 +3,8 @@ defmodule Archethic.Contracts.Contract do Represents a smart contract """ - alias Archethic.Contracts.Contract.Conditions - alias Archethic.Contracts.Contract.Constants - alias Archethic.Contracts.Contract.Trigger + alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.ContractConstants, as: Constants alias Archethic.Contracts.Interpreter @@ -14,7 +13,7 @@ defmodule Archethic.Contracts.Contract do alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.TransactionData - defstruct triggers: [], + defstruct triggers: %{}, conditions: %{ transaction: %Conditions{}, inherit: %Conditions{}, @@ -23,12 +22,15 @@ defmodule Archethic.Contracts.Contract do constants: %Constants{}, next_transaction: %Transaction{data: %TransactionData{}} - @type trigger_type() :: :datetime | :interval | :transaction + @type trigger_type() :: + {:datetime, DateTime.t()} | {:interval, String.t()} | :transaction | :oracle @type condition() :: :transaction | :inherit | :oracle @type origin_family :: SharedSecrets.origin_family() @type t() :: %__MODULE__{ - triggers: list(Trigger.t()), + triggers: %{ + trigger_type() => Macro.t() + }, conditions: %{ transaction: Conditions.t(), inherit: Conditions.t(), @@ -55,48 +57,24 @@ defmodule Archethic.Contracts.Contract do @doc """ Add a trigger to the contract """ - @spec add_trigger(t(), Trigger.type(), Keyword.t(), Macro.t()) :: t() + @spec add_trigger(map(), trigger_type(), Macro.t()) :: t() def add_trigger( contract = %__MODULE__{}, - :datetime, - opts = [at: _datetime = %DateTime{}], + type, actions ) do - do_add_trigger(contract, %Trigger{type: :datetime, opts: opts, actions: actions}) - end - - def add_trigger( - contract = %__MODULE__{}, - :interval, - opts = [at: interval], - actions - ) - when is_binary(interval) do - do_add_trigger(contract, %Trigger{type: :interval, opts: opts, actions: actions}) - end - - def add_trigger(contract = %__MODULE__{}, :transaction, _, actions) do - do_add_trigger(contract, %Trigger{type: :transaction, actions: actions}) - end - - def add_trigger(contract = %__MODULE__{}, :oracle, _, actions) do - do_add_trigger(contract, %Trigger{type: :oracle, actions: actions}) - end - - defp do_add_trigger(contract, trigger = %Trigger{}) do - Map.update!(contract, :triggers, &(&1 ++ [trigger])) + Map.update!(contract, :triggers, &Map.put(&1, type, actions)) end @doc """ Add a condition to the contract """ - @spec add_condition(t(), condition(), any()) :: t() + @spec add_condition(map(), condition(), Conditions.t()) :: t() def add_condition( - contract = %__MODULE__{conditions: conditions}, + contract = %__MODULE__{}, condition_name, - condition - ) - when condition_name in [:transaction, :inherit, :oracle] do - %{contract | conditions: Map.put(conditions, condition_name, condition)} + conditions = %Conditions{} + ) do + Map.update!(contract, :conditions, &Map.put(&1, condition_name, conditions)) end end diff --git a/lib/archethic/contracts/contract/conditions.ex b/lib/archethic/contracts/contract/conditions.ex index fb58bb95d..81cbae15d 100644 --- a/lib/archethic/contracts/contract/conditions.ex +++ b/lib/archethic/contracts/contract/conditions.ex @@ -1,9 +1,10 @@ -defmodule Archethic.Contracts.Contract.Conditions do +defmodule Archethic.Contracts.ContractConditions do @moduledoc """ Represents the smart contract conditions """ defstruct [ + :address, :type, :content, :code, @@ -20,6 +21,7 @@ defmodule Archethic.Contracts.Contract.Conditions do alias Archethic.TransactionChain.Transaction @type t :: %__MODULE__{ + address: binary() | Macro.t() | nil, type: Transaction.transaction_type() | nil, content: binary() | Macro.t() | nil, code: binary() | Macro.t() | nil, @@ -33,6 +35,7 @@ defmodule Archethic.Contracts.Contract.Conditions do } def empty?(%__MODULE__{ + address: nil, type: nil, content: nil, code: nil, diff --git a/lib/archethic/contracts/contract/constants.ex b/lib/archethic/contracts/contract/constants.ex index 4177a6570..229a4a2bc 100644 --- a/lib/archethic/contracts/contract/constants.ex +++ b/lib/archethic/contracts/contract/constants.ex @@ -1,4 +1,4 @@ -defmodule Archethic.Contracts.Contract.Constants do +defmodule Archethic.Contracts.ContractConstants do @moduledoc """ Represents the smart contract constants and bindings """ @@ -6,7 +6,7 @@ defmodule Archethic.Contracts.Contract.Constants do defstruct [:contract, :transaction] @type t :: %__MODULE__{ - contract: map(), + contract: map() | nil, transaction: map() | nil } @@ -153,4 +153,67 @@ defmodule Archethic.Contracts.Contract.Constants do previous_public_key: Map.get(constants, "previous_public_key") } end + + @doc """ + Stringify binary transaction values + """ + @spec stringify(map()) :: map() + def stringify(constants = %{}) do + %{ + "address" => apply_not_nil(constants, "address", &Base.encode16/1), + "type" => Map.get(constants, "type"), + "content" => Map.get(constants, "content"), + "code" => Map.get(constants, "code"), + "authorized_keys" => + apply_not_nil(constants, "authorized_keys", fn authorized_keys -> + authorized_keys + |> Enum.map(fn {public_key, encrypted_secret_key} -> + {Base.encode16(public_key), Base.encode16(encrypted_secret_key)} + end) + |> Enum.into(%{}) + end), + "authorized_public_keys" => + apply_not_nil(constants, "authorized_public_keys", fn public_keys -> + Enum.map(public_keys, &Base.encode16/1) + end), + "secrets" => + apply_not_nil(constants, "secrets", fn secrets -> + Enum.map(secrets, &Base.encode16/1) + end), + "previous_public_key" => apply_not_nil(constants, "previous_public_key", &Base.encode16/1), + "recipients" => + apply_not_nil(constants, "recipients", fn recipients -> + Enum.map(recipients, &Base.encode16/1) + end), + "uco_transfers" => + apply_not_nil(constants, "uco_transfers", fn transfers -> + transfers + |> Enum.map(fn {to, amount} -> + {Base.encode16(to), amount} + end) + |> Enum.into(%{}) + end), + "token_transfers" => + apply_not_nil(constants, "token_transfers", fn transfers -> + transfers + |> Enum.map(fn {to, transfers} -> + {Base.encode16(to), + Enum.map(transfers, fn transfer -> + Map.update!(transfer, "token_address", &Base.encode16/1) + end)} + end) + end), + "timestamp" => Map.get(constants, "timestamp") + } + end + + defp apply_not_nil(map, key, fun) do + case Map.get(map, key) do + nil -> + nil + + val -> + fun.(val) + end + end end diff --git a/lib/archethic/contracts/contract/trigger.ex b/lib/archethic/contracts/contract/trigger.ex deleted file mode 100644 index 697268c09..000000000 --- a/lib/archethic/contracts/contract/trigger.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Archethic.Contracts.Contract.Trigger do - @moduledoc """ - Represents the smart contract triggers - """ - - defstruct [:type, :actions, opts: []] - - @type timestamp :: non_neg_integer() - @type interval :: binary() - @type address :: binary() - - @type type() :: :datetime | :interval | :transaction | :oracle - - @type t :: %__MODULE__{ - type: type(), - opts: Keyword.t(), - actions: Macro.t() - } -end diff --git a/lib/archethic/contracts/interpreter.ex b/lib/archethic/contracts/interpreter.ex index b5c9c69dd..f09c8df62 100644 --- a/lib/archethic/contracts/interpreter.ex +++ b/lib/archethic/contracts/interpreter.ex @@ -3,51 +3,12 @@ defmodule Archethic.Contracts.Interpreter do require Logger - alias Crontab.CronExpression.Parser, as: CronParser + alias Archethic.Contracts.ActionInterpreter + alias Archethic.Contracts.ConditionInterpreter - alias __MODULE__.Library - alias __MODULE__.TransactionStatements + alias __MODULE__.Utils, as: InterpreterUtils alias Archethic.Contracts.Contract - alias Archethic.Contracts.Contract.Conditions - - alias Archethic.SharedSecrets - - alias Archethic.TransactionChain.Transaction - alias Archethic.TransactionChain.TransactionData - - @library_functions_names Library.__info__(:functions) - |> Enum.map(&Atom.to_string(elem(&1, 0))) - - @transaction_statements_functions_names TransactionStatements.__info__(:functions) - |> Enum.map(&Atom.to_string(elem(&1, 0))) - - @transaction_fields [ - "address", - "type", - "timestamp", - "previous_signature", - "previous_public_key", - "origin_signature", - "content", - "keys", - "code", - "uco_ledger", - "token_ledger", - "uco_transfers", - "token_transfers", - "authorized_public_keys", - "secrets", - "recipients" - ] - - @condition_fields Conditions.__struct__() - |> Map.keys() - |> Enum.reject(&(&1 == :__struct__)) - |> Enum.map(&Atom.to_string/1) - - @transaction_types Transaction.types() |> Enum.map(&Atom.to_string/1) - @origin_families SharedSecrets.list_origin_families() |> Enum.map(&Atom.to_string/1) @doc ~S""" Parse a smart contract code and return the filtered AST representation. @@ -84,7 +45,7 @@ defmodule Archethic.Contracts.Interpreter do {:ok, %Contract{ conditions: %{ - inherit: %Archethic.Contracts.Contract.Conditions{ + inherit: %Archethic.Contracts.ContractConditions{ content: { :==, [line: 7], @@ -101,7 +62,7 @@ defmodule Archethic.Contracts.Interpreter do type: nil, uco_transfers: nil }, - oracle: %Archethic.Contracts.Contract.Conditions{ + oracle: %Archethic.Contracts.ContractConditions{ content: { :>, [line: 11], @@ -118,14 +79,14 @@ defmodule Archethic.Contracts.Interpreter do ] }, authorized_keys: nil, - code: nil, + code: nil, token_transfers: nil, origin_family: :all, previous_public_key: nil, type: nil, uco_transfers: nil }, - transaction: %Archethic.Contracts.Contract.Conditions{ + transaction: %Archethic.Contracts.ContractConditions{ content: { :==, [line: 2], @@ -143,11 +104,10 @@ defmodule Archethic.Contracts.Interpreter do uco_transfers: nil } }, - constants: %Constants{}, + constants: %Archethic.Contracts.ContractConstants{}, next_transaction: %Transaction{ data: %TransactionData{}}, - triggers: [ - %Trigger{ - actions: {:__block__, [], + triggers: %{ + {:datetime, ~U[2020-10-21 08:56:43Z]} => {:__block__, [], [ {:=, [line: 15], [{:scope, [line: 15], nil}, {{:., [line: 15], [{:__aliases__, [line: 15], [:Map]}, :put]}, [line: 15], [{:scope, [line: 15], nil}, "new_content", "Sent 1040000000"]}]}, { @@ -171,78 +131,57 @@ defmodule Archethic.Contracts.Interpreter do [line: 18], [ {:scope, [line: 18], nil}, - {:update_in, [line: 18], [{:scope, [line: 18], nil}, ["next_transaction"], {:&, [line: 18], [{{:., [line: 18], [{:__aliases__, [alias: Archethic.Contracts.Interpreter.TransactionStatements], [:TransactionStatements]}, :add_uco_transfer]}, [line: 18], [{:&, [line: 18], [1]}, [{"to", <<34, 54, 139, 80, 211, 178, 151, 103, 135, 207, 204, 39, 80, 138, 142, 140, 103, 72, 50, 25, 130, 95, 153, 143, 201, 214, 144, 141, 84, 208, 254, 16>>}, {"amount", 1040000000}]]}]}]} + {:update_in, [line: 18], [{:scope, [line: 18], nil}, ["next_transaction"], {:&, [line: 18], [{{:., [line: 18], [{:__aliases__, [alias: Archethic.Contracts.Interpreter.TransactionStatements], [:TransactionStatements]}, :add_uco_transfer]}, [line: 18], [{:&, [line: 18], [1]}, [{"to", "22368B50D3B2976787CFCC27508A8E8C67483219825F998FC9D6908D54D0FE10"}, {"amount", 1040000000}]]}]}]} ] } ]}, - opts: [at: ~U[2020-10-21 08:56:43Z]], - type: :datetime - }, - %Trigger{actions: { + :oracle => { :=, [line: 22], [ {:scope, [line: 22], nil}, {:update_in, [line: 22], [{:scope, [line: 22], nil}, ["next_transaction"], {:&, [line: 22], [{{:., [line: 22], [{:__aliases__, [alias: Archethic.Contracts.Interpreter.TransactionStatements], [:TransactionStatements]}, :set_content]}, [line: 22], [{:&, [line: 22], [1]}, "uco price changed"]}]}]} ] - }, opts: [], type: :oracle} - ] + } + } } } - # Returns an error when there are invalid trigger options + Returns an error when there are invalid trigger options - # iex> Interpreter.parse(" - # ...> actions triggered_by: datetime, at: 0000000 do - # ...> end - # ...> ") - # {:error, "invalid trigger - invalid datetime - arguments at:0 - L1"} + iex> Interpreter.parse(" + ...> actions triggered_by: datetime, at: 0000000 do + ...> end + ...> ") + {:error, "invalid datetime's trigger"} - # Returns an error when a invalid term is provided + Returns an error when a invalid term is provided - # iex> Interpreter.parse(" - # ...> actions do - # ...> System.user_home - # ...> end - # ...> ") - # {:error, "unexpected token - System - L2"} + iex> Interpreter.parse(" + ...> actions triggered_by: transaction do + ...> System.user_home + ...> end + ...> ") + {: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} <- - Code.string_to_quoted(String.trim(code), - static_atoms_encoder: &atom_encoder/2 - ), - {_, {:ok, %{contract: contract}}} <- - Macro.traverse( - ast, - {:ok, %{scope: :root, contract: %Contract{}}}, - &prewalk/2, - &postwalk/2 - ) do + with {:ok, ast} <- sanitize_code(code), + {:ok, contract} <- parse_contract(ast, %Contract{}) do {:ok, contract} else - {_node, {:error, reason}} -> - {:error, format_error_reason(reason)} + {:error, {meta, {_, info}, token}} -> + {:error, InterpreterUtils.format_error_reason({token, meta, []}, info)} - {:error, reason} -> - {:error, format_error_reason(reason)} - end - catch - {{:error, reason}, {:., metadata, [{:__aliases__, _, atom: cause}, _]}} -> - {:error, format_error_reason({metadata, reason, cause})} - - {{:error, :unexpected_token}, {:atom, key}} -> - {:error, format_error_reason({[], "unexpected_token", key})} - - {{:error, :unexpected_token}, {{:atom, key}, metadata, _}} -> - {:error, format_error_reason({metadata, "unexpected_token", key})} + {:error, {meta, info, token}} -> + {:error, InterpreterUtils.format_error_reason({token, meta, []}, info)} - {{:error, :unexpected_token}, {{:atom, key}, _}} -> - {:error, format_error_reason({[], "unexpected_token", key})} + {:error, {:unexpected_term, ast}} -> + {:error, InterpreterUtils.format_error_reason(ast, "unexpected term")} - {:error, reason = {_metadata, _message, _cause}} -> - {:error, format_error_reason(reason)} + {:error, reason} -> + {:error, reason} + end end defp atom_encoder(atom, _) do @@ -253,1244 +192,52 @@ defmodule Archethic.Contracts.Interpreter do end end - defp format_error_reason({metadata, message, cause}) do - message = prepare_message(message) - - [prepare_message(message), cause, metadata_to_string(metadata)] - |> Enum.reject(&(&1 == "")) - |> Enum.join(" - ") - end - - defp prepare_message(message) when is_atom(message) do - message |> Atom.to_string() |> String.replace("_", " ") - end - - defp prepare_message(message) when is_binary(message) do - String.trim_trailing(message, ":") - end - - defp metadata_to_string(line: line, column: column), do: "L#{line}:C#{column}" - defp metadata_to_string(line: line), do: "L#{line}" - defp metadata_to_string(_), do: "" - - # Whitelist operators - defp prewalk(node = {:+, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:-, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:/, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:*, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:>, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:<, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:>=, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:<=, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:|>, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:==, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - # Allow variable assignation inside the actions - defp prewalk(node = {:=, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - # Allow variable as function argument - defp prewalk(node = {{:atom, _}, {{:atom, _varname}, _, _}}, acc = {:ok, %{scope: scope}}) - when scope != :root do - {node, acc} - end - - # Whitelist the use of doted statement - defp prewalk(node = {{:., _, [{_, _, _}, _]}, _, []}, acc = {:ok, %{scope: scope}}) - when scope != :root, - do: {node, acc} - - # # Whitelist the definition of globals in the root - # defp prewalk(node = {:@, _, [{key, _, [val]}]}, acc = {:ok, :root}) - # when is_atom(key) and not is_nil(val), - # do: {node, acc} - - # # Whitelist the use of globals - # defp prewalk(node = {:@, _, [{key, _, nil}]}, acc = {:ok, _}) when is_atom(key), - # do: {node, acc} - - # Whitelist conditional oeprators - defp prewalk(node = {:if, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = {:else, _}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = [do: _, else: _], acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = :else, acc = {:ok, %{scope: scope}}) when scope != :root, do: {node, acc} - - defp prewalk(node = {:and, _, _}, acc = {:ok, _}), do: {node, acc} - defp prewalk(node = {:or, _, _}, acc = {:ok, _}), do: {node, acc} - - # Whitelist the in operation - defp prewalk(node = {:in, _, [_, _]}, acc = {:ok, _}), do: {node, acc} - - # Whitelist maps - defp prewalk(node = {:%{}, _, fields}, acc = {:ok, _}) when is_list(fields), do: {node, acc} - - defp prewalk(node = {key, _val}, acc) when is_binary(key) do - {node, acc} - end - - # Whitelist the multiline - defp prewalk(node = {{:__block__, _, _}}, acc = {:ok, _}) do - {node, acc} - end - - defp prewalk(node = {:__block__, _, _}, acc = {:ok, _}) do - {node, acc} - end - - # Whitelist custom atom - defp prewalk(node = :atom, acc), do: {node, acc} - - # Whitelist the actions DSL - defp prewalk(node = {{:atom, "actions"}, _, _}, {:ok, acc = %{scope: :root}}) do - {node, {:ok, %{acc | scope: :actions}}} - end - - # Whitelist the triggered_by DSL in the actions - defp prewalk( - node = [ - {{:atom, "triggered_by"}, {{:atom, trigger_type}, meta = [line: _], _}} | trigger_opts - ], - acc = {:ok, %{scope: :actions}} - ) - when trigger_type in ["datetime", "interval", "transaction", "oracle"] do - case valid_trigger_opts(trigger_type, trigger_opts) do - :ok -> - {node, acc} - - {:error, reason} -> - params = Enum.map_join(trigger_opts, ", ", fn {{:atom, k}, v} -> "#{k}:#{v}" end) - - {node, {:error, {meta, "invalid trigger - #{reason}", "arguments #{params}"}}} - end - end - - defp prewalk( - node = {{:atom, "triggered_by"}, {{:atom, trigger_type}, _, _}}, - acc = {:ok, %{scope: :actions}} - ) - when trigger_type in ["datetime", "interval", "transaction", "oracle"], - do: {node, acc} - - defp prewalk(node = {:atom, trigger_type}, acc = {:ok, %{scope: :actions}}) - when trigger_type in ["datetime", "interval", "transaction", "oracle"], - do: {node, acc} - - # Whitelist triggers 'at' argument - defp prewalk(node = {{:atom, "at"}, _arg}, acc = {:ok, %{scope: :actions}}), do: {node, acc} - - # Whitelist the condition DSL - defp prewalk( - node = {{:atom, "condition"}, _, [{{:atom, condition_name}, [_]}]}, - {:ok, acc = %{scope: :root}} - ) - when condition_name in ["inherit", "transaction", "oracle"] do - {node, {:ok, %{acc | scope: :condition}}} - end - - defp prewalk(node = [{{:atom, condition}, [_ | _]}], acc = {:ok, %{scope: :condition}}) - when condition in ["inherit", "transaction", "oracle"] do - {node, acc} - end - - defp prewalk( - node = {{:atom, "condition"}, _, [[{{:atom, condition_name}, _}]]}, - {:ok, acc = %{scope: :root}} - ) - when condition_name in ["inherit", "transaction", "oracle"] do - {node, {:ok, %{acc | scope: :condition}}} - end - - defp prewalk(node = [{{:atom, _}, _} | _], acc = {:ok, %{scope: :condition}}) do - {node, acc} - end - - defp prewalk(node = {{:atom, condition_name}, _}, acc = {:ok, %{scope: :condition}}) - when condition_name in ["inherit", "transaction", "oracle"] do - {node, acc} - end - - defp prewalk(node = {:atom, condition_name}, acc = {:ok, %{scope: :condition}}) - when condition_name in ["inherit", "transaction", "oracle"], - do: {node, acc} - - defp prewalk(node = {:atom, field_name}, acc = {:ok, %{scope: :condition}}) - when field_name in @condition_fields, - do: {node, acc} - - # Whitelist the usage of map in the conditions - # Example: - # - # ``` - # condition inehrit: [ - # uco_transfers: [%{ to: "address", amount: 1000000 }] - # ] - # ``` - defp prewalk(node = {{:atom, _key}, _val}, acc = {:ok, %{scope: :condition}}) do - {node, acc} - end - - # Whitelist the usage transaction fields - defp prewalk( - node = {:., _, [{{:atom, transaction_ref}, _, nil}, {:atom, type}]}, - acc - ) - when transaction_ref in ["next", "previous", "transaction", "contract"] and - type in @transaction_fields do - {node, acc} - end - - defp prewalk( - node = - {{:atom, _}, {{:., _, [{{:atom, transaction_ref}, _, nil}, {:atom, type}]}, _, _}}, - acc - ) - when transaction_ref in ["next", "previous", "transaction", "contract"] and - type in @transaction_fields do - {node, acc} - end - - # Whitelist the use of list - defp prewalk(node = [{{:atom, _}, _, nil} | _], acc = {:ok, %{scope: scope}}) - when scope != :root do - {node, acc} - end - - # Whitelist access to map field - defp prewalk( - node = {{:., _, [Access, :get]}, _, _}, - acc = {:ok, %{scope: scope}} - ) - when scope != :root do - {node, acc} - end - - defp prewalk(node = {:., _, [Access, :get]}, acc = {:ok, %{scope: scope}}) when scope != :root, - do: {node, acc} - - defp prewalk(node = Access, acc), do: {node, acc} - defp prewalk(node = :get, acc), do: {node, acc} - - # Whitelist condition fields - - # Whitelist the origin family condition - defp prewalk( - node = {{:atom, "origin_family"}, {{:atom, family}, metadata, nil}}, - acc = {:ok, %{scope: :condition}} - ) do - if family in @origin_families do - {node, acc} - else - {node, {:error, metadata, "unexpected token"}, "invalid origin family"} - end - end - - defp prewalk(node = {:atom, origin_family}, acc = {:ok, %{scope: :condition}}) - when origin_family in @origin_families, - do: {node, acc} - - # Whitelist the transaction type condition - defp prewalk( - node = {{:atom, "type"}, {{:atom, type}, metadata, nil}}, - acc = {:ok, %{scope: :condition}} - ) do - if type in @transaction_types do - {node, acc} - else - {node, {:error, metadata, "unexpected token"}, "invalid transaction type"} - end - end - - defp prewalk( - node = {{:atom, field}, _}, - acc = {:ok, %{scope: :condition}} - ) - when field in @condition_fields do - {node, acc} - end - - defp prewalk( - node = {{:atom, field}, [line: _], _}, - acc = {:ok, %{scope: {:function, _}}} - ) - when field in @transaction_fields do - {node, acc} - end - - # Whitelist the size/1 function - defp prewalk( - node = {{:atom, "size"}, _, [_data]}, - acc = {:ok, %{scope: scope}} - ) - when scope != :root do - {node, acc} - end - - # Whitelist the hash/1 function - defp prewalk(node = {{:atom, "hash"}, _, [_data]}, acc = {:ok, %{scope: scope}}) - when scope != :root, - do: {node, acc} - - # Whitelist the regex_match?/2 function - defp prewalk( - node = {{:atom, "regex_match?"}, _, [_input, _search]}, - acc = {:ok, %{scope: scope}} - ) - when scope != :root, - do: {node, acc} - - # Whitelist the regex_extract/2 function - defp prewalk( - node = {{:atom, "regex_extract"}, _, [_input, _search]}, - acc = {:ok, %{scope: scope}} - ) - when scope != :root, - do: {node, acc} - - # Whitelist the json_path_extract/2 function - defp prewalk( - node = {{:atom, "json_path_extract"}, _, [_input, _search]}, - acc = {:ok, %{scope: scope}} - ) - when scope != :root, - do: {node, acc} - - # Whitelist the json_path_match?/2 function - defp prewalk( - node = {{:atom, "json_path_match?"}, _, [_input, _search]}, - acc = {:ok, %{scope: scope}} - ) - when scope != :root, - do: {node, acc} - - # Whitelist the get_genesis_address/1 function - defp prewalk( - node = {{:atom, "get_genesis_address"}, _, [_address]}, - acc = {:ok, %{scope: scope}} - ) - when scope != :root do - {node, acc} - end - - # Whitelist the timestamp/0 function - defp prewalk(node = {{:atom, "timestamp"}, _, []}, acc = {:ok, %{scope: scope}}) - when scope != :root, - do: {node, acc} - - defp prewalk( - node = {{:atom, "get_genesis_public_key"}, _, [_address]}, - acc = {:ok, %{scope: scope}} - ) - when scope != :root do - {node, acc} - end - - # Whitelist the regex_match?/1 function in the condition - defp prewalk( - node = {{:atom, "regex_match?"}, _, [_search]}, - acc = {:ok, %{scope: :condition}} - ) do - {node, acc} - end - - # Whitelist the json_path_extract/1 function in the condition - defp prewalk( - node = {{:atom, "json_path_extract"}, _, [_search]}, - acc = {:ok, %{scope: :condition}} - ) do - {node, acc} - end - - # Whitelist the json_path_match?/1 function in the condition - defp prewalk( - node = {{:atom, "json_path_match?"}, _, [_search]}, - acc = {:ok, %{scope: :condition}} - ) do - {node, acc} - end - - # Whitelist the hash/0 function in the condition - defp prewalk( - node = {{:atom, "hash"}, _, []}, - acc = {:ok, %{scope: :condition}} - ) do - {node, acc} - end - - # Whitelist the in?/1 function in the condition - defp prewalk( - node = {{:atom, "in?"}, _, [_data]}, - acc = {:ok, %{scope: :condition}} - ) do - {node, acc} - end - - # Whitelist the size/0 function in the condition - defp prewalk(node = {{:atom, "size"}, _, []}, acc = {:ok, %{scope: :condition}}), - do: {node, acc} - - # Whitelist the get_genesis_address/0 function in condition - defp prewalk( - node = {{:atom, "get_genesis_address"}, _, []}, - acc = {:ok, %{scope: :condition}} - ) do - {node, acc} - end - - # Whitelist the get_genesis_public_key/0 function in condition - defp prewalk( - node = {{:atom, "get_genesis_public_key"}, _, []}, - acc = {:ok, %{scope: :condition}} - ) do - {node, acc} - end - - # Whitelist the timestamp/0 function in condition - defp prewalk(node = {{:atom, "timestamp"}, _, []}, acc = {:ok, %{scope: :condition}}), - do: {node, acc} - - # Whitelist the used of functions in the actions - defp prewalk(node = {{:atom, fun_name}, _, _}, {:ok, acc = %{scope: :actions}}) - when fun_name in @transaction_statements_functions_names, - do: {node, {:ok, %{acc | scope: {:function, fun_name, :actions}}}} - - defp prewalk( - node = [{{:atom, _variable_name}, _, nil}], - acc = {:ok, %{scope: {:function, "set_content", :actions}}} - ) do - {node, acc} - end - - # Whitelist the add_uco_transfer argument list - defp prewalk( - node = [{{:atom, "to"}, _to}, {{:atom, "amount"}, _amount}], - acc = {:ok, %{scope: {:function, "add_uco_transfer", :actions}}} - ) do - {node, acc} - end - - defp prewalk( - node = {{:atom, arg}, _}, - acc = {:ok, %{scope: {:function, "add_uco_transfer", :actions}}} - ) - when arg in ["to", "amount"], - do: {node, acc} - - # Whitelist the add_token_tranfser argument list - defp prewalk( - node = [ - {{:atom, "to"}, _to}, - {{:atom, "amount"}, _amount}, - {{:atom, "token_address"}, _token_address}, - {{:atom, "token_id"}, _token_id} - ], - acc = {:ok, %{scope: {:function, "add_token_transfer", :actions}}} - ) do - {node, acc} - end - - defp prewalk( - node = {{:atom, arg}, _}, - acc = {:ok, %{scope: {:function, "add_token_transfer", :actions}}} - ) - when arg in ["to", "amount", "token_address", "token_id"], - do: {node, acc} - - # Whitelist the add_ownership argument list - defp prewalk( - node = [ - {{:atom, "secret"}, _secret}, - {{:atom, "secret_key"}, _secret_key}, - {{:atom, "authorized_public_keys"}, _authorized_public_keys} - ], - acc = {:ok, %{scope: {:function, "add_ownership", :actions}}} - ) do - {node, acc} - end - - defp prewalk( - node = {{:atom, arg}, _}, - acc = {:ok, %{scope: {:function, "add_ownership", :actions}}} - ) - when arg in ["secret", "secret_key", "authorized_public_keys"], - do: {node, acc} - - # Whitelist generics - defp prewalk(true, acc = {:ok, _}), do: {true, acc} - defp prewalk(false, acc = {:ok, _}), do: {false, acc} - defp prewalk(number, acc = {:ok, _}) when is_number(number), do: {number, acc} - defp prewalk(string, acc = {:ok, _}) when is_binary(string), do: {string, acc} - defp prewalk(node = [do: _], acc = {:ok, _}), do: {node, acc} - defp prewalk(node = {:do, _}, acc = {:ok, _}), do: {node, acc} - defp prewalk(node = :do, acc = {:ok, _}), do: {node, acc} - # Literals - defp prewalk(node = {{:atom, key}, _, nil}, acc = {:ok, _}) when is_binary(key), - do: {node, acc} - - defp prewalk(node = {:atom, key}, acc = {:ok, _}) when is_binary(key), do: {node, acc} - - # Whitelist interpolation of strings - defp prewalk( - node = - {:<<>>, _, [{:"::", _, [{{:., _, [Kernel, :to_string]}, _, _}, {:binary, _, nil}]}, _]}, - acc - ) do - {node, acc} - end - - defp prewalk( - node = - {:<<>>, _, - [ - _, - {:"::", _, [{{:., _, [Kernel, :to_string]}, _, _}, _]} - ]}, - acc - ) do - {node, acc} - end - - defp prewalk(node = {:"::", _, [{{:., _, [Kernel, :to_string]}, _, _}, _]}, acc) do - {node, acc} - end - - defp prewalk(node = {{:., _, [Kernel, :to_string]}, _, _}, acc) do - {node, acc} - end - - defp prewalk(node = {:., _, [Kernel, :to_string]}, acc) do - {node, acc} - end - - defp prewalk(node = Kernel, acc), do: {node, acc} - defp prewalk(node = :to_string, acc), do: {node, acc} - defp prewalk(node = {:binary, _, nil}, acc), do: {node, acc} - - defp prewalk(node, acc) when is_list(node) do - {node, acc} - end - - # Blacklist anything else - defp prewalk(node, {:ok, _acc}) do - throw({{:error, :unexpected_token}, node}) - end - - defp prewalk(node, e = {:error, _}), do: {node, e} - - # Reset the scope after actions triggered block ending - defp postwalk( - node = - {{:atom, "actions"}, [line: _], - [[{{:atom, "triggered_by"}, {{:atom, trigger_type}, _, _}} | opts], [do: actions]]}, - {:ok, acc} - ) do - actions = - inject_bindings_and_functions(actions, - bindings: %{ - "contract" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}), - "transaction" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) - } - ) - - acc = - case trigger_type do - "datetime" -> - [{{:atom, "at"}, timestamp}] = opts - datetime = DateTime.from_unix!(timestamp) - - Map.update!( - acc, - :contract, - &Contract.add_trigger(&1, :datetime, [at: datetime], actions) - ) - - "interval" -> - [{{:atom, "at"}, interval}] = opts - - Map.update!( - acc, - :contract, - &Contract.add_trigger( - &1, - :interval, - [at: interval], - actions - ) - ) - - "transaction" -> - Map.update!(acc, :contract, &Contract.add_trigger(&1, :transaction, [], actions)) - - "oracle" -> - Map.update!(acc, :contract, &Contract.add_trigger(&1, :oracle, [], actions)) - end - - {node, {:ok, %{acc | scope: :root}}} - end - - # Add conditions with brackets - defp postwalk( - node = {{:atom, "condition"}, _, [[{{:atom, condition_name}, conditions}]]}, - {:ok, acc} - ) do - bindings = Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) - - bindings = - case condition_name do - "inherit" -> - Map.merge(bindings, %{ - "next" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}), - "previous" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) - }) - - _ -> - Map.merge(bindings, %{ - "contract" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}), - "transaction" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) - }) - end - - subject_scope = if condition_name == "inherit", do: "next", else: "transaction" - - conditions = - inject_bindings_and_functions(conditions, - bindings: bindings, - subject: subject_scope - ) - - new_acc = - acc - |> Map.update!( - :contract, - &Contract.add_condition( - &1, - String.to_existing_atom(condition_name), - aggregate_conditions(conditions, subject_scope) - ) - ) - |> Map.put(:scope, :root) - - {node, {:ok, new_acc}} - end - - # Add complex conditions with if statements - defp postwalk( - node = - {{:atom, "condition"}, _, - [ - {{:atom, condition_name}, _, - [ - conditions - ]} - ]}, - {:ok, acc} - ) do - subject_scope = if condition_name == "inherit", do: "next", else: "transaction" - - bindings = Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) - - bindings = - case condition_name do - "inherit" -> - Map.merge(bindings, %{ - "next" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}), - "previous" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) - }) - - _ -> - Map.merge(bindings, %{ - "contract" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}), - "transaction" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) - }) - end - - conditions = - inject_bindings_and_functions(conditions, - bindings: bindings, - subject: subject_scope - ) - - new_acc = - acc - |> Map.update!( - :contract, - &Contract.add_condition( - &1, - String.to_existing_atom(condition_name), - aggregate_conditions(conditions, subject_scope) - ) - ) - |> Map.put(:scope, :root) - - {node, {:ok, new_acc}} - end - - # Return to the parent scope after parsing the function call - defp postwalk(node = {{:atom, _}, _, _}, {:ok, acc = %{scope: {:function, _, scope}}}) do - {node, {:ok, %{acc | scope: scope}}} - end - - # Convert Access key string to binary - defp postwalk( - {{:., meta1, [Access, :get]}, meta2, - [{{:., meta3, [{subject, meta4, nil}, {:atom, field}]}, meta5, []}, {:atom, key}]}, - acc = {:ok, _} - ) do - { - {{:., meta1, [Access, :get]}, meta2, - [ - {{:., meta3, [{subject, meta4, nil}, String.to_existing_atom(field)]}, meta5, []}, - Base.decode16!(key, case: :mixed) - ]}, - acc - } - end - - # Convert map key to binary - defp postwalk({:%{}, meta, params}, acc = {:ok, _}) do - encoded_params = - Enum.map(params, fn - {{:atom, key}, value} when is_binary(key) -> - case Base.decode16(key, case: :mixed) do - {:ok, bin} -> - {bin, value} - - :error -> - {key, value} - end - - {key, value} -> - {key, value} - end) - - {{:%{}, meta, encoded_params}, acc} - end - - defp postwalk(node, acc), do: {node, acc} - - defp valid_trigger_opts("datetime", [{{:atom, "at"}, timestamp}]) do - if length(Integer.digits(timestamp)) != 10 do - {:error, "invalid datetime"} - else - case DateTime.from_unix(timestamp) do - {:ok, _} -> - :ok - - _ -> - {:error, "invalid datetime"} - end - end - end - - defp valid_trigger_opts("interval", [{{:atom, "at"}, interval}]) do - case CronParser.parse(interval) do - {:ok, _} -> - :ok - - {:error, _} -> - {:error, "invalid interval"} - end - end - - defp valid_trigger_opts("transaction", []), do: :ok - defp valid_trigger_opts("oracle", []), do: :ok - - defp valid_trigger_opts(_, _), do: {:error, "unexpected token"} - - defp aggregate_conditions(conditions, subject_scope) do - Enum.reduce(conditions, %Conditions{}, fn {subject, condition}, acc -> - condition = do_aggregate_condition(condition, subject_scope, subject) - Map.put(acc, String.to_existing_atom(subject), condition) - end) - end - - defp do_aggregate_condition(condition, _, "origin_family"), - do: String.to_existing_atom(condition) - - defp do_aggregate_condition(condition, subject_scope, subject) - when is_binary(condition) or is_number(condition) do - {:==, [], - [ - {:get_in, [], [{:scope, [], nil}, [subject_scope, subject]]}, - condition - ]} + @spec sanitize_code(binary()) :: {:ok, Macro.t()} | {:error, any()} + def sanitize_code(code) when is_binary(code) do + code + |> String.trim() + |> Code.string_to_quoted(static_atoms_encoder: &atom_encoder/2) end - defp do_aggregate_condition(condition, subject_scope, subject) when is_list(condition) do - {:==, [], - [ - {:get_in, [], - [ - {:scope, [], nil}, - [subject_scope, subject] - ]}, - condition - ]} + defp parse_contract({:__block__, _, ast}, contract) do + parse_ast_block(ast, contract) end - defp do_aggregate_condition(condition, subject_scope, subject) do - Macro.postwalk(condition, &to_boolean_expression(&1, subject_scope, subject)) + defp parse_contract(ast, contract) do + parse_ast(ast, contract) end - defp to_boolean_expression( - {{:., metadata, [{:__aliases__, _, [:Library]}, fun]}, _, args}, - subject_scope, - subject - ) do - arguments = - if :erlang.function_exported(Library, fun, length(args)) do - # If the number of arguments fullfill the function's arity (without subject) - args - else - [ - {:get_in, metadata, [{:scope, metadata, nil}, [subject_scope, subject]]} | args - ] - end + defp parse_ast_block([ast | rest], contract) do + case parse_ast(ast, contract) do + {:ok, contract} -> + parse_ast_block(rest, contract) - if fun |> Atom.to_string() |> String.ends_with?("?") do - {:==, metadata, - [ - true, - {{:., metadata, [{:__aliases__, [alias: Library], [:Library]}, fun]}, metadata, - arguments} - ]} - else - {:==, metadata, - [ - {:get_in, metadata, [{:scope, metadata, nil}, [subject_scope, subject]]}, - {{:., metadata, [{:__aliases__, [alias: Library], [:Library]}, fun]}, metadata, - arguments} - ]} + {:error, _} = e -> + e end end - defp to_boolean_expression(condition = {:%{}, _, _}, subject_scope, subject) do - {:==, [], - [ - {:get_in, [], [{:scope, [], nil}, [subject_scope, subject]]}, - condition - ]} - end - - # Flatten comparison operations - defp to_boolean_expression({op, _, [{:==, metadata, [{:get_in, _, _}, comp_a]}, comp_b]}, _, _) - when op in [:==, :>=, :<=] do - {op, metadata, [comp_a, comp_b]} - end - - defp to_boolean_expression(condition, _, _), do: condition - - @doc """ - - ## Examples + defp parse_ast_block([], contract), do: {:ok, contract} - iex> Interpreter.execute_actions(%Contract{ - ...> triggers: [ - ...> %Contract.Trigger{ - ...> type: :transaction, - ...> actions: {:=, [line: 2], - ...> [ - ...> {:scope, [line: 2], nil}, - ...> {:update_in, [line: 2], - ...> [ - ...> {:scope, [line: 2], nil}, - ...> ["next_transaction"], - ...> {:&, [line: 2], - ...> [ - ...> {{:., [line: 2], - ...> [ - ...> {:__aliases__, - ...> [alias: Archethic.Contracts.Interpreter.TransactionStatements], - ...> [:TransactionStatements]}, - ...> :set_type - ...> ]}, [line: 2], [{:&, [line: 2], [1]}, "transfer"]} - ...> ]} - ...> ]} - ...> ]} - ...> } - ...> ] - ...> }, :transaction) - %Transaction{type: :transfer, data: %TransactionData{}} + 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)} - iex> Interpreter.execute_actions(%Contract{ - ...> triggers: [ - ...> %Contract.Trigger{ - ...> type: :transaction, - ...> actions: {:__block__, [], [ - ...> { - ...> :=, - ...> [{:line, 2}], - ...> [ - ...> {:scope, [{:line, 2}], nil}, - ...> {:update_in, [line: 2], [ - ...> {:scope, [line: 2], nil}, - ...> ["next_transaction"], - ...> {:&, [line: 2], [ - ...> {{:., [line: 2], [{:__aliases__, [alias: Archethic.Contracts.Interpreter.TransactionStatements], [:TransactionStatements]}, :set_type]}, - ...> [line: 2], - ...> [{:&, [line: 2], [1]}, "transfer"]}] - ...> } - ...> ]} - ...> ] - ...> }, - ...> { - ...> :=, - ...> [line: 3], - ...> [ - ...> {:scope, [line: 3], nil}, - ...> {:update_in, [line: 3], [ - ...> {:scope, [line: 3], nil}, - ...> ["next_transaction"], - ...> {:&, [line: 3], [ - ...> { - ...> {:., [line: 3], [{:__aliases__, [alias: Archethic.Contracts.Interpreter.TransactionStatements], [:TransactionStatements]}, :add_uco_transfer]}, - ...> [line: 3], [ - ...> {:&, [line: 3], [1]}, - ...> [ - ...> {"to", "005220865F2237E3B62FFAA2AB72260A9FA711FBADF7F1DA391AB02B93D9E0D4A3"}, - ...> {"amount", 10.04} - ...> ] - ...> ] - ...> } - ...> ]} - ...> ]}] - ...> } - ...> ]}, - ...> }]}, :transaction) - %Transaction{ - type: :transfer, - data: %TransactionData{ - ledger: %Ledger{ - uco: %UCOLedger{ - transfers: [ - %UCOTransfer{ - to: <<0, 82, 32, 134, 95, 34, 55, 227, 182, 47, 250, 162, 171, 114, 38, 10, 159, 167, 17, 251, 173, 247, 241, 218, 57, 26, 176, 43, 147, 217, 224, 212, 163>>, - amount: 10.04 - } - ] - } - } - } - } - """ - def execute_actions(%Contract{triggers: triggers}, trigger_type, constants \\ %{}) do - %Contract.Trigger{actions: quoted_code} = Enum.find(triggers, &(&1.type == trigger_type)) - - {%{"next_transaction" => next_transaction}, _} = - Code.eval_quoted(quoted_code, - scope: - Map.put(constants, "next_transaction", %Transaction{ - data: %TransactionData{} - }) - ) - - next_transaction - end - - @doc """ - Execute abritary code using some constants as bindings - - ## Examples - - iex> Interpreter.execute({{:., [line: 1], - ...> [ - ...> {:__aliases__, [alias: Archethic.Contracts.Interpreter.Library], - ...> [:Library]}, - ...> :regex_match? - ...> ]}, [line: 1], - ...> [{:get_in, [line: 1], [{:scope, [line: 1], nil}, ["content"]]}, "abc"]}, %{ "content" => "abc"}) - true - - iex> Interpreter.execute({:==, [], - ...> [ - ...> {:get_in, [line: 1], [{:scope, [line: 1], nil}, ["next_transaction", "content"]]}, - ...> {:get_in, [line: 1], [{:scope, [line: 1], nil}, ["previous_transaction", "content"]]}, - ...> ]}, %{ "previous_transaction" => %{"content" => "abc"}, "next_transaction" => %{ "content" => "abc" } }) - true - - iex> Interpreter.execute({{:., [line: 2], - ...> [ - ...> {:__aliases__, [alias: Archethic.Contracts.Interpreter.Library], - ...> [:Library]}, - ...> :hash - ...> ]}, [line: 2], - ...> [{:get_in, [line: 2], [{:scope, [line: 2], nil}, ["content"]]}]}, %{ "content" => "abc" }) - <<186, 120, 22, 191, 143, 1, 207, 234, 65, 65, 64, 222, 93, 174, 34, 35, 176, 3, 97, 163, 150, 23, 122, 156, 180, 16, 255, 97, 242, 0, 21, 173>> - """ - def execute(quoted_code, constants = %{}) do - {res, _} = Code.eval_quoted(quoted_code, scope: constants) - res - end - - defp inject_bindings_and_functions(quoted_code, opts) when is_list(opts) do - bindings = Keyword.get(opts, :bindings, %{}) - subject = Keyword.get(opts, :subject) - - {ast, _} = - Macro.postwalk( - quoted_code, - %{ - bindings: bindings, - library_functions: @library_functions_names, - transaction_statements_functions: @transaction_statements_functions_names, - subject: subject - }, - &do_postwalk_execution/2 - ) - - ast - end - - defp do_postwalk_execution({:=, metadata, [var_name, content]}, acc) do - put_ast = - {{:., metadata, [{:__aliases__, metadata, [:Map]}, :put]}, metadata, - [{:scope, metadata, nil}, var_name, parse_value(content)]} - - { - {:=, metadata, [{:scope, metadata, nil}, put_ast]}, - put_in(acc, [:bindings, var_name], parse_value(content)) - } - end - - defp do_postwalk_execution(_node = {{:atom, atom}, metadata, args}, acc) - when atom in @library_functions_names do - {{{:., metadata, [{:__aliases__, [alias: Library], [:Library]}, String.to_atom(atom)]}, - metadata, args}, acc} - end - - defp do_postwalk_execution(_node = {{:atom, atom}, metadata, args}, acc) - when atom in @transaction_statements_functions_names do - args = - Enum.map(args, fn arg -> - {ast, _} = Macro.postwalk(arg, acc, &do_postwalk_execution/2) - ast - end) - - ast = { - {:., metadata, - [ - {:__aliases__, [alias: TransactionStatements], [:TransactionStatements]}, - String.to_atom(atom) - ]}, - metadata, - [{:&, metadata, [1]} | args] - } - - update_ast = - {:update_in, metadata, - [ - {:scope, metadata, nil}, - ["next_transaction"], - {:&, metadata, - [ - ast - ]} - ]} - - { - {:=, metadata, [{:scope, metadata, nil}, update_ast]}, - acc - } - end - - defp do_postwalk_execution( - _node = {{:atom, atom}, metadata, _args}, - acc = %{bindings: bindings, subject: subject} - ) do - if Map.has_key?(bindings, atom) do - search = - case subject do - nil -> - [atom] - - subject -> - # Do not use the subject when using reserved keyword - if atom in ["contract", "transaction", "next", "previous"] do - [atom] - else - [subject, atom] - end - end - - { - {:get_in, metadata, [{:scope, metadata, nil}, search]}, - acc - } - else - {atom, acc} + {:error, _} = e -> + e end end - defp do_postwalk_execution({:., metadata, [parent, {{:atom, field}}]}, acc) do - { - {:get_in, metadata, [{:scope, metadata, nil}, [parent, parse_value(field)]]}, - acc - } - end - - defp do_postwalk_execution( - {:., _, [{:get_in, metadata, [{:scope, _, nil}, access]}, {:atom, field}]}, - acc - ) do - { - {:get_in, metadata, [{:scope, metadata, nil}, access ++ [parse_value(field)]]}, - acc - } - end - - defp do_postwalk_execution( - {:., metadata, - [{:get_in, metadata, [{:scope, metadata, nil}, [parent]]}, {:atom, child}]}, - acc - ) do - {{:get_in, metadata, [{:scope, metadata, nil}, [parent, parse_value(child)]]}, acc} - end - - defp do_postwalk_execution({{:atom, atom}, val}, acc) do - {ast, _} = Macro.postwalk(val, acc, &do_postwalk_execution/2) - {{atom, ast}, acc} - end - - defp do_postwalk_execution({{:get_in, metadata, [{:scope, metadata, nil}, access]}, _, []}, acc) do - { - {:get_in, metadata, [{:scope, metadata, nil}, access]}, - acc - } - end - - defp do_postwalk_execution({:==, _, [{left, _, args_l}, {right, _, args_r}]}, acc) do - {{:==, [], [{left, [], args_l}, {right, [], args_r}]}, acc} - 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)} - defp do_postwalk_execution({:==, _, [{left, _, args}, right]}, acc) do - {{:==, [], [{left, [], args}, right]}, acc} - end - - defp do_postwalk_execution({node, _, _}, acc) when is_binary(node) do - {parse_value(node), acc} - end - - defp do_postwalk_execution(node, acc), do: {parse_value(node), acc} - - defp parse_value(val) when is_binary(val) do - case Base.decode16(val, case: :mixed) do - {:ok, bin} -> - bin - - _ -> - val + {:error, _} = e -> + e end end - defp parse_value(val), do: val - - @spec valid_conditions?(Conditions.t(), map()) :: boolean() - def valid_conditions?(conditions = %Conditions{}, constants = %{}) do - result = - conditions - |> Map.from_struct() - |> Enum.all?(fn {field, condition} -> - case validate_condition({field, condition}, constants) do - {_, true} -> - true - - {_, false} -> - Logger.debug( - "Invalid condition for `#{field}` with the given value: `#{get_in(constants, ["next", field])}` - expected: #{inspect(condition)}" - ) - - false - end - end) - - if result do - result - else - result - 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({:previous_public_key, nil}, _) do - # Skip the verification as previous public key change for each transaction - {:previous_public_key, true} - end - - defp validate_condition({:timestamp, nil}, _) do - # Skip the verification as timestamp change 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 - - # Validation rules for inherit constraints - defp validate_condition({field, nil}, %{"previous" => prev, "next" => next}) do - {field, Map.get(prev, Atom.to_string(field)) == Map.get(next, Atom.to_string(field))} - end - - defp validate_condition({field, condition}, constants = %{"next" => next}) do - result = execute(condition, constants) - - if is_boolean(result) do - {field, result} - else - {field, Map.get(next, Atom.to_string(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 = execute(condition, constants) - - if is_boolean(result) do - {field, result} - else - {field, Map.get(transaction, Atom.to_string(field)) == result} - end - end + defp parse_ast(ast, _), do: {:error, {:unexpected_term, ast}} end diff --git a/lib/archethic/contracts/interpreter/action.ex b/lib/archethic/contracts/interpreter/action.ex new file mode 100644 index 000000000..077c77e4b --- /dev/null +++ b/lib/archethic/contracts/interpreter/action.ex @@ -0,0 +1,338 @@ +defmodule Archethic.Contracts.ActionInterpreter do + @moduledoc false + + alias Archethic.Contracts.Interpreter.TransactionStatements + alias Archethic.Contracts.Interpreter.Utils, as: InterpreterUtils + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + alias Crontab.CronExpression.Parser, as: CronParser + + @transaction_fields InterpreterUtils.transaction_fields() + + @transaction_statements_functions_names TransactionStatements.__info__(:functions) + |> Enum.map(&Atom.to_string(elem(&1, 0))) + + @type trigger :: :transaction | {:interval, String.t()} | {:datetime, DateTime.t()} | :oracle + + @doc ~S""" + Parse an action block and return the trigger's type associated with the code to execute + + ## Examples + + iex> ActionInterpreter.parse({{:atom, "actions"}, [line: 1], + ...> [ + ...> [ + ...> {{:atom, "triggered_by"}, {{:atom, "transaction"}, [line: 1], nil}} + ...> ], + ...> [ + ...> do: {{:atom, "add_uco_transfer"}, [line: 2], + ...> [ + ...> [ + ...> {{:atom, "to"}, "0000D574D171A484F8DEAC2D61FC3F7CC984BEB52465D69B3B5F670090742CBF5CC"}, + ...> {{:atom, "amount"}, 2000000000} + ...> ] + ...> ]} + ...> ] + ...> ]}) + {:ok, :transaction, {:=, [line: 2], [{:scope, [line: 2], nil}, {:update_in, [line: 2], [{:scope, [line: 2], nil}, ["next_transaction"], {:&, [line: 2], [{{:., [line: 2], [{:__aliases__, [alias: Archethic.Contracts.Interpreter.TransactionStatements], [:TransactionStatements]}, :add_uco_transfer]}, [line: 2], [{:&, [line: 2], [1]}, [{"to", "0000D574D171A484F8DEAC2D61FC3F7CC984BEB52465D69B3B5F670090742CBF5CC"}, {"amount", 2000000000}]]}]}]}]}} + + Usage with trigger accepting parameters + + iex> ActionInterpreter.parse({{:atom, "actions"}, [line: 1], + ...> [ + ...> [ + ...> {{:atom, "triggered_by"}, {{:atom, "datetime"}, + ...> [line: 1], nil}}, + ...> {{:atom, "at"}, 1391309030} + ...> ], + ...> [ + ...> do: {{:atom, "add_recipient"}, [line: 2], + ...> ["0000D574D171A484F8DEAC2D61FC3F7CC984BEB52465D69B3B5F670090742CBF5CC"]} + ...> ] + ...> ]}) + {:ok, {:datetime, ~U[2014-02-02 02:43:50Z]}, {:=, [line: 2], [{:scope, [line: 2], nil}, {:update_in, [line: 2], [{:scope, [line: 2], nil}, ["next_transaction"], {:&, [line: 2], [{{:., [line: 2], [{:__aliases__, [alias: Archethic.Contracts.Interpreter.TransactionStatements], [:TransactionStatements]}, :add_recipient]}, [line: 2], [{:&, [line: 2], [1]}, "0000D574D171A484F8DEAC2D61FC3F7CC984BEB52465D69B3B5F670090742CBF5CC"]}]}]}]}} + + + Prevent usage of not authorized functions + + iex> ActionInterpreter.parse({{:atom, "actions"}, [line: 1], + ...> [ + ...> [{{:atom, "triggered_by"}, {{:atom, "transaction"}, [line: 1], nil}}], + ...> [ + ...> do: {{:., [line: 2], + ...> [{:__aliases__, [line: 2], [atom: "System"]}, {:atom, "user_home"}]}, + ...> [line: 2], []} + ...> ] + ...> ]} + ...> ) + {:error, "unexpected term - System - L2"} + + """ + @spec parse(any()) :: {:ok, trigger(), Macro.t()} | {:error, String.t()} + def parse(ast) do + case Macro.traverse( + ast, + {:ok, %{scope: :root}}, + &prewalk(&1, &2), + &postwalk/2 + ) do + {_node, {:ok, trigger, actions}} -> + {:ok, trigger, actions} + + {node, _} -> + {:error, InterpreterUtils.format_error_reason(node, "unexpected term")} + end + catch + {:error, reason, node} -> + {:error, InterpreterUtils.format_error_reason(node, reason)} + + {:error, node} -> + {:error, InterpreterUtils.format_error_reason(node, "unexpected term")} + end + + # Whitelist the actions DSL + defp prewalk(node = {{:atom, "actions"}, _, _}, {:ok, context = %{scope: :root}}) do + {node, {:ok, %{context | scope: :actions}}} + end + + # Whitelist the triggers + defp prewalk( + node = {{:atom, "triggered_by"}, {{:atom, trigger}, _, _}}, + {:ok, context = %{scope: :actions}} + ) + when trigger in ["transaction", "datetime", "interval", "oracle"] do + {node, {:ok, %{context | scope: {:actions, String.to_existing_atom(trigger)}}}} + end + + defp prewalk(node = {{:atom, "at"}, timestamp}, acc = {:ok, %{scope: {:actions, :datetime}}}) do + with digits when length(digits) == 10 <- Integer.digits(timestamp), + {:ok, _} <- DateTime.from_unix(timestamp) do + {node, acc} + else + _ -> + {node, {:error, "invalid datetime's trigger"}} + end + end + + defp prewalk(node = {{:atom, "at"}, interval}, acc = {:ok, %{scope: {:actions, :interval}}}) do + case CronParser.parse(interval) do + {:ok, _} -> + {node, acc} + + {:error, _} -> + {node, {:error, "invalid interval"}} + end + end + + # Whitelist variable assignation inside the actions + defp prewalk(node = {:=, _, _}, acc = {:ok, %{scope: {:actions, _}}}), do: {node, acc} + + # Whitelist the transaction statements functions + defp prewalk( + node = {{:atom, function}, _, _}, + {:ok, context = %{scope: parent_scope = {:actions, _}}} + ) + when function in @transaction_statements_functions_names do + {node, {:ok, %{context | scope: {:function, function, parent_scope}}}} + end + + # Whitelist the add_uco_transfer function parameters + defp prewalk( + node = {{:atom, "to"}, address}, + acc = {:ok, %{scope: {"add_uco_transfer", {:actions, _}}}} + ) + when is_binary(address) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "to"}, address}, + acc = {:ok, %{scope: {:function, "add_uco_transfer", {:actions, _}}}} + ) + when is_binary(address) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "to"}, {{:atom, _}, _, _}}, + acc = {:ok, %{scope: {:function, "add_uco_transfer", {:actions, _}}}} + ) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "amount"}, amount}, + acc = {:ok, %{scope: {:function, "add_uco_transfer", {:actions, _}}}} + ) + when is_integer(amount) and amount > 0 do + {node, acc} + end + + defp prewalk( + node = {{:atom, "amount"}, {{:atom, _}, _, _}}, + acc = {:ok, %{scope: {:function, "add_uco_transfer", {:actions, _}}}} + ) do + {node, acc} + end + + # Whitelist the add_token_transfer argument list + defp prewalk( + node = {{:atom, "to"}, address}, + acc = {:ok, %{scope: {:function, "add_token_transfer", {:actions, _}}}} + ) + when is_binary(address) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "to"}, {{:atom, _}, _, _}}, + acc = {:ok, %{scope: {:function, "add_token_transfer", {:actions, _}}}} + ) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "amount"}, amount}, + acc = {:ok, %{scope: {:function, "add_token_transfer", {:actions, _}}}} + ) + when is_integer(amount) and amount > 0 do + {node, acc} + end + + defp prewalk( + node = {{:atom, "amount"}, {{:atom, _}, _, _}}, + acc = {:ok, %{scope: {:function, "add_token_transfer", {:actions, _}}}} + ) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "token_address"}, token_address}, + acc = {:ok, %{scope: {:function, "add_token_transfer", {:actions, _}}}} + ) + when is_binary(token_address) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "token_address"}, {{:atom, _}, _, _}}, + acc = {:ok, %{scope: {:function, "add_token_transfer", {:actions, _}}}} + ) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "token_id"}, token_id}, + acc = {:ok, %{scope: {:function, "add_token_transfer", {:actions, _}}}} + ) + when is_integer(token_id) and token_id >= 0 do + {node, acc} + end + + defp prewalk( + node = {{:atom, "token_id"}, {{:atom, _}, _, _}}, + acc = {:ok, %{scope: {:function, "add_token_transfer", {:actions, _}}}} + ) do + {node, acc} + end + + # Whitelist the add_ownership argument list + defp prewalk( + node = {{:atom, "secret"}, secret}, + acc = {:ok, %{scope: {:function, "add_ownership", {:actions, _}}}} + ) + when is_binary(secret) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "secret"}, {{:atom, _}, _, _}}, + acc = {:ok, %{scope: {:function, "add_ownership", {:actions, _}}}} + ) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "secret_key"}, _secret_key}, + acc = {:ok, %{scope: {:function, "add_ownership", {:actions, _}}}} + ) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "authorized_public_keys"}, authorized_public_keys}, + acc = {:ok, %{scope: {:function, "add_ownership", {:actions, _}}}} + ) + when is_list(authorized_public_keys) do + {node, acc} + end + + defp prewalk( + node = {{:atom, "authorized_public_keys"}, {{:atom, _, _}}}, + acc = {:ok, %{scope: {:function, "add_ownership", {:actions, _}}}} + ) do + {node, acc} + end + + defp prewalk(node, {:error, reason}) do + throw({:error, reason, node}) + end + + defp prewalk(node, acc) do + InterpreterUtils.prewalk(node, acc) + end + + defp postwalk( + node = + {{:atom, "actions"}, [line: _], + [[{{:atom, "triggered_by"}, {{:atom, trigger_type}, _, _}} | opts], [do: actions]]}, + {:ok, _} + ) do + actions = + InterpreterUtils.inject_bindings_and_functions(actions, + bindings: %{ + "contract" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}), + "transaction" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) + } + ) + + case trigger_type do + "transaction" -> + {node, {:ok, :transaction, actions}} + + "datetime" -> + [{{:atom, "at"}, timestamp}] = opts + datetime = DateTime.from_unix!(timestamp) + {node, {:ok, {:datetime, datetime}, actions}} + + "interval" -> + [{{:atom, "at"}, interval}] = opts + {node, {:ok, {:interval, interval}, actions}} + + "oracle" -> + {node, {:ok, :oracle, actions}} + end + end + + defp postwalk(node, acc) do + InterpreterUtils.postwalk(node, acc) + end + + @doc """ + Execute actions code and returns a transaction as result + """ + @spec execute(Macro.t(), map()) :: Transaction.t() + def execute(code, constants \\ %{}) do + {%{"next_transaction" => next_transaction}, _} = + Code.eval_quoted(code, + scope: + Map.put(constants, "next_transaction", %Transaction{ + data: %TransactionData{} + }) + ) + + next_transaction + end +end diff --git a/lib/archethic/contracts/interpreter/condition.ex b/lib/archethic/contracts/interpreter/condition.ex new file mode 100644 index 000000000..14c887c45 --- /dev/null +++ b/lib/archethic/contracts/interpreter/condition.ex @@ -0,0 +1,601 @@ +defmodule Archethic.Contracts.ConditionInterpreter do + @moduledoc false + + alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.ContractConstants, as: Constants + alias Archethic.Contracts.Interpreter.Library + alias Archethic.Contracts.Interpreter.Utils, as: InterpreterUtils + alias Archethic.SharedSecrets + + @condition_fields Conditions.__struct__() + |> Map.keys() + |> Enum.reject(&(&1 == :__struct__)) + |> Enum.map(&Atom.to_string/1) + + @transaction_fields InterpreterUtils.transaction_fields() + + @exported_library_functions Library.__info__(:functions) + + @type condition_type :: :transaction | :inherit | :oracle + + require Logger + + @doc ~S""" + Parse a condition block and returns the right condition's type with a `Archethic.Contracts.Contract.Conditions` struct + + ## Examples + + iex> ConditionInterpreter.parse({{:atom, "condition"}, [line: 1], + ...> [ + ...> [ + ...> {{:atom, "transaction"}, [ + ...> {{:atom, "content"}, "hello"} + ...> ]} + ...> ] + ...> ]}) + {:ok, :transaction, %Conditions{ + content: {:==, [], [ + {:get_in, [], [ + {:scope, [], nil}, + ["transaction", "content"] + ]}, + "hello" + ]} + } + } + + Usage of functions in the condition fields + + iex> ConditionInterpreter.parse({{:atom, "condition"}, [line: 1], + ...> [ + ...> [ + ...> {{:atom, "transaction"}, [ + ...> {{:atom, "content"}, {{:atom, "hash"}, [line: 2], + ...> [ + ...> {{:., [line: 2], + ...> [ + ...> { {:atom, "contract"}, [line: 2], + ...> nil}, + ...> {:atom, "code"} + ...> ]}, + ...> [no_parens: true, line: 2], + ...> []} + ...> ]} + ...> }]} + ...> ] + ...> ]}) + { + :ok, :transaction, %Conditions{ + content: {:==, [line: 2], [ + {:get_in, [line: 2], [ + {:scope, [line: 2], nil}, + ["transaction", "content"] + ]}, + { + {:., [line: 2], [ + {:__aliases__, [alias: Archethic.Contracts.Interpreter.Library], [:Library]}, + :hash + ]}, [line: 2], [ + {:get_in, [line: 2], [ + {:scope, [line: 2], nil}, + ["contract", "code"] + ]} + ] + } + ] + } + } + } + + Usage with multiple condition fields + + iex> ConditionInterpreter.parse({{:atom, "condition"}, [line: 1], + ...> [ + ...> {{:atom, "transaction"}, [ + ...> {{:atom, "content"}, "hello"}, + ...> {{:atom, "uco_transfers"}, {:%{}, [line: 3], + ...> [ + ...> {"00006B368BE45DACD0CBC0EC5893BDC1079448181AA88A2CBB84AF939912E858843E", + ...> 1000000000} + ...> ]} + ...> } + ...> ]} + ...> ]}) + {:ok, :transaction, %Conditions{ + content: {:==, [], [{:get_in, [], [{:scope, [], nil}, ["transaction", "content"]]}, "hello"]}, + uco_transfers: {:==, [], [ + {:get_in, [], [{:scope, [], nil}, + ["transaction", "uco_transfers"] + ]}, + {:%{}, [line: 3], [{ + "00006B368BE45DACD0CBC0EC5893BDC1079448181AA88A2CBB84AF939912E858843E", 1000000000 + }]} + ]} + } + } + + Usage with origin_family condition + + iex> ConditionInterpreter.parse({{:atom, "condition"}, [line: 1], + ...> [ + ...> [ + ...> {{:atom, "inherit"}, [ + ...> {{:atom, "origin_family"}, {{:atom, "abc"}, + ...> [line: 2], nil}} + ...> ]} + ...> ] + ...> ]}) + {:error, "invalid origin family - L2"} + + """ + @spec parse(any()) :: + {:ok, condition_type(), Conditions.t()} | {:error, reason :: String.t()} + def parse(ast) do + case Macro.traverse( + ast, + {:ok, %{scope: :root}}, + &prewalk(&1, &2), + &postwalk/2 + ) do + {_node, {:ok, condition_name, conditions}} -> + {:ok, condition_name, conditions} + + {node, _} -> + {:error, InterpreterUtils.format_error_reason(node, "unexpected term")} + end + catch + {:error, node} -> + {:error, InterpreterUtils.format_error_reason(node, "unexpected term")} + + {:error, reason, node} -> + {:error, InterpreterUtils.format_error_reason(node, reason)} + end + + # Whitelist the DSL for conditions + defp prewalk( + node = {{:atom, "condition"}, _metadata, _}, + {:ok, context = %{scope: :root}} + ) do + {node, {:ok, %{context | scope: :condition}}} + end + + # Whitelist the transaction/inherit/oracle conditions + defp prewalk(node = {{:atom, condition_name}, rest}, {:ok, context = %{scope: :condition}}) + when condition_name in ["transaction", "inherit", "oracle"] and is_list(rest), + do: + {node, {:ok, %{context | scope: {:condition, String.to_existing_atom(condition_name)}}}} + + # Whitelist the transaction fields in the conditions + defp prewalk( + node = {{:atom, field}, _}, + {:ok, context = %{scope: {:condition, condition_name}}} + ) + when field in @condition_fields do + {node, {:ok, %{context | scope: {:condition, condition_name, field}}}} + end + + # Whitelist the origin family + defp prewalk(node = [{{:atom, "origin_family"}, {{:atom, family}, _, _}}], acc = {:ok, _}) do + families = SharedSecrets.list_origin_families() |> Enum.map(&Atom.to_string/1) + + if family in families do + {node, acc} + else + {node, {:error, "invalid origin family"}} + end + end + + defp prewalk(node = [{{:atom, "uco_transfers"}, value}], acc = {:ok, _}) do + case value do + {:%{}, _, _} -> + {node, acc} + + {op, _, _} when op in [:==, :<, :>, :<=, :>=, :if] -> + {node, acc} + + _ -> + {node, {:error, "must be a map or a code instruction starting by an comparator"}} + end + end + + defp prewalk(node = [{{:atom, "token_transfers"}, value}], acc = {:ok, _}) do + case value do + {:%{}, _, _} -> + {node, acc} + + {op, _, _} when op in [:==, :<, :>, :<=, :>=, :if] -> + {node, acc} + + _ -> + {node, {:error, "must be a map or a code instruction starting by an comparator"}} + end + end + + # Whitelist the regex_match?/1 function in the condition + defp prewalk( + node = {{:atom, "regex_match?"}, _, [_search]}, + acc = {:ok, %{scope: {:condition, _, _}}} + ) do + {node, acc} + end + + # Whitelist the json_path_extract/1 function in the condition + defp prewalk( + node = {{:atom, "json_path_extract"}, _, [_search]}, + acc = {:ok, %{scope: {:condition, _, _}}} + ) do + {node, acc} + end + + # Whitelist the json_path_match?/1 function in the condition + defp prewalk( + node = {{:atom, "json_path_match?"}, _, [_search]}, + acc = {:ok, %{scope: {:condition, _, _}}} + ) do + {node, acc} + end + + # Whitelist the hash/0 function in the condition + defp prewalk( + node = {{:atom, "hash"}, _, []}, + acc = {:ok, %{scope: {:condition, _, _}}} + ) do + {node, acc} + end + + # Whitelist the in?/1 function in the condition + defp prewalk( + node = {{:atom, "in?"}, _, [_data]}, + acc = {:ok, %{scope: {:condition, _, _}}} + ) do + {node, acc} + end + + # Whitelist the size/0 function in the condition + defp prewalk(node = {{:atom, "size"}, _, []}, acc = {:ok, %{scope: {:condition, _, _}}}), + do: {node, acc} + + # Whitelist the get_genesis_address/0 function in condition + defp prewalk( + node = {{:atom, "get_genesis_address"}, _, []}, + acc = {:ok, %{scope: {:condition, _, _}}} + ) do + {node, acc} + end + + # Whitelist the get_genesis_public_key/0 function in condition + defp prewalk( + node = {{:atom, "get_genesis_public_key"}, _, []}, + acc = {:ok, %{scope: {:condition, _, _}}} + ) do + {node, acc} + end + + # Whitelist usage of taps in the field of a condition + defp prewalk(node = {{:atom, _key}, _val}, acc = {:ok, %{scope: {:condition, _, _}}}) do + {node, acc} + end + + defp prewalk(node, {:error, reason}) do + throw({:error, reason, node}) + end + + defp prewalk(node, acc) do + InterpreterUtils.prewalk(node, acc) + end + + defp postwalk(node, :error), do: {node, :error} + + defp postwalk( + node = {{:atom, "condition"}, _, [[{{:atom, condition_name}, conditions}]]}, + {:ok, _} + ) do + conditions = build_conditions(condition_name, conditions) + + acc = + case condition_name do + "transaction" -> {:ok, :transaction, conditions} + "inherit" -> {:ok, :inherit, conditions} + "oracle" -> {:ok, :oracle, conditions} + end + + {node, acc} + end + + defp postwalk( + node = {{:atom, "condition"}, _, [{{:atom, condition_name}, conditions}]}, + {:ok, _} + ) do + conditions = build_conditions(condition_name, conditions) + + acc = + case condition_name do + "transaction" -> {:ok, :transaction, conditions} + "inherit" -> {:ok, :inherit, conditions} + "oracle" -> {:ok, :oracle, conditions} + end + + {node, acc} + end + + defp postwalk( + node = + {{:atom, "condition"}, _, + [ + {{:atom, condition_name}, _, + [ + conditions + ]} + ]}, + {:ok, _} + ) do + conditions = build_conditions(condition_name, conditions) + + acc = + case condition_name do + "transaction" -> {:ok, :transaction, conditions} + "inherit" -> {:ok, :inherit, conditions} + "oracle" -> {:ok, :oracle, conditions} + end + + {node, acc} + end + + defp postwalk( + node = {{:atom, field}, _}, + {:ok, context = %{scope: {:condition, condition_name, field}}} + ) + when field in @condition_fields do + {node, {:ok, %{context | scope: {:condition, condition_name}}}} + end + + defp postwalk( + node = {{:atom, condition_name}, _}, + {:ok, context = %{scope: {:condition, _}}} + ) + when condition_name in ["transaction", "inherit", "oracle"] do + {node, {:ok, %{context | scope: :condition}}} + end + + defp postwalk(node, acc) do + InterpreterUtils.postwalk(node, acc) + end + + defp build_conditions(condition_name, conditions) do + bindings = Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) + + bindings = + case condition_name do + "inherit" -> + Map.merge(bindings, %{ + "next" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}), + "previous" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) + }) + + _ -> + Map.merge(bindings, %{ + "contract" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}), + "transaction" => Enum.map(@transaction_fields, &{&1, ""}) |> Enum.into(%{}) + }) + end + + subject_scope = if condition_name == "inherit", do: "next", else: "transaction" + + conditions + |> InterpreterUtils.inject_bindings_and_functions( + bindings: bindings, + subject: subject_scope + ) + |> aggregate_conditions(subject_scope) + end + + defp aggregate_conditions(conditions, subject_scope) do + Enum.reduce(conditions, %Conditions{}, fn {subject, condition}, acc -> + condition = do_aggregate_condition(condition, subject_scope, subject) + Map.put(acc, String.to_existing_atom(subject), condition) + end) + end + + defp do_aggregate_condition(condition, _, "origin_family"), + do: String.to_existing_atom(condition) + + defp do_aggregate_condition(condition, subject_scope, subject) + when is_binary(condition) or is_number(condition) do + {:==, [], + [ + {:get_in, [], [{:scope, [], nil}, [subject_scope, subject]]}, + condition + ]} + end + + defp do_aggregate_condition(condition, subject_scope, subject) when is_list(condition) do + {:==, [], + [ + {:get_in, [], + [ + {:scope, [], nil}, + [subject_scope, subject] + ]}, + condition + ]} + end + + defp do_aggregate_condition(condition, subject_scope, subject) do + Macro.postwalk(condition, &to_boolean_expression(&1, subject_scope, subject)) + end + + defp to_boolean_expression( + {{:., metadata, [{:__aliases__, _, [:Library]}, fun]}, _, args}, + subject_scope, + subject + ) do + arg_length = length(args) + + arguments = + case Keyword.get(@exported_library_functions, fun) do + ^arg_length -> + # If the number of arguments fullfill the function's arity (without subject) + args + + _ -> + [ + {:get_in, metadata, [{:scope, metadata, nil}, [subject_scope, subject]]} | args + ] + end + + if fun |> Atom.to_string() |> String.ends_with?("?") do + {:==, metadata, + [ + true, + {{:., metadata, [{:__aliases__, [alias: Library], [:Library]}, fun]}, metadata, + arguments} + ]} + else + {:==, metadata, + [ + {:get_in, metadata, [{:scope, metadata, nil}, [subject_scope, subject]]}, + {{:., metadata, [{:__aliases__, [alias: Library], [:Library]}, fun]}, metadata, + arguments} + ]} + end + end + + defp to_boolean_expression(condition = {:%{}, _, _}, subject_scope, subject) do + {:==, [], + [ + {:get_in, [], [{:scope, [], nil}, [subject_scope, subject]]}, + condition + ]} + end + + # Flatten comparison operations + defp to_boolean_expression({op, _, [{:==, metadata, [{:get_in, _, _}, comp_a]}, comp_b]}, _, _) + when op in [:==, :>=, :<=] do + {op, metadata, [comp_a, comp_b]} + end + + defp to_boolean_expression(condition, _, _), do: condition + + @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(%{}) + + result = + 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) + + if result do + result + else + result + 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 + + # 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 = execute_condition_code(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 = execute_condition_code(condition, constants) + + if is_boolean(result) do + {field, result} + else + {field, Map.get(transaction, field) == result} + end + end + + defp execute_condition_code(quoted_code, constants) do + {res, _} = Code.eval_quoted(quoted_code, scope: constants) + res + end +end diff --git a/lib/archethic/contracts/interpreter/library.ex b/lib/archethic/contracts/interpreter/library.ex index eee53fc40..8256439d7 100644 --- a/lib/archethic/contracts/interpreter/library.ex +++ b/lib/archethic/contracts/interpreter/library.ex @@ -109,12 +109,11 @@ defmodule Archethic.Contracts.Interpreter.Library do ## Examples iex> Library.hash("hello") - <<44, 242, 77, 186, 95, 176, 163, 14, 38, 232, 59, 42, 197, 185, 226, 158, - 27, 22, 30, 92, 31, 167, 66, 94, 115, 4, 51, 98, 147, 139, 152, 36>> + "2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824" """ @spec hash(binary()) :: binary() def hash(content) when is_binary(content) do - :crypto.hash(:sha256, content) + :crypto.hash(:sha256, decode_binary(content)) |> Base.encode16() end @doc """ @@ -152,7 +151,7 @@ defmodule Archethic.Contracts.Interpreter.Library do 2 """ @spec size(binary() | list()) :: non_neg_integer() - def size(binary) when is_binary(binary), do: byte_size(binary) + def size(binary) when is_binary(binary), do: binary |> decode_binary() |> byte_size() def size(list) when is_list(list), do: length(list) def size(map) when is_map(map), do: map_size(map) @@ -163,9 +162,10 @@ defmodule Archethic.Contracts.Interpreter.Library do @spec get_genesis_address(binary()) :: binary() def get_genesis_address(address) do - nodes = Election.chain_storage_nodes(address, P2P.authorized_and_available_nodes()) - {:ok, address} = download_first_address(nodes, address) - address + bin_address = decode_binary(address) + nodes = Election.chain_storage_nodes(bin_address, P2P.authorized_and_available_nodes()) + {:ok, address} = download_first_address(nodes, bin_address) + Base.encode16(address) end @doc """ @@ -173,9 +173,10 @@ defmodule Archethic.Contracts.Interpreter.Library do """ @spec get_genesis_public_key(binary()) :: binary() def get_genesis_public_key(address) do - nodes = Election.chain_storage_nodes(address, P2P.authorized_and_available_nodes()) - {:ok, key} = download_first_public_key(nodes, address) - key + bin_address = decode_binary(address) + nodes = Election.chain_storage_nodes(bin_address, P2P.authorized_and_available_nodes()) + {:ok, key} = download_first_public_key(nodes, bin_address) + Base.encode16(key) end defp download_first_public_key([node | rest], public_key) do @@ -202,4 +203,18 @@ defmodule Archethic.Contracts.Interpreter.Library do """ @spec timestamp() :: non_neg_integer() def timestamp, do: DateTime.utc_now() |> DateTime.to_unix() + + defp decode_binary(bin) do + if String.printable?(bin) do + case Base.decode16(bin, case: :mixed) do + {:ok, hex} -> + hex + + _ -> + bin + end + else + bin + end + end end diff --git a/lib/archethic/contracts/interpreter/transaction_statements.ex b/lib/archethic/contracts/interpreter/transaction_statements.ex index 94c08a12d..28f114b1a 100644 --- a/lib/archethic/contracts/interpreter/transaction_statements.ex +++ b/lib/archethic/contracts/interpreter/transaction_statements.ex @@ -165,9 +165,9 @@ defmodule Archethic.Contracts.Interpreter.TransactionStatements do ownership = Ownership.new( - secret, - secret_key, - Enum.map(authorized_public_keys, &Base.decode16!(&1, case: :mixed)) + decode_binary(secret), + decode_binary(secret_key), + Enum.map(authorized_public_keys, &decode_binary(&1)) ) update_in( @@ -201,14 +201,8 @@ defmodule Archethic.Contracts.Interpreter.TransactionStatements do end defp decode_binary(bin) do - if String.printable?(bin) do - case Base.decode16(bin, case: :mixed) do - {:ok, hex} -> - hex - - _ -> - bin - end + if String.match?(bin, ~r/^[[:xdigit:]]+$/) do + Base.decode16!(bin, case: :mixed) else bin end diff --git a/lib/archethic/contracts/interpreter/utils.ex b/lib/archethic/contracts/interpreter/utils.ex new file mode 100644 index 000000000..93e24599e --- /dev/null +++ b/lib/archethic/contracts/interpreter/utils.ex @@ -0,0 +1,547 @@ +defmodule Archethic.Contracts.Interpreter.Utils do + @moduledoc false + + alias Archethic.Contracts.Interpreter.Library + alias Archethic.Contracts.Interpreter.TransactionStatements + + @library_functions_names Library.__info__(:functions) + |> Enum.map(&Atom.to_string(elem(&1, 0))) + + @library_functions_names_atoms Library.__info__(:functions) + |> Enum.map(&{Atom.to_string(elem(&1, 0)), elem(&1, 0)}) + |> Enum.into(%{}) + + @transaction_statements_functions_names TransactionStatements.__info__(:functions) + |> Enum.map(&Atom.to_string(elem(&1, 0))) + + @transaction_statements_functions_names_atoms TransactionStatements.__info__(:functions) + |> Enum.map( + &{Atom.to_string(elem(&1, 0)), elem(&1, 0)} + ) + |> Enum.into(%{}) + + @transaction_fields [ + "address", + "type", + "timestamp", + "previous_signature", + "previous_public_key", + "origin_signature", + "content", + "keys", + "code", + "uco_ledger", + "token_ledger", + "uco_transfers", + "token_transfers", + "authorized_public_keys", + "secrets", + "recipients" + ] + + @spec transaction_fields() :: list(String.t()) + def transaction_fields, do: @transaction_fields + + @spec prewalk(Macro.t(), any()) :: {Macro.t(), any()} + def prewalk(node = :atom, acc), do: {node, acc} + def prewalk(node = {:atom, key}, acc) when is_binary(key), do: {node, acc} + + def prewalk(node = {{:atom, key}, _, nil}, acc = {:ok, _}) when is_binary(key), + do: {node, acc} + + def prewalk(node, acc) when is_list(node), do: {node, acc} + + # Whitelist operators + def prewalk(node = {:+, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:-, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:/, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:*, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:>, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:<, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:>=, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:<=, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:|>, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:==, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + # Whitelist the use of doted statement + def prewalk(node = {{:., _, [{_, _, _}, _]}, _, []}, acc = {:ok, %{scope: scope}}) + when scope != :root, + do: {node, acc} + + def prewalk(node = {:if, _, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = {:else, _}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = [do: _, else: _], acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = :else, acc = {:ok, %{scope: scope}}) when scope != :root, do: {node, acc} + + def prewalk(node = {:and, _, _}, acc = {:ok, _}), do: {node, acc} + def prewalk(node = {:or, _, _}, acc = {:ok, _}), do: {node, acc} + + # Whitelist the in operation + def prewalk(node = {:in, _, [_, _]}, acc = {:ok, _}), do: {node, acc} + + # Whitelist maps + def prewalk(node = {:%{}, _, fields}, acc = {:ok, _}) when is_list(fields), do: {node, acc} + + def prewalk(node = {key, _val}, acc) when is_binary(key) do + {node, acc} + end + + # Whitelist the multiline + def prewalk(node = {{:__block__, _, _}}, acc = {:ok, _}) do + {node, acc} + end + + def prewalk(node = {:__block__, _, _}, acc = {:ok, _}) do + {node, acc} + end + + # Whitelist interpolation of strings + def prewalk( + node = + {:<<>>, _, [{:"::", _, [{{:., _, [Kernel, :to_string]}, _, _}, {:binary, _, nil}]}, _]}, + acc + ) do + {node, acc} + end + + def prewalk( + node = + {:<<>>, _, + [ + _, + {:"::", _, [{{:., _, [Kernel, :to_string]}, _, _}, _]} + ]}, + acc + ) do + {node, acc} + end + + def prewalk(node = {:"::", _, [{{:., _, [Kernel, :to_string]}, _, _}, _]}, acc) do + {node, acc} + end + + def prewalk(node = {{:., _, [Kernel, :to_string]}, _, _}, acc) do + {node, acc} + end + + def prewalk(node = {:., _, [Kernel, :to_string]}, acc) do + {node, acc} + end + + def prewalk(node = Kernel, acc), do: {node, acc} + def prewalk(node = :to_string, acc), do: {node, acc} + def prewalk(node = {:binary, _, nil}, acc), do: {node, acc} + + # Whitelist generics + def prewalk(true, acc = {:ok, _}), do: {true, acc} + def prewalk(false, acc = {:ok, _}), do: {false, acc} + def prewalk(number, acc = {:ok, _}) when is_number(number), do: {number, acc} + def prewalk(string, acc = {:ok, _}) when is_binary(string), do: {string, acc} + def prewalk(node = [do: _], acc = {:ok, _}), do: {node, acc} + def prewalk(node = {:do, _}, acc = {:ok, _}), do: {node, acc} + def prewalk(node = :do, acc = {:ok, _}), do: {node, acc} + + # Whitelist the use of list + def prewalk(node = [{{:atom, _}, _, nil} | _], acc = {:ok, %{scope: scope}}) + when scope != :root do + {node, acc} + end + + # Whitelist access to map field + def prewalk( + node = {{:., _, [Access, :get]}, _, _}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root do + {node, acc} + end + + def prewalk(node = {:., _, [Access, :get]}, acc = {:ok, %{scope: scope}}) when scope != :root, + do: {node, acc} + + def prewalk(node = Access, acc), do: {node, acc} + def prewalk(node = :get, acc), do: {node, acc} + + # Whitelist the usage of transaction fields in references: "transaction/contract/previous/next" + def prewalk( + node = {:., _, [{{:atom, transaction_ref}, _, nil}, {:atom, transaction_field}]}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root and transaction_ref in ["next", "previous", "transaction", "contract"] and + transaction_field in @transaction_fields do + {node, acc} + end + + def prewalk( + node = {{:atom, _}, {{:., _, [{{:atom, transaction_ref}, _, nil}, {:atom, type}]}, _, _}}, + acc + ) + when transaction_ref in ["next", "previous", "transaction", "contract"] and + type in @transaction_fields do + {node, acc} + end + + # Whitelist the size/1 function + def prewalk( + node = {{:atom, "size"}, _, [_data]}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root do + {node, acc} + end + + # Whitelist the hash/1 function + def prewalk(node = {{:atom, "hash"}, _, [_data]}, acc = {:ok, %{scope: scope}}) + when scope != :root, + do: {node, acc} + + # Whitelist the regex_match?/2 function + def prewalk( + node = {{:atom, "regex_match?"}, _, [_input, _search]}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root, + do: {node, acc} + + # Whitelist the regex_extract/2 function + def prewalk( + node = {{:atom, "regex_extract"}, _, [_input, _search]}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root, + do: {node, acc} + + # Whitelist the json_path_extract/2 function + def prewalk( + node = {{:atom, "json_path_extract"}, _, [_input, _search]}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root, + do: {node, acc} + + # Whitelist the json_path_match?/2 function + def prewalk( + node = {{:atom, "json_path_match?"}, _, [_input, _search]}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root, + do: {node, acc} + + # Whitelist the get_genesis_address/1 function + def prewalk( + node = {{:atom, "get_genesis_address"}, _, [_address]}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root do + {node, acc} + end + + # Whitelist the get_genesis_public_key/1 function + def prewalk( + node = {{:atom, "get_genesis_public_key"}, _, [_address]}, + acc = {:ok, %{scope: scope}} + ) + when scope != :root do + {node, acc} + end + + # Whitelist the timestamp/0 function in condition + def prewalk(node = {{:atom, "timestamp"}, _, _}, acc = {:ok, %{scope: scope}}) + when scope != :root do + {node, acc} + end + + # Blacklist everything else + def prewalk(node, _acc) do + throw({:error, node}) + end + + @spec postwalk(Macro.t(), any()) :: {Macro.t(), any()} + def postwalk(node = {{:atom, fun}, _, _}, {:ok, context = %{scope: {:function, _, scope}}}) + when fun in @library_functions_names or fun in @transaction_statements_functions_names do + {node, {:ok, %{context | scope: scope}}} + end + + def postwalk( + {{:., meta1, [Access, :get]}, meta2, + [{{:., meta3, [{subject, meta4, nil}, {:atom, field}]}, meta5, []}, {:atom, key}]}, + acc = {:ok, _} + ) do + { + {{:., meta1, [Access, :get]}, meta2, + [ + {{:., meta3, [{subject, meta4, nil}, String.to_existing_atom(field)]}, meta5, []}, + Base.decode16!(key, case: :mixed) + ]}, + acc + } + end + + # Convert map key to binary + def postwalk({:%{}, meta, params}, acc = {:ok, _}) do + encoded_params = + Enum.map(params, fn + {{:atom, key}, value} when is_binary(key) -> + case Base.decode16(key, case: :mixed) do + {:ok, bin} -> + {bin, value} + + :error -> + {key, value} + end + + {key, value} -> + {key, value} + end) + + {{:%{}, meta, encoded_params}, acc} + end + + def postwalk(node, acc) when is_binary(node) do + if String.printable?(node) do + case Base.decode16(node, case: :mixed) do + {:ok, hex} -> + {Base.encode16(hex), acc} + + _ -> + {node, acc} + end + else + {node, acc} + end + end + + def postwalk(node, acc), do: {node, acc} + + @doc """ + Inject context variables and functions by transforming the ast + """ + @spec inject_bindings_and_functions(Macro.t(), list()) :: Macro.t() + def inject_bindings_and_functions(quoted_code, opts) when is_list(opts) do + bindings = Keyword.get(opts, :bindings, %{}) + subject = Keyword.get(opts, :subject) + + {ast, _} = + Macro.postwalk( + quoted_code, + %{ + bindings: bindings, + library_functions: @library_functions_names, + transaction_statements_functions: @transaction_statements_functions_names, + subject: subject + }, + &do_postwalk_execution/2 + ) + + ast + end + + defp do_postwalk_execution({:=, metadata, [var_name, content]}, acc) do + put_ast = + {{:., metadata, [{:__aliases__, metadata, [:Map]}, :put]}, metadata, + [{:scope, metadata, nil}, var_name, content]} + + { + {:=, metadata, [{:scope, metadata, nil}, put_ast]}, + put_in(acc, [:bindings, var_name], content) + } + end + + defp do_postwalk_execution(_node = {{:atom, atom}, metadata, args}, acc) + when atom in @library_functions_names do + fun = Map.get(@library_functions_names_atoms, atom) + + {{{:., metadata, [{:__aliases__, [alias: Library], [:Library]}, fun]}, metadata, args}, acc} + end + + defp do_postwalk_execution(_node = {{:atom, atom}, metadata, args}, acc) + when atom in @transaction_statements_functions_names do + args = + Enum.map(args, fn arg -> + {ast, _} = Macro.postwalk(arg, acc, &do_postwalk_execution/2) + ast + end) + + fun = Map.get(@transaction_statements_functions_names_atoms, atom) + + ast = { + {:., metadata, + [ + {:__aliases__, [alias: TransactionStatements], [:TransactionStatements]}, + fun + ]}, + metadata, + [{:&, metadata, [1]} | args] + } + + update_ast = + {:update_in, metadata, + [ + {:scope, metadata, nil}, + ["next_transaction"], + {:&, metadata, + [ + ast + ]} + ]} + + { + {:=, metadata, [{:scope, metadata, nil}, update_ast]}, + acc + } + end + + defp do_postwalk_execution( + _node = {{:atom, atom}, metadata, _args}, + acc = %{bindings: bindings, subject: subject} + ) do + if Map.has_key?(bindings, atom) do + search = + case subject do + nil -> + [atom] + + subject -> + # Do not use the subject when using reserved keyword + if atom in ["contract", "transaction", "next", "previous"] do + [atom] + else + [subject, atom] + end + end + + { + {:get_in, metadata, [{:scope, metadata, nil}, search]}, + acc + } + else + {atom, acc} + end + end + + defp do_postwalk_execution({:., metadata, [parent, {{:atom, field}}]}, acc) do + { + {:get_in, metadata, [{:scope, metadata, nil}, [parent, field]]}, + acc + } + end + + defp do_postwalk_execution( + {:., _, [{:get_in, metadata, [{:scope, _, nil}, access]}, {:atom, field}]}, + acc + ) do + { + {:get_in, metadata, [{:scope, metadata, nil}, access ++ [field]]}, + acc + } + end + + defp do_postwalk_execution( + {:., metadata, + [{:get_in, metadata, [{:scope, metadata, nil}, [parent]]}, {:atom, child}]}, + acc + ) do + {{:get_in, metadata, [{:scope, metadata, nil}, [parent, child]]}, acc} + end + + defp do_postwalk_execution({{:atom, atom}, val}, acc) do + {ast, _} = Macro.postwalk(val, acc, &do_postwalk_execution/2) + {{atom, ast}, acc} + end + + defp do_postwalk_execution({{:get_in, metadata, [{:scope, metadata, nil}, access]}, _, []}, acc) do + { + {:get_in, metadata, [{:scope, metadata, nil}, access]}, + acc + } + end + + defp do_postwalk_execution({:==, _, [{left, _, args_l}, {right, _, args_r}]}, acc) do + {{:==, [], [{left, [], args_l}, {right, [], args_r}]}, acc} + end + + defp do_postwalk_execution({:==, _, [{left, _, args}, right]}, acc) do + {{:==, [], [{left, [], args}, right]}, acc} + end + + defp do_postwalk_execution({node, _, _}, acc) when is_binary(node) do + {node, acc} + end + + defp do_postwalk_execution(node, acc), do: {node, acc} + + @doc """ + Format an error message from the failing ast node + + It returns message with metadata if possible to indicate the line of the error + """ + @spec format_error_reason(any(), String.t()) :: String.t() + def format_error_reason({:atom, _key}, reason) do + do_format_error_reason(reason, "", []) + end + + def format_error_reason({{:atom, key}, metadata, _}, reason) do + do_format_error_reason(reason, key, metadata) + end + + def format_error_reason({_, metadata, [{:__aliases__, _, [atom: module]} | _]}, reason) do + do_format_error_reason(reason, module, metadata) + end + + def format_error_reason(ast_node = {_, metadata, _}, reason) do + do_format_error_reason(reason, Macro.to_string(ast_node), metadata) + end + + def format_error_reason({{:atom, _}, {_, metadata, _}}, reason) do + do_format_error_reason(reason, "", metadata) + end + + def format_error_reason({{:atom, key}, _}, reason) do + do_format_error_reason(reason, key, []) + end + + defp do_format_error_reason(message, cause, metadata) do + message = prepare_message(message) + + [prepare_message(message), cause, metadata_to_string(metadata)] + |> Enum.reject(&(&1 == "")) + |> Enum.join(" - ") + end + + defp prepare_message(message) when is_atom(message) do + message |> Atom.to_string() |> String.replace("_", " ") + end + + defp prepare_message(message) when is_binary(message) do + String.trim_trailing(message, ":") + end + + defp metadata_to_string(line: line, column: column), do: "L#{line}:C#{column}" + defp metadata_to_string(line: line), do: "L#{line}" + defp metadata_to_string(_), do: "" +end diff --git a/lib/archethic/contracts/loader.ex b/lib/archethic/contracts/loader.ex index 29247d095..0ce8d0f31 100644 --- a/lib/archethic/contracts/loader.ex +++ b/lib/archethic/contracts/loader.ex @@ -11,6 +11,7 @@ defmodule Archethic.Contracts.Loader do alias Archethic.DB + alias Archethic.TransactionChain alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.Transaction.ValidationStamp alias Archethic.TransactionChain.TransactionData @@ -63,27 +64,23 @@ defmodule Archethic.Contracts.Loader do when code != "" do stop_contract(Transaction.previous_address(tx)) - case Contracts.parse!(code) do - # Only load smart contract which are expecting interactions - %Contract{triggers: triggers = [_ | _]} -> - triggers = Enum.reject(triggers, &(&1.actions == {:__block__, [], []})) - - # Avoid to load empty smart contract - if length(triggers) > 0 do - {:ok, _} = - DynamicSupervisor.start_child( - ContractSupervisor, - {Worker, Contract.from_transaction!(tx)} - ) - - Logger.info("Smart contract loaded", - transaction_address: Base.encode16(address), - transaction_type: type - ) - end + %Contract{triggers: triggers} = Contracts.parse!(code) + triggers = Enum.reject(triggers, fn {_, actions} -> actions == {:__block__, [], []} end) - _ -> - :ok + # Create worker only load smart contract which are expecting interactions and where the actions are not empty + if length(triggers) > 0 do + {:ok, _} = + DynamicSupervisor.start_child( + ContractSupervisor, + {Worker, Contract.from_transaction!(tx)} + ) + + Logger.info("Smart contract loaded", + transaction_address: Base.encode16(address), + transaction_type: type + ) + else + :ok end end @@ -91,7 +88,11 @@ defmodule Archethic.Contracts.Loader do tx = %Transaction{ address: tx_address, type: tx_type, - validation_stamp: %ValidationStamp{timestamp: tx_timestamp, recipients: recipients} + validation_stamp: %ValidationStamp{ + timestamp: tx_timestamp, + recipients: recipients, + protocol_version: protocol_version + } }, false ) @@ -104,7 +105,12 @@ defmodule Archethic.Contracts.Loader do case Worker.execute(contract_address, tx) do :ok -> - TransactionLookup.add_contract_transaction(contract_address, tx_address, tx_timestamp) + TransactionLookup.add_contract_transaction( + contract_address, + tx_address, + tx_timestamp, + protocol_version + ) Logger.info("Transaction towards contract ingested", transaction_address: Base.encode16(tx_address), @@ -121,12 +127,19 @@ defmodule Archethic.Contracts.Loader do %Transaction{ address: address, type: type, - validation_stamp: %ValidationStamp{recipients: recipients, timestamp: timestamp} + validation_stamp: %ValidationStamp{ + recipients: recipients, + timestamp: timestamp, + protocol_version: protocol_version + } }, true ) when recipients != [] do - Enum.each(recipients, &TransactionLookup.add_contract_transaction(&1, address, timestamp)) + Enum.each( + recipients, + &TransactionLookup.add_contract_transaction(&1, address, timestamp, protocol_version) + ) Logger.info("Transaction towards contract ingested", transaction_address: Base.encode16(address), @@ -144,6 +157,8 @@ defmodule Archethic.Contracts.Loader do case Registry.lookup(ContractRegistry, address) do [{pid, _}] -> DynamicSupervisor.terminate_child(ContractSupervisor, pid) + TransactionLookup.clear_contract_transactions(address) + TransactionChain.clear_pending_transactions(address) Logger.info("Stop smart contract at #{Base.encode16(address)}") _ -> diff --git a/lib/archethic/contracts/transaction_lookup.ex b/lib/archethic/contracts/transaction_lookup.ex index bafbc8bfc..14b37c7b8 100644 --- a/lib/archethic/contracts/transaction_lookup.ex +++ b/lib/archethic/contracts/transaction_lookup.ex @@ -6,6 +6,10 @@ defmodule Archethic.Contracts.TransactionLookup do use GenServer @vsn Mix.Project.config()[:version] + alias Archethic.DB + alias Archethic.TransactionChain.TransactionInput + alias Archethic.TransactionChain.VersionedTransactionInput + require Logger @doc """ @@ -29,11 +33,24 @@ defmodule Archethic.Contracts.TransactionLookup do @doc """ Register a new transaction address towards a contract address """ - @spec add_contract_transaction(binary(), binary(), DateTime.t()) :: :ok - def add_contract_transaction(contract_address, transaction_address, transaction_timestamp) + @spec add_contract_transaction( + contract_address :: binary(), + tx_address :: binary(), + tx_timestamp :: DateTime.t(), + protocol_version :: pos_integer() + ) :: :ok + def add_contract_transaction( + contract_address, + transaction_address, + transaction_timestamp, + protocol_version + ) when is_binary(contract_address) and is_binary(transaction_address) do true = - :ets.insert(@table_name, {contract_address, transaction_address, transaction_timestamp}) + :ets.insert( + @table_name, + {contract_address, transaction_address, transaction_timestamp, protocol_version} + ) :ok end @@ -41,10 +58,53 @@ defmodule Archethic.Contracts.TransactionLookup do @doc """ Return the list transaction towards a contract address """ - @spec list_contract_transactions(binary()) :: list({binary(), DateTime.t()}) + @spec list_contract_transactions(binary()) :: + list( + {address :: binary(), timestamp :: DateTime.t(), protocol_version :: pos_integer()} + ) def list_contract_transactions(contract_address) when is_binary(contract_address) do - Enum.map(:ets.lookup(@table_name, contract_address), fn {_, tx_address, tx_timestamp} -> - {tx_address, tx_timestamp} + case :ets.lookup(@table_name, contract_address) do + [] -> + DB.get_inputs(:call, contract_address) + |> Enum.map(fn %VersionedTransactionInput{ + input: %TransactionInput{from: from, timestamp: timestamp}, + protocol_version: protocol_version + } -> + {from, timestamp, protocol_version} + end) + + inputs -> + Enum.map(inputs, fn {_, tx_address, tx_timestamp, protocol_version} -> + {tx_address, tx_timestamp, protocol_version} + end) + end + end + + @doc """ + Remove the contract transactions + """ + @spec clear_contract_transactions(binary()) :: :ok + def clear_contract_transactions(contract_address) when is_binary(contract_address) do + {:ok, pid} = DB.start_inputs_writer(:call, contract_address) + + contract_address + |> list_contract_transactions() + |> Enum.each(fn {tx_address, tx_timestamp, protocol_version} -> + input = %VersionedTransactionInput{ + input: %TransactionInput{ + from: tx_address, + timestamp: tx_timestamp, + type: :call + }, + protocol_version: protocol_version + } + + DB.append_input(pid, input) end) + + DB.stop_inputs_writer(pid) + + :ets.delete(@table_name, contract_address) + :ok end end diff --git a/lib/archethic/contracts/worker.ex b/lib/archethic/contracts/worker.ex index 3ee05903c..eed5630fa 100644 --- a/lib/archethic/contracts/worker.ex +++ b/lib/archethic/contracts/worker.ex @@ -5,10 +5,10 @@ defmodule Archethic.Contracts.Worker do alias Archethic.ContractRegistry alias Archethic.Contracts.Contract - alias Archethic.Contracts.Contract.Conditions - alias Archethic.Contracts.Contract.Constants - alias Archethic.Contracts.Contract.Trigger - alias Archethic.Contracts.Interpreter + alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.ContractConstants, as: Constants + alias Archethic.Contracts.ActionInterpreter + alias Archethic.Contracts.ConditionInterpreter alias Archethic.Crypto @@ -19,7 +19,6 @@ defmodule Archethic.Contracts.Worker do alias Archethic.OracleChain alias Archethic.P2P - alias Archethic.P2P.Message.StartMining alias Archethic.P2P.Node alias Archethic.PubSub @@ -62,10 +61,10 @@ defmodule Archethic.Contracts.Worker do def handle_continue(:start_schedulers, state = %{contract: %Contract{triggers: triggers}}) do new_state = - Enum.reduce(triggers, state, fn trigger = %Trigger{type: type}, acc -> - case schedule_trigger(trigger) do + Enum.reduce(triggers, state, fn {trigger_type, _}, acc -> + case schedule_trigger(trigger_type) do timer when is_reference(timer) -> - Map.update(acc, :timers, %{type => timer}, &Map.put(&1, type, timer)) + Map.update(acc, :timers, %{trigger_type => timer}, &Map.put(&1, trigger_type, timer)) _ -> acc @@ -79,14 +78,13 @@ defmodule Archethic.Contracts.Worker do {:execute, incoming_tx = %Transaction{}}, _from, state = %{ - contract: - contract = %Contract{ - triggers: triggers, - constants: %Constants{ - contract: contract_constants = %{"address" => contract_address} - }, - conditions: %{transaction: transaction_condition} - } + contract: %Contract{ + triggers: triggers, + constants: %Constants{ + contract: contract_constants = %{"address" => contract_address} + }, + conditions: %{transaction: transaction_condition} + } } ) do Logger.info("Execute contract transaction actions", @@ -95,7 +93,7 @@ defmodule Archethic.Contracts.Worker do contract: Base.encode16(contract_address) ) - if Enum.any?(triggers, &(&1.type == :transaction)) do + if Map.has_key?(triggers, :transaction) do constants = %{ "contract" => contract_constants, "transaction" => Constants.from_transaction(incoming_tx) @@ -103,8 +101,8 @@ defmodule Archethic.Contracts.Worker do contract_transaction = Constants.to_transaction(contract_constants) - with true <- Interpreter.valid_conditions?(transaction_condition, constants), - next_tx <- Interpreter.execute_actions(contract, :transaction, constants), + with true <- ConditionInterpreter.valid_conditions?(transaction_condition, constants), + next_tx <- ActionInterpreter.execute(Map.fetch!(triggers, :transaction), constants), {:ok, next_tx} <- chain_transaction(next_tx, contract_transaction) do handle_new_transaction(next_tx) {:reply, :ok, state} @@ -133,13 +131,12 @@ defmodule Archethic.Contracts.Worker do end def handle_info( - %Trigger{type: :datetime}, + {:trigger, {:datetime, timestamp}}, state = %{ - contract: - contract = %Contract{ - constants: %Constants{contract: contract_constants = %{"address" => address}} - }, - timers: %{datetime: timer} + contract: %Contract{ + triggers: triggers, + constants: %Constants{contract: contract_constants = %{"address" => address}} + } } ) do Logger.info("Execute contract datetime trigger actions", @@ -152,24 +149,24 @@ defmodule Archethic.Contracts.Worker do contract_tx = Constants.to_transaction(contract_constants) - with next_tx <- Interpreter.execute_actions(contract, :datetime, constants), + with next_tx <- + ActionInterpreter.execute(Map.fetch!(triggers, {:datetime, timestamp}), constants), {:ok, next_tx} <- chain_transaction(next_tx, contract_tx) do handle_new_transaction(next_tx) end - Process.cancel_timer(timer) - {:noreply, Map.update!(state, :timers, &Map.delete(&1, :datetime))} + {:noreply, Map.update!(state, :timers, &Map.delete(&1, {:datetime, timestamp}))} end def handle_info( - trigger = %Trigger{type: :interval}, + {:trigger, {:interval, interval}}, state = %{ - contract: - contract = %Contract{ - constants: %Constants{ - contract: contract_constants = %{"address" => address} - } + contract: %Contract{ + triggers: triggers, + constants: %Constants{ + contract: contract_constants = %{"address" => address} } + } } ) do Logger.info("Execute contract interval trigger actions", @@ -177,7 +174,7 @@ defmodule Archethic.Contracts.Worker do ) # Schedule the next interval trigger - interval_timer = schedule_trigger(trigger) + interval_timer = schedule_trigger({:interval, interval}) constants = %{ "contract" => contract_constants @@ -186,7 +183,8 @@ defmodule Archethic.Contracts.Worker do contract_transaction = Constants.to_transaction(contract_constants) with true <- enough_funds?(address), - next_tx <- Interpreter.execute_actions(contract, :interval, constants), + next_tx <- + ActionInterpreter.execute(Map.fetch!(triggers, {:interval, interval}), constants), {:ok, next_tx} <- chain_transaction(next_tx, contract_transaction), :ok <- ensure_enough_funds(next_tx, address) do handle_new_transaction(next_tx) @@ -198,18 +196,17 @@ defmodule Archethic.Contracts.Worker do def handle_info( {:new_transaction, tx_address, :oracle, _timestamp}, state = %{ - contract: - contract = %Contract{ - triggers: triggers, - constants: %Constants{contract: contract_constants = %{"address" => address}}, - conditions: %{oracle: oracle_condition} - } + contract: %Contract{ + triggers: triggers, + constants: %Constants{contract: contract_constants = %{"address" => address}}, + conditions: %{oracle: oracle_condition} + } } ) do Logger.info("Execute contract oracle trigger actions", contract: Base.encode16(address)) with true <- enough_funds?(address), - true <- Enum.any?(triggers, &(&1.type == :oracle)) do + true <- Map.has_key?(triggers, :oracle) do {:ok, tx} = TransactionChain.get_transaction(tx_address) constants = %{ @@ -220,14 +217,14 @@ defmodule Archethic.Contracts.Worker do contract_transaction = Constants.to_transaction(contract_constants) if Conditions.empty?(oracle_condition) do - with next_tx <- Interpreter.execute_actions(contract, :oracle, constants), + with next_tx <- ActionInterpreter.execute(Map.fetch!(triggers, :oracle), constants), {:ok, next_tx} <- chain_transaction(next_tx, contract_transaction), :ok <- ensure_enough_funds(next_tx, address) do handle_new_transaction(next_tx) end else - with true <- Interpreter.valid_conditions?(oracle_condition, constants), - next_tx <- Interpreter.execute_actions(contract, :oracle, constants), + with true <- ConditionInterpreter.valid_conditions?(oracle_condition, constants), + next_tx <- ActionInterpreter.execute(Map.fetch!(triggers, :oracle), constants), {:ok, next_tx} <- chain_transaction(next_tx, contract_transaction), :ok <- ensure_enough_funds(next_tx, address) do handle_new_transaction(next_tx) @@ -244,39 +241,40 @@ defmodule Archethic.Contracts.Worker do {:noreply, state} end - def handle_info({:EXIT, _pid, _}, _state) do - :keep_state_and_data + def handle_info({:EXIT, _pid, _}, state) do + {:noreply, state} end defp via_tuple(address) do {:via, Registry, {ContractRegistry, address}} end - defp schedule_trigger( - trigger = %Trigger{ - type: :interval, - opts: opts - } - ) do - interval = Keyword.fetch!(opts, :at) - enable_seconds? = Keyword.get(opts, :enable_seconds, false) + # Only used for testing + defp schedule_trigger(trigger = {:interval, {interval, :second}}) do + Process.send_after( + self(), + {:trigger, trigger}, + Utils.time_offset(interval, DateTime.utc_now(), true) * 1000 + ) + end + defp schedule_trigger(trigger = {:interval, interval}) do Process.send_after( self(), - trigger, - Utils.time_offset(interval, DateTime.utc_now(), enable_seconds?) * 1000 + {:trigger, trigger}, + Utils.time_offset(interval, DateTime.utc_now()) * 1000 ) end - defp schedule_trigger(trigger = %Trigger{type: :datetime, opts: [at: datetime = %DateTime{}]}) do + defp schedule_trigger(trigger = {:datetime, datetime = %DateTime{}}) do seconds = DateTime.diff(datetime, DateTime.utc_now()) if seconds > 0 do - Process.send_after(self(), trigger, seconds * 1000) + Process.send_after(self(), {:trigger, trigger}, seconds * 1000) end end - defp schedule_trigger(%Trigger{type: :oracle}) do + defp schedule_trigger(:oracle) do PubSub.register_to_new_transaction_by_type(:oracle) end @@ -287,11 +285,7 @@ defmodule Archethic.Contracts.Worker do # The first storage node of the contract initiate the sending of the new transaction if trigger_node?(validation_nodes) do - P2P.broadcast_message(validation_nodes, %StartMining{ - transaction: next_transaction, - validation_node_public_keys: Enum.map(validation_nodes, & &1.last_public_key), - welcome_node_public_key: Crypto.last_node_public_key() - }) + Archethic.send_new_transaction(next_transaction) else DetectNodeResponsiveness.start_link( next_transaction.address, @@ -300,11 +294,7 @@ defmodule Archethic.Contracts.Worker do Logger.info("contract transaction ...attempt #{count}") if trigger_node?(validation_nodes, count) do - P2P.broadcast_message(validation_nodes, %StartMining{ - transaction: next_transaction, - validation_node_public_keys: Enum.map(validation_nodes, & &1.last_public_key), - welcome_node_public_key: Crypto.last_node_public_key() - }) + Archethic.send_new_transaction(next_transaction) end end ) diff --git a/lib/archethic/db.ex b/lib/archethic/db.ex index ee5efd4e1..8c3869409 100644 --- a/lib/archethic/db.ex +++ b/lib/archethic/db.ex @@ -7,6 +7,7 @@ defmodule Archethic.DB do alias Archethic.Crypto alias __MODULE__.EmbeddedImpl + alias __MODULE__.EmbeddedImpl.InputsWriter alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.VersionedTransactionInput @@ -76,11 +77,12 @@ defmodule Archethic.DB do @callback get_bootstrap_info(key :: String.t()) :: String.t() | nil @callback set_bootstrap_info(key :: String.t(), value :: String.t()) :: :ok - @callback start_inputs_writer(ledger :: :UCO | :token, address :: binary()) :: {:ok, pid()} + @callback start_inputs_writer(input_type :: InputsWriter.input_type(), address :: binary()) :: + {:ok, pid()} @callback stop_inputs_writer(pid :: pid()) :: :ok @callback append_input(pid :: pid(), VersionedTransactionInput.t()) :: :ok - @callback get_inputs(ledger :: :UCO | :token, address :: binary()) :: + @callback get_inputs(input_type :: InputsWriter.input_type(), address :: binary()) :: list(VersionedTransactionInput.t()) @callback stream_first_addresses() :: Enumerable.t() diff --git a/lib/archethic/db/embedded_impl.ex b/lib/archethic/db/embedded_impl.ex index 91800661c..99286962e 100644 --- a/lib/archethic/db/embedded_impl.ex +++ b/lib/archethic/db/embedded_impl.ex @@ -388,8 +388,9 @@ defmodule Archethic.DB.EmbeddedImpl do @doc """ Start a process responsible to write the inputs """ - @spec start_inputs_writer(ledger :: :UCO | :token, address :: binary()) :: {:ok, pid()} - defdelegate start_inputs_writer(ledger, address), to: InputsWriter, as: :start_link + @spec start_inputs_writer(input_type :: InputsWriter.input_type(), address :: binary()) :: + {:ok, pid()} + defdelegate start_inputs_writer(input_type, address), to: InputsWriter, as: :start_link @doc """ Stop the process responsible to write the inputs @@ -407,7 +408,7 @@ defmodule Archethic.DB.EmbeddedImpl do @doc """ Read the list of inputs available at address """ - @spec get_inputs(ledger :: :UCO | :token, address :: binary()) :: + @spec get_inputs(input_type :: InputsWriter.input_type(), address :: binary()) :: list(VersionedTransactionInput.t()) defdelegate get_inputs(ledger, address), to: InputsReader, as: :get_inputs diff --git a/lib/archethic/db/embedded_impl/inputs_reader.ex b/lib/archethic/db/embedded_impl/inputs_reader.ex index 12f269a20..51167a272 100644 --- a/lib/archethic/db/embedded_impl/inputs_reader.ex +++ b/lib/archethic/db/embedded_impl/inputs_reader.ex @@ -7,7 +7,7 @@ defmodule Archethic.DB.EmbeddedImpl.InputsReader do alias Archethic.TransactionChain.VersionedTransactionInput alias Archethic.Utils - @spec get_inputs(ledger :: InputsWriter.ledger(), address :: binary()) :: + @spec get_inputs(input_type :: InputsWriter.input_type(), address :: binary()) :: list(VersionedTransactionInput.t()) def get_inputs(ledger, address) do filename = InputsWriter.address_to_filename(ledger, address) diff --git a/lib/archethic/db/embedded_impl/inputs_writer.ex b/lib/archethic/db/embedded_impl/inputs_writer.ex index 3a909b94c..df10a7feb 100644 --- a/lib/archethic/db/embedded_impl/inputs_writer.ex +++ b/lib/archethic/db/embedded_impl/inputs_writer.ex @@ -9,9 +9,10 @@ defmodule Archethic.DB.EmbeddedImpl.InputsWriter do alias Archethic.TransactionChain.VersionedTransactionInput alias Archethic.Utils - @type ledger :: :token | :UCO + @type input_type :: :UCO | :token | :call - @spec start_link(ledger :: ledger, address :: binary()) :: {:ok, pid()} + @spec start_link(type :: input_type(), address :: binary()) :: + {:ok, pid()} def start_link(ledger, address) do GenServer.start_link(__MODULE__, ledger: ledger, address: address) end @@ -29,12 +30,14 @@ defmodule Archethic.DB.EmbeddedImpl.InputsWriter do GenServer.call(pid, {:append_input, input}) end - @spec address_to_filename(ledger :: ledger, address :: binary()) :: String.t() + @spec address_to_filename(type :: input_type(), address :: binary()) :: + String.t() def address_to_filename(ledger, address) do prefix = case ledger do :UCO -> "uco" :token -> "token" + :call -> "call" end Path.join([EmbeddedImpl.db_path(), "inputs", prefix, Base.encode16(address)]) diff --git a/lib/archethic/mining/pending_transaction_validation.ex b/lib/archethic/mining/pending_transaction_validation.ex index 7485aa892..a66fa674d 100644 --- a/lib/archethic/mining/pending_transaction_validation.ex +++ b/lib/archethic/mining/pending_transaction_validation.ex @@ -225,7 +225,7 @@ defmodule Archethic.Mining.PendingTransactionValidation do data: %TransactionData{code: code, ownerships: ownerships} }) do case Contracts.parse(code) do - {:ok, %Contract{triggers: [_ | _]}} -> + {:ok, %Contract{triggers: triggers}} when map_size(triggers) > 0 -> if Enum.any?( ownerships, &Ownership.authorized_public_key?(&1, Crypto.storage_nonce_public_key()) diff --git a/lib/archethic/mining/proof_of_work.ex b/lib/archethic/mining/proof_of_work.ex index a162fb539..17e678582 100644 --- a/lib/archethic/mining/proof_of_work.ex +++ b/lib/archethic/mining/proof_of_work.ex @@ -11,7 +11,7 @@ defmodule Archethic.Mining.ProofOfWork do alias Archethic.Contracts alias Archethic.Contracts.Contract - alias Archethic.Contracts.Contract.Conditions + alias Archethic.Contracts.ContractConditions alias Archethic.Crypto @@ -117,14 +117,13 @@ defmodule Archethic.Mining.ProofOfWork do @spec list_origin_public_keys_candidates(Transaction.t()) :: list(Crypto.key()) def list_origin_public_keys_candidates(tx = %Transaction{data: %TransactionData{code: code}}) when code != "" do - %Contract{conditions: %{inherit: %Conditions{origin_family: family}}} = Contracts.parse!(code) + case Contracts.parse(code) do + {:ok, %Contract{conditions: %{inherit: %ContractConditions{origin_family: family}}}} + when family != :all -> + SharedSecrets.list_origin_public_keys(family) - case family do - :all -> + _ -> do_list_origin_public_keys_candidates(tx) - - family -> - SharedSecrets.list_origin_public_keys(family) end end diff --git a/lib/archethic/p2p/message.ex b/lib/archethic/p2p/message.ex index 4d65a6956..f79fc15ea 100644 --- a/lib/archethic/p2p/message.ex +++ b/lib/archethic/p2p/message.ex @@ -1633,8 +1633,11 @@ defmodule Archethic.P2P.Message do contract_inputs = address |> Contracts.list_contract_transactions() - |> Enum.map(fn {address, timestamp} -> - %TransactionInput{from: address, type: :call, timestamp: timestamp} + |> Enum.map(fn {address, timestamp, protocol_version} -> + %VersionedTransactionInput{ + input: %TransactionInput{from: address, type: :call, timestamp: timestamp}, + protocol_version: protocol_version + } end) inputs = Account.get_inputs(address) ++ contract_inputs diff --git a/lib/archethic/transaction_chain.ex b/lib/archethic/transaction_chain.ex index 3bbd9875f..03cd11f29 100644 --- a/lib/archethic/transaction_chain.ex +++ b/lib/archethic/transaction_chain.ex @@ -251,6 +251,12 @@ defmodule Archethic.TransactionChain do @spec pending_transaction_signed_by?(to :: binary(), from :: binary()) :: boolean() defdelegate pending_transaction_signed_by?(to, from), to: PendingLedger, as: :already_signed? + @doc """ + Clear the transactions stored as pending + """ + @spec clear_pending_transactions(binary()) :: :ok + defdelegate clear_pending_transactions(address), to: PendingLedger, as: :remove_address + @doc """ Determine if the transaction exists """ diff --git a/lib/archethic/transaction_chain/mem_tables_loader.ex b/lib/archethic/transaction_chain/mem_tables_loader.ex index 97b7021e3..44e48f890 100644 --- a/lib/archethic/transaction_chain/mem_tables_loader.ex +++ b/lib/archethic/transaction_chain/mem_tables_loader.ex @@ -5,7 +5,7 @@ defmodule Archethic.TransactionChain.MemTablesLoader do @vsn Mix.Project.config()[:version] alias Archethic.Contracts.Contract - alias Archethic.Contracts.Contract.Conditions + alias Archethic.Contracts.ContractConditions alias Archethic.DB @@ -70,7 +70,7 @@ defmodule Archethic.TransactionChain.MemTablesLoader do %Contract{conditions: %{transaction: transaction_conditions}} = Contract.from_transaction!(tx) # TODO: improve the criteria of pending detection - if Conditions.empty?(transaction_conditions) do + if ContractConditions.empty?(transaction_conditions) do :ok else PendingLedger.add_address(address) diff --git a/lib/archethic/transaction_chain/transaction/validation_stamp.ex b/lib/archethic/transaction_chain/transaction/validation_stamp.ex index 1bc9fc4b4..b5e6cf25e 100755 --- a/lib/archethic/transaction_chain/transaction/validation_stamp.ex +++ b/lib/archethic/transaction_chain/transaction/validation_stamp.ex @@ -4,6 +4,8 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp do """ alias Archethic.Crypto + + alias Archethic.Utils alias Archethic.Utils.VarInt alias __MODULE__.LedgerOperations @@ -275,7 +277,7 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp do {recipients_length, rest} = rest |> VarInt.get_value() {recipients, <>} = - deserialize_list_of_recipients_addresses(rest, recipients_length, []) + Utils.deserialize_addresses(rest, recipients_length, []) error = deserialize_error(error_byte) @@ -403,26 +405,6 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp do Crypto.verify?(signature, raw_stamp, public_key) end - defp deserialize_list_of_recipients_addresses(rest, 0, _acc), do: {[], rest} - - defp deserialize_list_of_recipients_addresses(rest, nb_recipients, acc) - when length(acc) == nb_recipients do - {Enum.reverse(acc), rest} - end - - defp deserialize_list_of_recipients_addresses( - <>, - nb_recipients, - acc - ) do - hash_size = Crypto.hash_size(hash_id) - <> = rest - - deserialize_list_of_recipients_addresses(rest, nb_recipients, [ - <> | acc - ]) - end - defp serialize_error(nil), do: 0 defp serialize_error(:invalid_pending_transaction), do: 1 defp serialize_error(:invalid_inherit_constraints), do: 2 diff --git a/lib/archethic/transaction_chain/transaction_input.ex b/lib/archethic/transaction_chain/transaction_input.ex index 0c41930da..b29ce3d9c 100644 --- a/lib/archethic/transaction_chain/transaction_input.ex +++ b/lib/archethic/transaction_chain/transaction_input.ex @@ -13,7 +13,7 @@ defmodule Archethic.TransactionChain.TransactionInput do @type t() :: %__MODULE__{ from: Crypto.versioned_hash(), - amount: pos_integer(), + amount: pos_integer() | nil, spent?: boolean(), type: TransactionMovementType.t() | :call, timestamp: DateTime.t(), diff --git a/test/archethic/contracts/interpreter/action_test.exs b/test/archethic/contracts/interpreter/action_test.exs new file mode 100644 index 000000000..f67cc3932 --- /dev/null +++ b/test/archethic/contracts/interpreter/action_test.exs @@ -0,0 +1,346 @@ +defmodule Archethic.Contracts.ActionInterpreterTest do + use ArchethicCase + + alias Archethic.Contracts.ActionInterpreter + alias Archethic.Contracts.Interpreter + + alias Archethic.P2P + alias Archethic.P2P.Node + alias Archethic.P2P.Message.FirstAddress + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + doctest ActionInterpreter + + import Mox + + test "should parse a contract with some standard functions" do + assert {:ok, :transaction, + { + :__block__, + [], + [ + { + :=, + [line: 2], + [ + {:scope, [line: 2], nil}, + { + :update_in, + [line: 2], + [ + {:scope, [line: 2], nil}, + ["next_transaction"], + {:&, [line: 2], + [ + {{:., [line: 2], + [ + {:__aliases__, + [ + alias: Archethic.Contracts.Interpreter.TransactionStatements + ], [:TransactionStatements]}, + :set_type + ]}, [line: 2], [{:&, [line: 2], [1]}, "transfer"]} + ]} + ] + } + ] + }, + { + :=, + [line: 3], + [ + {:scope, [line: 3], nil}, + { + :update_in, + [line: 3], + [ + {:scope, [line: 3], nil}, + ["next_transaction"], + { + :&, + [line: 3], + [ + { + {:., [line: 3], + [ + {:__aliases__, + [ + alias: Archethic.Contracts.Interpreter.TransactionStatements + ], [:TransactionStatements]}, + :add_uco_transfer + ]}, + [line: 3], + [ + {:&, [line: 3], [1]}, + [ + {"to", + "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3"}, + {"amount", 1_040_000_000} + ] + ] + } + ] + } + ] + } + ] + }, + { + :=, + [line: 4], + [ + {:scope, [line: 4], nil}, + { + :update_in, + [line: 4], + [ + {:scope, [line: 4], nil}, + ["next_transaction"], + { + :&, + [line: 4], + [ + { + {:., [line: 4], + [ + {:__aliases__, + [ + alias: Archethic.Contracts.Interpreter.TransactionStatements + ], [:TransactionStatements]}, + :add_token_transfer + ]}, + [line: 4], + [ + {:&, [line: 4], [1]}, + [ + {"to", + "30670455713E2CBECF94591226A903651ED8625635181DDA236FECC221D1E7E4"}, + {"amount", 20_000_000_000}, + {"token_address", + "AEB4A6F5AB6D82BE223C5867EBA5FE616F52F410DCF83B45AFF158DD40AE8AC3"}, + {"token_id", 0} + ] + ] + } + ] + } + ] + } + ] + }, + { + :=, + [line: 5], + [ + {:scope, [line: 5], nil}, + { + :update_in, + [line: 5], + [ + {:scope, [line: 5], nil}, + ["next_transaction"], + { + :&, + [line: 5], + [ + {{:., [line: 5], + [ + {:__aliases__, + [ + alias: Archethic.Contracts.Interpreter.TransactionStatements + ], [:TransactionStatements]}, + :set_content + ]}, [line: 5], [{:&, [line: 5], [1]}, "Receipt"]} + ] + } + ] + } + ] + }, + { + :=, + [line: 6], + [ + {:scope, [line: 6], nil}, + { + :update_in, + [line: 6], + [ + {:scope, [line: 6], nil}, + ["next_transaction"], + { + :&, + [line: 6], + [ + { + {:., [line: 6], + [ + {:__aliases__, + [ + alias: Archethic.Contracts.Interpreter.TransactionStatements + ], [:TransactionStatements]}, + :add_ownership + ]}, + [line: 6], + [ + {:&, [line: 6], [1]}, + [ + {"secret", "MyEncryptedSecret"}, + {"secret_key", "MySecretKey"}, + {"authorized_public_keys", + [ + "70C245E5D970B59DF65638BDD5D963EE22E6D892EA224D8809D0FB75D0B1907A" + ]} + ] + ] + } + ] + } + ] + } + ] + }, + { + :=, + [line: 7], + [ + {:scope, [line: 7], nil}, + { + :update_in, + [line: 7], + [ + {:scope, [line: 7], nil}, + ["next_transaction"], + { + :&, + [line: 7], + [ + { + {:., [line: 7], + [ + {:__aliases__, + [ + alias: Archethic.Contracts.Interpreter.TransactionStatements + ], [:TransactionStatements]}, + :add_recipient + ]}, + [line: 7], + [ + {:&, [line: 7], [1]}, + "78273C5CBCEB8617F54380CC2F173DF2404DB676C9F10D546B6F395E6F3BDDEE" + ] + } + ] + } + ] + } + ] + } + ] + }} = + """ + actions triggered_by: transaction do + set_type transfer + add_uco_transfer to: \"7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3\", amount: 1040000000 + add_token_transfer to: \"30670455713E2CBECF94591226A903651ED8625635181DDA236FECC221D1E7E4\", amount: 20000000000, token_address: \"AEB4A6F5AB6D82BE223C5867EBA5FE616F52F410DCF83B45AFF158DD40AE8AC3\", token_id: 0 + set_content \"Receipt\" + add_ownership secret: \"MyEncryptedSecret\", secret_key: \"MySecretKey\", authorized_public_keys: ["70C245E5D970B59DF65638BDD5D963EE22E6D892EA224D8809D0FB75D0B1907A"] + add_recipient \"78273C5CBCEB8617F54380CC2F173DF2404DB676C9F10D546B6F395E6F3BDDEE\" + end + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + end + + test "should evaluate actions based on if statement" do + assert %Transaction{data: %TransactionData{content: "yes"}} = + ~S""" + actions triggered_by: transaction do + if transaction.previous_public_key == "abc" do + set_content "yes" + else + set_content "no" + end + end + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + |> elem(2) + |> ActionInterpreter.execute(%{"transaction" => %{"previous_public_key" => "abc"}}) + end + + test "should use a variable assignation" do + assert %Transaction{data: %TransactionData{content: "hello"}} = + """ + actions triggered_by: transaction do + new_content = \"hello\" + set_content new_content + end + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + |> elem(2) + |> ActionInterpreter.execute() + end + + test "should use a interpolation assignation" do + assert %Transaction{data: %TransactionData{content: "hello 4"}} = + ~S""" + actions triggered_by: transaction do + new_content = "hello #{2+2}" + set_content new_content + end + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + |> elem(2) + |> ActionInterpreter.execute() + end + + test "shall use get_genesis_address/1 in actions" do + key = <<0::16, :crypto.strong_rand_bytes(32)::binary>> + + P2P.add_and_connect_node(%Node{ + ip: {127, 0, 0, 1}, + port: 3000, + first_public_key: key, + last_public_key: key, + available?: true, + geo_patch: "AAA", + network_patch: "AAA", + authorized?: true, + authorization_date: DateTime.utc_now() + }) + + address = "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" + b_address = Base.decode16!(address) + + MockClient + |> expect(:send_message, fn _, _, _ -> + {:ok, %FirstAddress{address: b_address}} + end) + + assert %Transaction{data: %TransactionData{content: "yes"}} = + ~s""" + actions triggered_by: transaction do + address = get_genesis_address("64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4") + if address == "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" do + set_content "yes" + else + set_content "no" + end + end + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ActionInterpreter.parse() + |> elem(2) + |> ActionInterpreter.execute() + end +end diff --git a/test/archethic/contracts/interpreter/condition_test.exs b/test/archethic/contracts/interpreter/condition_test.exs new file mode 100644 index 000000000..f54c4d540 --- /dev/null +++ b/test/archethic/contracts/interpreter/condition_test.exs @@ -0,0 +1,280 @@ +defmodule Archethic.Contracts.ConditionInterpreterTest do + use ArchethicCase + + alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.ConditionInterpreter + alias Archethic.Contracts.Interpreter + + alias Archethic.P2P + alias Archethic.P2P.Node + alias Archethic.P2P.Message.FirstPublicKey + alias Archethic.P2P.Message.FirstAddress + + doctest ConditionInterpreter + + import Mox + + test "should parse map based inherit constraints" do + assert {:ok, :inherit, + %Conditions{ + uco_transfers: + {:==, _, + [ + {:get_in, _, + [ + {:scope, _, nil}, + ["next", "uco_transfers"] + ]}, + {:%{}, [line: 2], + [ + {"7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", + 1_040_000_000} + ]} + ]} + }} = + """ + condition inherit: [ + uco_transfers: %{ "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3" => 1040000000 } + ] + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "should parse multiline inherit constraints" do + assert {:ok, :inherit, + %Conditions{ + uco_transfers: + {:==, _, + [ + {:get_in, _, [{:scope, _, nil}, ["next", "uco_transfers"]]}, + [ + {:%{}, _, + [ + {"to", + "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3"}, + {"amount", 1_040_000_000} + ]} + ] + ]}, + content: {:==, _, [{:get_in, _, [{:scope, _, nil}, ["next", "content"]]}, "hello"]} + }} = + """ + condition inherit: [ + uco_transfers: [%{ to: "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", amount: 1040000000 }], + content: "hello" + ] + + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "should flatten comparison operators" do + assert {:ok, :inherit, + %Conditions{ + content: + {:>=, [line: 2], + [ + {{:., [line: 2], + [ + {:__aliases__, [alias: Archethic.Contracts.Interpreter.Library], + [:Library]}, + :size + ]}, [line: 2], + [ + {:get_in, [line: 2], [{:scope, [line: 2], nil}, ["next", "content"]]} + ]}, + 10 + ]} + }} = + """ + condition inherit: [ + content: size() >= 10 + ] + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "should accept conditional code within condition" do + assert {:ok, :inherit, %Conditions{}} = + ~S""" + condition inherit: [ + content: if type == transfer do + regex_match?("hello") + else + regex_match?("hi") + end + ] + + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "should accept different type of transaction keyword references" do + assert {:ok, :inherit, %Conditions{}} = + ~S""" + condition inherit: [ + content: if next.type == transfer do + "hi" + else + if previous.type == transfer do + "hi" + else + "hello" + end + end + ] + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "parse invalid uco transfers type definition" do + assert {:error, + "must be a map or a code instruction starting by an comparator - uco_transfers"} = + """ + condition inherit: [ + uco_transfers: [%{ "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3" => 1040000000 }] + ] + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + test "parse invalid token transfers type definition" do + assert {:error, + "must be a map or a code instruction starting by an comparator - token_transfers"} = + """ + condition inherit: [ + token_transfers: [%{ "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3" => 1040000000 }] + ] + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + end + + describe "valid_conditions?" do + test "should validate complex if conditions" do + {:ok, :inherit, conditions} = + ~S""" + condition inherit: [ + type: in?([transfer, token]), + content: if type == transfer do + regex_match?("reason transfer: (.*)") + else + regex_match?("reason token creation: (.*)") + end + ] + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + + assert true == + ConditionInterpreter.valid_conditions?(conditions, %{ + "next" => %{"type" => "transfer", "content" => "reason transfer: pay back alice"} + }) + + assert false == + ConditionInterpreter.valid_conditions?(conditions, %{ + "next" => %{"type" => "transfer", "content" => "dummy"} + }) + + assert true == + ConditionInterpreter.valid_conditions?(conditions, %{ + "next" => %{ + "type" => "token", + "content" => "reason token creation: new super token" + } + }) + end + + test "shall get the first address of the chain in the conditions" do + key = <<0::16, :crypto.strong_rand_bytes(32)::binary>> + + P2P.add_and_connect_node(%Node{ + ip: {127, 0, 0, 1}, + port: 3000, + first_public_key: key, + last_public_key: key, + available?: true, + geo_patch: "AAA", + network_patch: "AAA", + authorized?: true, + authorization_date: DateTime.utc_now() + }) + + address = "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" + b_address = Base.decode16!(address) + + MockClient + |> expect(:send_message, fn _, _, _ -> + {:ok, %FirstAddress{address: b_address}} + end) + + assert true = + ~s""" + condition transaction: [ + address: get_genesis_address() == "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" + ] + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionInterpreter.valid_conditions?(%{ + "transaction" => %{"address" => :crypto.strong_rand_bytes(32)} + }) + end + + test "shall get the first public of the chain in the conditions" do + key = <<0::16, :crypto.strong_rand_bytes(32)::binary>> + + P2P.add_and_connect_node(%Node{ + ip: {127, 0, 0, 1}, + port: 3000, + first_public_key: key, + last_public_key: key, + available?: true, + geo_patch: "AAA", + network_patch: "AAA", + authorized?: true, + authorization_date: DateTime.utc_now() + }) + + public_key = "0001DDE54A313E5DCD73E413748CBF6679F07717F8BDC66CBE8F981E1E475A98605C" + b_public_key = Base.decode16!(public_key) + + MockClient + |> expect(:send_message, fn _, _, _ -> + {:ok, %FirstPublicKey{public_key: b_public_key}} + end) + + assert true = + ~s""" + condition transaction: [ + previous_public_key: get_genesis_public_key() == "0001DDE54A313E5DCD73E413748CBF6679F07717F8BDC66CBE8F981E1E475A98605C" + ] + """ + |> Interpreter.sanitize_code() + |> elem(1) + |> ConditionInterpreter.parse() + |> elem(2) + |> ConditionInterpreter.valid_conditions?(%{ + "transaction" => %{ + "previous_public_key" => <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + } + }) + end + end +end diff --git a/test/archethic/contracts/interpreter_test.exs b/test/archethic/contracts/interpreter_test.exs index 2ce64ec02..1a27cdb99 100644 --- a/test/archethic/contracts/interpreter_test.exs +++ b/test/archethic/contracts/interpreter_test.exs @@ -3,651 +3,75 @@ defmodule Archethic.Contracts.InterpreterTest do use ArchethicCase alias Archethic.Contracts.Contract - alias Archethic.Contracts.Contract.Conditions - alias Archethic.Contracts.Contract.Constants - alias Archethic.Contracts.Contract.Trigger alias Archethic.Contracts.Interpreter - alias Archethic.P2P - alias Archethic.P2P.Node - alias Archethic.P2P.Message.FirstAddress - alias Archethic.P2P.Message.FirstPublicKey alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.TransactionData - alias Archethic.TransactionChain.TransactionData.Ledger - alias Archethic.TransactionChain.TransactionData.UCOLedger - alias Archethic.TransactionChain.TransactionData.UCOLedger.Transfer, as: UCOTransfer - - import Mox doctest Interpreter describe "parse/1" do - test "should parse a contract with some standard functions" do - assert {:ok, - %Contract{ - triggers: [ - %Trigger{ - type: :transaction, - actions: { - :__block__, - [], - [ - { - :=, - [line: 2], - [ - {:scope, [line: 2], nil}, - { - :update_in, - [line: 2], - [ - {:scope, [line: 2], nil}, - ["next_transaction"], - {:&, [line: 2], - [ - {{:., [line: 2], - [ - {:__aliases__, - [ - alias: - Archethic.Contracts.Interpreter.TransactionStatements - ], [:TransactionStatements]}, - :set_type - ]}, [line: 2], [{:&, [line: 2], [1]}, "transfer"]} - ]} - ] - } - ] - }, - { - :=, - [line: 3], - [ - {:scope, [line: 3], nil}, - { - :update_in, - [line: 3], - [ - {:scope, [line: 3], nil}, - ["next_transaction"], - { - :&, - [line: 3], - [ - { - {:., [line: 3], - [ - {:__aliases__, - [ - alias: - Archethic.Contracts.Interpreter.TransactionStatements - ], [:TransactionStatements]}, - :add_uco_transfer - ]}, - [line: 3], - [ - {:&, [line: 3], [1]}, - [ - {"to", - <<127, 102, 97, 172, 226, 130, 249, 71, 172, 162, 239, - 148, 125, 1, 189, 220, 144, 198, 95, 9, 238, 130, - 139, 218, 222, 46, 62, 212, 37, 132, 112, 179>>}, - {"amount", 1_040_000_000} - ] - ] - } - ] - } - ] - } - ] - }, - { - :=, - [line: 4], - [ - {:scope, [line: 4], nil}, - { - :update_in, - [line: 4], - [ - {:scope, [line: 4], nil}, - ["next_transaction"], - { - :&, - [line: 4], - [ - { - {:., [line: 4], - [ - {:__aliases__, - [ - alias: - Archethic.Contracts.Interpreter.TransactionStatements - ], [:TransactionStatements]}, - :add_token_transfer - ]}, - [line: 4], - [ - {:&, [line: 4], [1]}, - [ - {"to", - <<48, 103, 4, 85, 113, 62, 44, 190, 207, 148, 89, 18, - 38, 169, 3, 101, 30, 216, 98, 86, 53, 24, 29, 218, - 35, 111, 236, 194, 33, 209, 231, 228>>}, - {"amount", 20_000_000_000}, - {"token_address", - <<174, 180, 166, 245, 171, 109, 130, 190, 34, 60, 88, - 103, 235, 165, 254, 97, 111, 82, 244, 16, 220, 248, - 59, 69, 175, 241, 88, 221, 64, 174, 138, 195>>}, - {"token_id", 0} - ] - ] - } - ] - } - ] - } - ] - }, - { - :=, - [line: 5], - [ - {:scope, [line: 5], nil}, - { - :update_in, - [line: 5], - [ - {:scope, [line: 5], nil}, - ["next_transaction"], - { - :&, - [line: 5], - [ - {{:., [line: 5], - [ - {:__aliases__, - [ - alias: - Archethic.Contracts.Interpreter.TransactionStatements - ], [:TransactionStatements]}, - :set_content - ]}, [line: 5], [{:&, [line: 5], [1]}, "Receipt"]} - ] - } - ] - } - ] - }, - { - :=, - [line: 6], - [ - {:scope, [line: 6], nil}, - { - :update_in, - [line: 6], - [ - {:scope, [line: 6], nil}, - ["next_transaction"], - { - :&, - [line: 6], - [ - { - {:., [line: 6], - [ - {:__aliases__, - [ - alias: - Archethic.Contracts.Interpreter.TransactionStatements - ], [:TransactionStatements]}, - :add_ownership - ]}, - [line: 6], - [ - {:&, [line: 6], [1]}, - [ - {"secret", "MyEncryptedSecret"}, - {"secret_key", "MySecretKey"}, - {"authorized_public_keys", - [ - <<112, 194, 69, 229, 217, 112, 181, 157, 246, 86, 56, - 189, 213, 217, 99, 238, 34, 230, 216, 146, 234, 34, - 77, 136, 9, 208, 251, 117, 208, 177, 144, 122>> - ]} - ] - ] - } - ] - } - ] - } - ] - }, - { - :=, - [line: 7], - [ - {:scope, [line: 7], nil}, - { - :update_in, - [line: 7], - [ - {:scope, [line: 7], nil}, - ["next_transaction"], - { - :&, - [line: 7], - [ - { - {:., [line: 7], - [ - {:__aliases__, - [ - alias: - Archethic.Contracts.Interpreter.TransactionStatements - ], [:TransactionStatements]}, - :add_recipient - ]}, - [line: 7], - [ - {:&, [line: 7], [1]}, - <<120, 39, 60, 92, 188, 235, 134, 23, 245, 67, 128, 204, - 47, 23, 61, 242, 64, 77, 182, 118, 201, 241, 13, 84, - 107, 111, 57, 94, 111, 59, 221, 238>> - ] - } - ] - } - ] - } - ] - } - ] - } - } - ] - }} = + test "should return an error if not conditions or triggers are defined" do + assert {:error, _} = """ - actions triggered_by: transaction do - set_type transfer - add_uco_transfer to: \"7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3\", amount: 1040000000 - add_token_transfer to: \"30670455713E2CBECF94591226A903651ED8625635181DDA236FECC221D1E7E4\", amount: 20000000000, token_address: \"AEB4A6F5AB6D82BE223C5867EBA5FE616F52F410DCF83B45AFF158DD40AE8AC3\", token_id: 0 - set_content \"Receipt\" - add_ownership secret: \"MyEncryptedSecret\", secret_key: \"MySecretKey\", authorized_public_keys: ["70C245E5D970B59DF65638BDD5D963EE22E6D892EA224D8809D0FB75D0B1907A"] - add_recipient \"78273C5CBCEB8617F54380CC2F173DF2404DB676C9F10D546B6F395E6F3BDDEE\" - end + abc """ |> Interpreter.parse() - end - test "should parse a contract with some map based inherit constraints" do - assert {:ok, - %Contract{ - conditions: %{ - inherit: %Conditions{ - uco_transfers: - {:==, _, - [ - {:get_in, _, - [ - {:scope, _, nil}, - ["next", "uco_transfers"] - ]}, - [ - {:%{}, _, - [ - {"to", - <<127, 102, 97, 172, 226, 130, 249, 71, 172, 162, 239, 148, 125, 1, - 189, 220, 144, 198, 95, 9, 238, 130, 139, 218, 222, 46, 62, 212, - 37, 132, 112, 179>>}, - {"amount", 1_040_000_000} - ]} - ] - ]} - } - } - }} = + assert {:error, _} = """ - condition inherit: [ - uco_transfers: [%{ to: "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", amount: 1040000000 }] - ] - - actions triggered_by: datetime, at: 1102190390 do - set_type transfer - add_uco_transfer to: \"7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3\", amount: 1040000000 - end + condition """ |> Interpreter.parse() end - test "should parse multiline inherit constraints" do - assert {:ok, - %Contract{ - conditions: %{ - inherit: %Conditions{ - uco_transfers: - {:==, _, - [ - {:get_in, _, [{:scope, _, nil}, ["next", "uco_transfers"]]}, - [ - {:%{}, _, - [ - {"to", - <<127, 102, 97, 172, 226, 130, 249, 71, 172, 162, 239, 148, 125, 1, - 189, 220, 144, 198, 95, 9, 238, 130, 139, 218, 222, 46, 62, 212, - 37, 132, 112, 179>>}, - {"amount", 1_040_000_000} - ]} - ] - ]}, - content: - {:==, _, [{:get_in, _, [{:scope, _, nil}, ["next", "content"]]}, "hello"]} - } - } - }} = - """ - condition inherit: [ - uco_transfers: [%{ to: "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", amount: 1040000000 }], - content: "hello" - ] - - """ - |> Interpreter.parse() - end - end - - describe "execute_actions/2" do - test "should evaluate actions based on if statement" do - {:ok, contract} = - ~S""" - actions triggered_by: transaction do - if transaction.previous_public_key == "abc" do - set_content "yes" - else - set_content "no" - end - end - """ - |> Interpreter.parse() - - assert %Transaction{data: %TransactionData{content: "yes"}} = - Interpreter.execute_actions(contract, :transaction, %{ - "transaction" => %{"previous_public_key" => "abc"} - }) - end - - test "should use a variable assignation" do - {:ok, contract} = - """ - actions triggered_by: transaction do - new_content = \"hello\" - set_content new_content - end - """ - |> Interpreter.parse() - - assert %Transaction{data: %TransactionData{content: "hello"}} = - Interpreter.execute_actions(contract, :transaction) - end - - test "should use a interpolation assignation" do - {:ok, contract} = - ~S""" - actions triggered_by: transaction do - new_content = "hello #{2+2}" - set_content new_content - end - """ - |> Interpreter.parse() - - assert %Transaction{data: %TransactionData{content: "hello 4"}} = - Interpreter.execute_actions(contract, :transaction) - end - - test "should flatten comparison operators" do - code = """ - condition inherit: [ - content: size() >= 10 - ] - """ - - {:ok, - %Contract{ - conditions: %{ - inherit: %Conditions{ - content: - {:>=, [line: 2], - [ - {{:., [line: 2], - [ - {:__aliases__, [alias: Archethic.Contracts.Interpreter.Library], - [:Library]}, - :size - ]}, [line: 2], - [ - {:get_in, [line: 2], [{:scope, [line: 2], nil}, ["next", "content"]]} - ]}, - 10 - ]} - } - } - }} = Interpreter.parse(code) - end - - test "should accept conditional code within condition" do - {:ok, %Contract{}} = - ~S""" - condition inherit: [ - content: if type == transfer do - regex_match?("hello") - else - regex_match?("hi") - end - ] - - """ - |> Interpreter.parse() - end - - test "should accept different type of transaction keyword references" do - {:ok, %Contract{}} = - ~S""" - condition inherit: [ - content: if next.type == transfer do - "hi" - else - if previous.type == transfer do - "hi" - else - "hello" - end - end - ] - - condition transaction: [ - content: "#{hash(contract.code)} - YES" - ] - """ - |> Interpreter.parse() - end - end - - describe "execute/2" do - test "should execute complex condition with if statements" do - code = ~S""" - condition inherit: [ - type: in?([transfer, token]), - content: if type == transfer do - regex_match?("reason transfer: (.*)") - else - regex_match?("reason token creation: (.*)") - end, - ] - - condition transaction: [ - content: hash(contract.code) - ] - """ - - {:ok, - %Contract{conditions: %{inherit: inherit_conditions, transaction: transaction_conditions}}} = - Interpreter.parse(code) - - assert true == - Interpreter.valid_conditions?(inherit_conditions, %{ - "next" => %{"type" => "transfer", "content" => "reason transfer: pay back alice"} - }) - - assert false == - Interpreter.valid_conditions?(inherit_conditions, %{ - "next" => %{"type" => "transfer", "content" => "dummy"} - }) - - assert true == - Interpreter.valid_conditions?(inherit_conditions, %{ - "next" => %{ - "type" => "token", - "content" => "reason token creation: new super token" - } - }) - - assert true == - Interpreter.valid_conditions?(transaction_conditions, %{ - "transaction" => %{"content" => :crypto.hash(:sha256, code)}, - "contract" => %{"code" => code} - }) + test "should return an error for unexpected term" do + assert {:error, "unexpected term - @1 - L1"} = "@1" |> Interpreter.parse() end end test "ICO contract parsing" do - {:ok, _} = - """ - condition inherit: [ - type: transfer, - uco_transfers: size() == 1 - # TODO: to provide more security, we should check the destination address is within the previous transaction inputs - ] - - - actions triggered_by: transaction do - # Get the amount of uco send to this contract - amount_send = transaction.uco_transfers[contract.address] - - if amount_send > 0 do - # Convert UCO to the number of tokens to credit. Each UCO worth 10000 token - token_to_credit = amount_send * 10000 - - # Send the new transaction - set_type transfer - add_token_transfer to: transaction.address, token_address: contract.address, amount: token_to_credit, token_id: token_id - end - end - """ - |> Interpreter.parse() + assert {:ok, _} = + """ + condition inherit: [ + token_transfers: size() == 1 + ] + + condition transaction: [ + uco_transfers: size() > 0, + timestamp: transaction.timestamp < 1665750161 + ] + + actions triggered_by: transaction do + # Get the amount of uco send to this contract + amount_send = transaction.uco_transfers[contract.address] + if amount_send > 0 do + # Convert UCO to the number of tokens to credit. Each UCO worth 10 token + token_to_credit = amount_send * 10 + + # Send the new transaction + add_token_transfer to: transaction.address, token_address: contract.address, amount: token_to_credit + end + end + """ + |> Interpreter.parse() end - describe "get_genesis_address/1" do - setup do - key = <<0::16, :crypto.strong_rand_bytes(32)::binary>> - - P2P.add_and_connect_node(%Node{ - ip: {127, 0, 0, 1}, - port: 3000, - first_public_key: key, - last_public_key: key, - available?: true, - geo_patch: "AAA", - network_patch: "AAA", - authorized?: true, - authorization_date: DateTime.utc_now() - }) - - {:ok, [key: key]} - end - - test "shall get the first address of the chain in the conditions" do - address = "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" - b_address = Base.decode16!(address) - - MockClient - |> expect(:send_message, fn _, _, _ -> - {:ok, %FirstAddress{address: b_address}} - end) - - {:ok, %Contract{conditions: %{transaction: conditions}}} = - ~s""" - condition transaction: [ - address: get_genesis_address() == "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" - - ] - """ - |> Interpreter.parse() - - assert true = - Interpreter.valid_conditions?( - conditions, - %{"transaction" => %{"address" => :crypto.strong_rand_bytes(32)}} - ) - end - - test "shall get the first public of the chain in the conditions" do - public_key = "0001DDE54A313E5DCD73E413748CBF6679F07717F8BDC66CBE8F981E1E475A98605C" - b_public_key = Base.decode16!(public_key) - - MockClient - |> expect(:send_message, fn _, _, _ -> - {:ok, %FirstPublicKey{public_key: b_public_key}} - end) - - {:ok, %Contract{conditions: %{transaction: conditions}}} = - ~s""" - condition transaction: [ - previous_public_key: get_genesis_public_key() == "0001DDE54A313E5DCD73E413748CBF6679F07717F8BDC66CBE8F981E1E475A98605C" - ] - """ - |> Interpreter.parse() - - assert true = - Interpreter.valid_conditions?( - conditions, - %{ - "transaction" => %{ - "previous_public_key" => - <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> - } - } - ) - end - - test "shall parse get_genesis_address/1 in actions" do - address = "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" - b_address = Base.decode16!(address) - - MockClient - |> expect(:send_message, fn _, _, _ -> - {:ok, %FirstAddress{address: b_address}} - end) - - {:ok, contract} = - ~s""" - actions triggered_by: transaction do - address = get_genesis_address "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" - if address == "64F05F5236088FC64D1BB19BD13BC548F1C49A42432AF02AD9024D8A2990B2B4" do - set_content "yes" - else - set_content "no" - end - end - """ - |> Interpreter.parse() - - assert %Transaction{data: %TransactionData{content: "yes"}} = - Interpreter.execute_actions(contract, :transaction) - end + test "schedule transfers parsing" do + assert {:ok, _} = + """ + condition inherit: [ + type: transfer, + uco_transfers: + %{ "0000D574D171A484F8DEAC2D61FC3F7CC984BEB52465D69B3B5F670090742CBF5CC" => 100000000 } + ] + + actions triggered_by: interval, at: "* * * * *" do + set_type transfer + add_uco_transfer to: "0000D574D171A484F8DEAC2D61FC3F7CC984BEB52465D69B3B5F670090742CBF5CC", amount: 100000000 + end + """ + |> Interpreter.parse() end end diff --git a/test/archethic/contracts/loader_test.exs b/test/archethic/contracts/loader_test.exs index 3883db654..f9e3fefbc 100644 --- a/test/archethic/contracts/loader_test.exs +++ b/test/archethic/contracts/loader_test.exs @@ -1,10 +1,9 @@ defmodule Archethic.Contracts.LoaderTest do - use ExUnit.Case + use ArchethicCase alias Archethic.ContractRegistry alias Archethic.Contracts.Contract - alias Archethic.Contracts.Contract.Constants - alias Archethic.Contracts.Contract.Trigger + alias Archethic.Contracts.ContractConstants, as: Constants alias Archethic.Contracts.Loader alias Archethic.Contracts.Worker alias Archethic.ContractSupervisor @@ -55,7 +54,7 @@ defmodule Archethic.Contracts.LoaderTest do assert %{ contract: %Contract{ - triggers: [%Trigger{type: :transaction}], + triggers: %{transaction: _}, constants: %Constants{contract: %{"address" => ^contract_address}} } } = :sys.get_state(pid) @@ -164,7 +163,7 @@ defmodule Archethic.Contracts.LoaderTest do assert %{ contract: %Contract{ - triggers: [%Trigger{type: :transaction}], + triggers: %{transaction: _}, constants: %Constants{contract: %{"address" => ^contract_address}} } } = :sys.get_state(pid) diff --git a/test/archethic/contracts/worker_test.exs b/test/archethic/contracts/worker_test.exs index 660e9d222..459cb462d 100644 --- a/test/archethic/contracts/worker_test.exs +++ b/test/archethic/contracts/worker_test.exs @@ -4,7 +4,7 @@ defmodule Archethic.Contracts.WorkerTest do alias Archethic.Account alias Archethic.Contracts.Contract - alias Archethic.Contracts.Contract.Constants + alias Archethic.Contracts.ContractConstants, as: Constants alias Archethic.Contracts.Interpreter @@ -141,7 +141,7 @@ defmodule Archethic.Contracts.WorkerTest do } do code = """ condition inherit: [ - uco_transfers: [%{ to: "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", amount: 1_040_000_000}] + uco_transfers: %{ "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3" => 1_040_000_000} ] actions triggered_by: datetime, at: #{DateTime.utc_now() |> DateTime.add(1) |> DateTime.to_unix()} do @@ -175,7 +175,7 @@ defmodule Archethic.Contracts.WorkerTest do } do code = """ condition inherit: [ - uco_transfers: [%{ to: "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", amount: 1_040_000_000}] + uco_transfers: %{ "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3" => 1_040_000_000} ] actions triggered_by: interval, at: "* * * * * *" do @@ -192,9 +192,10 @@ defmodule Archethic.Contracts.WorkerTest do | constants: %Constants{contract: Map.put(constants, "code", code)} } |> Map.update!(:triggers, fn triggers -> - Enum.map(triggers, fn trigger -> - %{trigger | opts: Keyword.put(trigger.opts, :enable_seconds, true)} + Enum.map(triggers, fn {{:interval, interval}, code} -> + {{:interval, {interval, :second}}, code} end) + |> Enum.into(%{}) end) {:ok, _pid} = Worker.start_link(contract) @@ -241,7 +242,7 @@ defmodule Archethic.Contracts.WorkerTest do } do code = """ condition inherit: [ - uco_transfers: [%{ to: "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", amount: 1_040_000_000}] + uco_transfers: %{ "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3" => 1_040_000_000} ] actions triggered_by: transaction do @@ -288,7 +289,7 @@ defmodule Archethic.Contracts.WorkerTest do ] condition inherit: [ - uco_transfers: [%{ to: "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", amount: 1_040_000_000}] + uco_transfers: %{ "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3" => 1_040_000_000} ] actions triggered_by: transaction do @@ -343,7 +344,7 @@ defmodule Archethic.Contracts.WorkerTest do ] condition inherit: [ - uco_transfers: [%{ to: "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3", amount: 1_040_000_000}] + uco_transfers: %{ "7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3" => 1_040_000_000} ] actions triggered_by: transaction do @@ -355,7 +356,7 @@ defmodule Archethic.Contracts.WorkerTest do ] condition inherit: [ - uco_transfers: [%{ to: \\"7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3\\", amount: 9_200_000_000}] + uco_transfers: %{ \\"7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3\\" => 9_200_000_000} ] actions triggered_by: transaction do @@ -391,7 +392,7 @@ defmodule Archethic.Contracts.WorkerTest do ] condition inherit: [ - uco_transfers: [%{ to: \"7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3\", amount: 9_200_000_000}] + uco_transfers: %{ \"7F6661ACE282F947ACA2EF947D01BDDC90C65F09EE828BDADE2E3ED4258470B3\" => 9_200_000_000} ] actions triggered_by: transaction do diff --git a/test/archethic/contracts_test.exs b/test/archethic/contracts_test.exs index e20df4ff6..6050c5e4f 100644 --- a/test/archethic/contracts_test.exs +++ b/test/archethic/contracts_test.exs @@ -4,9 +4,8 @@ defmodule Archethic.ContractsTest do alias Archethic.Contracts alias Archethic.Contracts.Contract - alias Archethic.Contracts.Contract.Conditions - alias Archethic.Contracts.Contract.Constants - alias Archethic.Contracts.Contract.Trigger + alias Archethic.Contracts.ContractConditions, as: Conditions + alias Archethic.Contracts.ContractConstants, as: Constants alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.TransactionData @@ -222,7 +221,7 @@ defmodule Archethic.ContractsTest do uco_transfers: %{ "3265CCD78CD74984FAB3CC6984D30C8C82044EBBAB1A4FFFB683BDB2D8C5BCF9" => 1000000000} ] - actions triggered_by: interval, at: "0 * * * * *" do + actions triggered_by: interval, at: "0 * * * *" do add_uco_transfer to: \"3265CCD78CD74984FAB3CC6984D30C8C82044EBBAB1A4FFFB683BDB2D8C5BCF9\", amount: 1000000000 end """ @@ -233,8 +232,6 @@ defmodule Archethic.ContractsTest do } } - {:ok, time} = DateTime.new(~D[2016-05-24], ~T[13:26:20], "Etc/UTC") - next_tx = %Transaction{ data: %TransactionData{ code: code, @@ -253,7 +250,8 @@ defmodule Archethic.ContractsTest do } } - assert false == Contracts.accept_new_contract?(previous_tx, next_tx, time) + assert false == + Contracts.accept_new_contract?(previous_tx, next_tx, ~U[2016-05-24 13:26:20Z]) end test "should return false when the transaction have been triggered by interval and the timestamp does match " do @@ -262,7 +260,7 @@ defmodule Archethic.ContractsTest do uco_transfers: %{ "3265CCD78CD74984FAB3CC6984D30C8C82044EBBAB1A4FFFB683BDB2D8C5BCF9" => 1000000000} ] - actions triggered_by: interval, at: "0 * * * * *" do + actions triggered_by: interval, at: "0 * * * *" do add_uco_transfer to: \"3265CCD78CD74984FAB3CC6984D30C8C82044EBBAB1A4FFFB683BDB2D8C5BCF9\", amount: 1000000000 end """ @@ -273,8 +271,6 @@ defmodule Archethic.ContractsTest do } } - {:ok, time} = DateTime.new(~D[2016-05-24], ~T[13:26:00.000999], "Etc/UTC") - next_tx = %Transaction{ data: %TransactionData{ code: code, @@ -293,7 +289,8 @@ defmodule Archethic.ContractsTest do } } - assert true == Contracts.accept_new_contract?(previous_tx, next_tx, time) + assert true == + Contracts.accept_new_contract?(previous_tx, next_tx, ~U[2016-05-24 13:00:00Z]) end test "should return true when the inherit constraint match and when no trigger is specified" do