Skip to content

Commit

Permalink
Enhance smart contract Interpreter (new version: v1) (#903)
Browse files Browse the repository at this point in the history
* Add modules for library and transaction statements
* Enable loops
* Improve testing
* Improve some error messages
* Enable ranges
* Handle map[field] syntax
* Allow code blocks in the condition interpreter
* Support several scopes
* Fix double execution of contract
  • Loading branch information
bchamagne committed Mar 9, 2023
1 parent 9f68495 commit 273ef20
Show file tree
Hide file tree
Showing 41 changed files with 5,079 additions and 132 deletions.
4 changes: 2 additions & 2 deletions lib/archethic/contracts/contract.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule Archethic.Contracts.Contract do
alias Archethic.TransactionChain.TransactionData

defstruct triggers: %{},
version: {0, 0, 1},
version: 0,
conditions: %{
transaction: %Conditions{},
inherit: %Conditions{},
Expand All @@ -32,7 +32,7 @@ defmodule Archethic.Contracts.Contract do
triggers: %{
trigger_type() => Macro.t()
},
version: {integer(), integer(), integer()},
version: integer(),
conditions: %{
transaction: Conditions.t(),
inherit: Conditions.t(),
Expand Down
105 changes: 44 additions & 61 deletions lib/archethic/contracts/interpreter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,26 @@ defmodule Archethic.Contracts.Interpreter do

alias Archethic.TransactionChain.Transaction

@type version() :: {integer(), integer(), integer()}
@type version() :: integer()

@doc """
Dispatch through the correct interpreter.
This return a filled contract structure or an human-readable error.
"""
@spec parse(code :: binary()) :: {:ok, Contract.t()} | {:error, String.t()}
def parse(code) when is_binary(code) do
case version(code) do
{{0, 0, 1}, code_without_version} ->
Version0.parse(code_without_version)

{version = {1, _, _}, code_without_version} ->
Version1.parse(code_without_version, version)

_ ->
{:error, "@version not supported"}
case sanitize_code(code) do
{:ok, block} ->
case block do
{:__block__, [], [{:@, _, [{{:atom, "version"}, _, [version]}]} | rest]} ->
Version1.parse({:__block__, [], rest}, version)

_ ->
Version0.parse(block)
end

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

Expand All @@ -42,55 +45,15 @@ defmodule Archethic.Contracts.Interpreter do
|> Code.string_to_quoted(static_atoms_encoder: &atom_encoder/2)
end

@doc """
Determine from the code, the version to use.
Return the version & the code where the version has been removed.
(should be private, but there are unit tests)
"""
@spec version(String.t()) :: {version(), String.t()} | :error
def version(code) do
regex_opts = [capture: :all_but_first]

version_attr_regex = ~r/^\s*@version\s+"(\S+)"/

if Regex.match?(~r/^\s*@version/, code) do
case Regex.run(version_attr_regex, code, regex_opts) do
nil ->
# there is a @version but syntax is invalid (probably the quotes missing)
:error

[capture] ->
case Regex.run(semver_regex(), capture, regex_opts) do
nil ->
# there is a @version but semver syntax is wrong
:error

["0", "0", "0"] ->
# there is a @version but it's 0.0.0
:error

[major, minor, patch] ->
{
{String.to_integer(major), String.to_integer(minor), String.to_integer(patch)},
Regex.replace(version_attr_regex, code, "")
}
end
end
else
# no @version at all
{{0, 0, 1}, code}
end
end

@doc """
Return true if the given conditions are valid on the given constants
"""
@spec valid_conditions?(version(), Conditions.t(), map()) :: bool()
def valid_conditions?({0, _, _}, conditions, constants) do
def valid_conditions?(0, conditions, constants) do
Version0.valid_conditions?(conditions, constants)
end

def valid_conditions?({1, _, _}, conditions, constants) do
def valid_conditions?(1, conditions, constants) do
Version1.valid_conditions?(conditions, constants)
end

Expand All @@ -99,11 +62,11 @@ defmodule Archethic.Contracts.Interpreter do
May return a new transaction or nil
"""
@spec execute_trigger(version(), Macro.t(), map()) :: Transaction.t() | nil
def execute_trigger({0, _, _}, ast, constants) do
def execute_trigger(0, ast, constants) do
Version0.execute_trigger(ast, constants)
end

def execute_trigger({1, _, _}, ast, constants) do
def execute_trigger(1, ast, constants) do
Version1.execute_trigger(ast, constants)
end

Expand All @@ -126,8 +89,33 @@ defmodule Archethic.Contracts.Interpreter do
end

def format_error_reason(ast_node = {_, metadata, _}, reason) do
# FIXME: Macro.to_string will not work on all nodes due to {:atom, bin()}
do_format_error_reason(reason, Macro.to_string(ast_node), metadata)
node_msg =
try do
Macro.to_string(ast_node)
rescue
_ ->
# {:atom, _} is not an atom so it breaks the Macro.to_string/1
# here we replace it with :_var_
{sanified_ast, variables} =
Macro.traverse(
ast_node,
[],
fn node, acc -> {node, acc} end,
fn
{:atom, bin}, acc -> {:_var_, [bin | acc]}
node, acc -> {node, acc}
end
)

# then we will replace all instances of _var_ in the string with the binary
variables
|> Enum.reverse()
|> Enum.reduce(Macro.to_string(sanified_ast), fn variable, acc ->
String.replace(acc, "_var_", variable, global: false)
end)
end

do_format_error_reason(reason, node_msg, metadata)
end

def format_error_reason({{:atom, _}, {_, metadata, _}}, reason) do
Expand Down Expand Up @@ -165,9 +153,4 @@ defmodule Archethic.Contracts.Interpreter do
{:ok, {:atom, atom}}
end
end

# source: https://semver.org/
defp semver_regex() do
~r/(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/
end
end
180 changes: 180 additions & 0 deletions lib/archethic/contracts/interpreter/ast_helper.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
defmodule Archethic.Contracts.Interpreter.ASTHelper do
@moduledoc """
Helper functions to manipulate AST
"""

@doc """
Return wether the given ast is a keyword list.
Remember that we convert all keywords to maps in the prewalk.
iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("[]")
iex> ASTHelper.is_keyword_list?(ast)
true
iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("[sum: 1, product: 10]")
iex> ASTHelper.is_keyword_list?(ast)
true
iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("[1,2,3]")
iex> ASTHelper.is_keyword_list?(ast)
false
"""
@spec is_keyword_list?(Macro.t()) :: boolean()
def is_keyword_list?(ast) when is_list(ast) do
Enum.all?(ast, fn
{{:atom, bin}, _value} when is_binary(bin) ->
true

_ ->
false
end)
end

def is_keyword_list?(_), do: false

@doc """
Return wether the given ast is a map
iex> ast = quote do: %{"sum" => 1, "product" => 10}
iex> ASTHelper.is_map?(ast)
true
"""
@spec is_map?(Macro.t()) :: boolean()
def is_map?({:%{}, _, _}), do: true
def is_map?(_), do: false

@doc """
Return wether the given ast is an integer
iex> ast = quote do: 1
iex> ASTHelper.is_integer?(ast)
true
"""
@spec is_integer?(Macro.t()) :: boolean()
def is_integer?(node), do: is_integer(node)

@doc ~S"""
Return wether the given ast is an binary
iex> ast = quote do: "hello"
iex> ASTHelper.is_binary?(ast)
true
iex> _hello = "hello"
iex> ast = quote do: "#{_hello} world"
iex> ASTHelper.is_binary?(ast)
true
"""
@spec is_binary?(Macro.t()) :: boolean()
def is_binary?(node) when is_binary(node), do: true
def is_binary?({:<<>>, _, _}), do: true
def is_binary?(_), do: false

@doc """
Return wether the given ast is an float
iex> ast= quote do: 1.0
iex> ASTHelper.is_float?(ast)
true
"""
@spec is_float?(Macro.t()) :: boolean()
def is_float?(node), do: is_float(node)

@doc """
Return wether the given ast is a a list
iex> ast = quote do: [1, 2]
iex> ASTHelper.is_list?(ast)
true
"""
@spec is_list?(Macro.t()) :: boolean()
def is_list?(node), do: is_list(node)

@doc """
Return wether the given ast is a variable or a function call.
Useful because we pretty much accept this everywhere
"""
@spec is_variable_or_function_call?(Macro.t()) :: boolean()
def is_variable_or_function_call?(ast) do
is_variable?(ast) || is_function_call?(ast)
end

@doc """
Return wether the given ast is a variable.
Variable are transformed into {:get_in, _, _} in our prewalks
TODO: find a elegant way to test this.
"""
@spec is_variable?(Macro.t()) :: boolean()
def is_variable?({:get_in, _, _}), do: true
def is_variable?(_), do: false

@doc """
Return wether the given ast is a function call or not
iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("hello(12)")
iex> ASTHelper.is_function_call?(ast)
true
iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("hello()")
iex> ASTHelper.is_function_call?(ast)
true
iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("Module.hello()")
iex> ASTHelper.is_function_call?(ast)
true
iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("hello")
iex> ASTHelper.is_function_call?(ast)
false
"""
@spec is_function_call?(Macro.t()) :: boolean()
def is_function_call?({{:atom, _}, _, list}) when is_list(list), do: true
def is_function_call?({{:., _, [{:__aliases__, _, [_]}, _]}, _, _}), do: true
def is_function_call?(_), do: false

@doc """
Convert a keyword AST into a map AST
iex> {:ok, ast} = Archethic.Contracts.Interpreter.sanitize_code("[sum: 1, product: 10]")
iex> Macro.to_string(ASTHelper.keyword_to_map(ast))
~s(%{"sum" => 1, "product" => 10})
"""
@spec keyword_to_map(Macro.t()) :: Macro.t()
def keyword_to_map(ast) do
proplist =
Enum.map(ast, fn {{:atom, atom_name}, value} ->
{atom_name, value}
end)

{:%{}, [], proplist}
end

@doc """
Maybe wrap the AST in a block if it's not already a block
We use this because do..end blocks have 2 forms:
- when there is a single expression in the block
ex:
{:if, _, _} (1)
- when there are multiple expression in the block
ex:
{:__block__, [], [
{:if, _, _},
{:if, _, _}
]}
We use it:
- in if/else, in order to always have a __block__ to pattern match
- in the ActionIntepreter's prewalk because we discard completely the rest of the code except the do..end block.
If we don't wrap in a block and the code is a single expression, it would be automatically whitelisted.
iex> ASTHelper.wrap_in_block({:if, [], [true, [do: 1, else: 2]]})
iex> {:__block__, [], [{:if, [], [true, [do: 1, else: 2]]}]}
iex> ASTHelper.wrap_in_block({:__block__, [], [{:if, [], [true, [do: 1, else: 2]]}]})
iex> {:__block__, [], [{:if, [], [true, [do: 1, else: 2]]}]}
"""
def wrap_in_block(ast = {:__block__, _, _}), do: ast
def wrap_in_block(ast), do: {:__block__, [], [ast]}
end
Loading

0 comments on commit 273ef20

Please sign in to comment.