defmodule Numscriptex do @moduledoc """ NumscriptEx is a library that allows its users to run [Numscripts](https://docs.formance.com/numscript/) via Elixir. """ alias Numscriptex.AssetsManager alias Numscriptex.Balance alias Numscriptex.Posting alias Numscriptex.Run alias Numscriptex.Utilities require AssetsManager AssetsManager.ensure_wasm_binary_is_valid() @type run_result() :: %{ required(:balances) => list(Balance.t()), required(:postings) => list(Posting.t()), required(:accountsMeta) => map(), required(:txMeta) => map() } @type errors() :: %{ required(:reason) => any(), optional(:details) => any() } @doc """ `version/0` simply shows a map with both [Numscript-WASM](https://github.com/PagoPlus/numscript-wasm) and NumscriptEx versions. Ex: ```elixir iex> Numscriptex.version() %{numscriptex: "v0.2.6", numscript_wasm: "v0.1.0"} ``` """ @spec version() :: %{numscriptex: binary(), numscript_wasm: binary()} def version do numscriptex_version = :numscriptex |> Application.spec(:vsn) |> to_string() |> then(fn vsn -> "v#{vsn}" end) case execute_command(:version) do {:ok, numscript_wasm_version} -> Map.put(numscript_wasm_version, :numscriptex, numscriptex_version) {:error, _reason} -> %{numscript_wasm: "unknown", numscriptex: numscriptex_version} end end @doc """ To use `run/2` your first argument must be your script, and the second must be a `%Numscriptex.Run{}` (go to `Numscriptex.Run` module to see more) struct. Ex: ```elixir iex> script = "send [USD/2 100] (source = @foo destination = @bar)" ...> balances = %{"foo" => %{"USD/2" => 500, "EUR/2" => 300}} ...> ...> struct = ...> Numscriptex.Run.new() ...> |> Numscriptex.Run.put!(:balances, balances) ...> |> Numscriptex.Run.put!(:metadata, %{}) ...> |> Numscriptex.Run.put!(:variables, %{}) ...> ...> Numscriptex.run(script, struct) ``` """ @spec run(binary(), Numscriptex.Run.t()) :: {:ok, run_result()} | {:error, errors()} def run(numscript, %Numscriptex.Run{} = run_struct) do initial_balance = Map.get(run_struct, :balances) run_struct |> Run.normalize_to_map() |> Map.merge(%{script: numscript}) |> JSON.encode!() |> execute_command(:run) |> maybe_put_final_balance(initial_balance) |> standardize_run_result() end def run(_numscript, _run_struct), do: {:error, %{reason: :badarg}} defp standardize_run_result({:ok, result}) do standardized_result = result |> Map.put_new(:accountsMeta, %{}) |> Map.put_new(:txMeta, %{}) |> Map.update!(:postings, &Posting.from_list/1) {:ok, standardized_result} end defp standardize_run_result({:error, _reason} = errors), do: errors defp maybe_put_final_balance({:ok, %{"postings" => postings} = result}, initial_balance) do balances = Balance.put(initial_balance, postings) normalized_result = result |> Utilities.normalize_keys(:atom) |> Map.put(:balances, balances) {:ok, normalized_result} end defp maybe_put_final_balance({:error, _reason} = error, _initial_balance), do: error defp execute_command(input \\ "", operation) defp execute_command(input, operation) do {:ok, stdout_pipe} = Wasmex.Pipe.new() {:ok, stdin_pipe} = Wasmex.Pipe.new() {:ok, stderr_pipe} = Wasmex.Pipe.new() Wasmex.Pipe.write(stdin_pipe, input) Wasmex.Pipe.seek(stdin_pipe, 0) wasi = %Wasmex.Wasi.WasiOptions{ args: ["numscript.wasm", to_string(operation)], stdout: stdout_pipe, stdin: stdin_pipe, stderr: stderr_pipe } binary_path = AssetsManager.binary_path() with {:ok, binary} <- File.read(binary_path), {:ok, pid} <- Wasmex.start_link(%{bytes: binary, wasi: wasi}), {{:ok, _}, _pid} <- {Wasmex.call_function(pid, :_start, []), pid} do GenServer.stop(pid, :normal) process(pid, stdout_pipe, stderr_pipe, operation) else {{:error, _reason}, pid} -> GenServer.stop(pid) process(pid, stdout_pipe, stderr_pipe, operation) {:error, reason} when is_atom(reason) -> {:error, %{reason: handle_posix_errors(reason)}} {:error, reason} -> {:error, %{reason: reason}} end end defp process(pid, stdout_pipe, stderr_pipe, operation) when is_pid(pid) do Wasmex.Pipe.seek(stdout_pipe, 0) stdout = Wasmex.Pipe.read(stdout_pipe) Wasmex.Pipe.seek(stderr_pipe, 0) error = Wasmex.Pipe.read(stderr_pipe) stdout |> maybe_decode_json(operation) |> handle_operation_result(operation) |> maybe_put_stderr(error) |> handle_errors() end defp handle_posix_errors(reason) do file_read_error = reason |> :file.format_error() |> to_string() if file_read_error =~ "unknown POSIX error" do reason else "Can't read the WASM binary due to: #{file_read_error}" end end defp maybe_put_stderr({:error, reason}, stderr) when is_map(reason) do is_stderr_empty? = stderr |> String.replace(" ", "") |> Kernel.==("") if is_stderr_empty?, do: {:error, reason}, else: {:error, Map.put(reason, :details, stderr)} end defp maybe_put_stderr(data, _stderr), do: data defp maybe_decode_json(result, :version), do: result defp maybe_decode_json(result, _operation), do: JSON.decode(result) defp handle_operation_result(result, operation) defp handle_operation_result(result, :version) when is_binary(result) do {:ok, %{numscript_wasm: String.trim(result)}} end defp handle_operation_result({:ok, %{"postings" => postings} = result}, :run) do if Enum.empty?(postings), do: {:error, %{reason: :invalid_input}}, else: {:ok, result} end defp handle_operation_result({:error, reason}, _operation), do: {:error, %{reason: reason}} defp handle_operation_result({:ok, _data} = result, _operation), do: result defp handle_operation_result(result, _operation), do: result defp handle_errors({:ok, _} = result), do: result defp handle_errors({:error, %{reason: reason, details: details}}) do {:error, %{reason: normalize_error(reason), details: normalize_error(details)}} end defp handle_errors({:error, %{reason: reason}}) do {:error, %{reason: normalize_error(reason)}} end defp normalize_error(error) when is_binary(error) do error |> String.replace("panic:", "") |> String.trim() end defp normalize_error(error), do: error end