diff --git a/lib/archethic/bootstrap/network_init.ex b/lib/archethic/bootstrap/network_init.ex index 491f524ae..d45e5f6f9 100644 --- a/lib/archethic/bootstrap/network_init.ex +++ b/lib/archethic/bootstrap/network_init.ex @@ -14,6 +14,7 @@ defmodule Archethic.Bootstrap.NetworkInit do alias Archethic.Election alias Archethic.Mining + alias Archethic.Mining.LedgerValidation alias Archethic.PubSub @@ -26,7 +27,6 @@ defmodule Archethic.Bootstrap.NetworkInit do alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.Transaction.CrossValidationStamp alias Archethic.TransactionChain.Transaction.ValidationStamp - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.UnspentOutput alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput @@ -187,20 +187,16 @@ defmodule Archethic.Bootstrap.NetworkInit do timestamp = DateTime.utc_now() |> DateTime.truncate(:millisecond) fee = Mining.get_transaction_fee(tx, nil, 0.07, timestamp, nil) movements = Transaction.get_movements(tx) - resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() operations = - %LedgerOperations{fee: fee} - |> LedgerOperations.consume_inputs( - address, - timestamp, - unspent_outputs, - Transaction.get_movements(tx), - LedgerOperations.get_utxos_from_transaction(tx, timestamp, 1) - ) - |> elem(1) - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx_type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, nil) + |> LedgerValidation.mint_token_utxos(tx, timestamp, 1) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx_type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(address, timestamp) + |> LedgerValidation.to_ledger_operations() validation_stamp = %ValidationStamp{ diff --git a/lib/archethic/contracts/interpreter/library/common/chain_impl.ex b/lib/archethic/contracts/interpreter/library/common/chain_impl.ex index 9ae54ac18..2408199db 100644 --- a/lib/archethic/contracts/interpreter/library/common/chain_impl.ex +++ b/lib/archethic/contracts/interpreter/library/common/chain_impl.ex @@ -9,9 +9,9 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.ChainImpl do alias Archethic.Crypto - alias Archethic.Tag + alias Archethic.Mining.LedgerValidation - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations + alias Archethic.Tag alias Archethic.Utils @@ -85,7 +85,7 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.ChainImpl do end @impl Chain - def get_burn_address(), do: LedgerOperations.burning_address() |> Base.encode16() + def get_burn_address(), do: LedgerValidation.burning_address() |> Base.encode16() @impl Chain def get_previous_address(previous_public_key) when is_binary(previous_public_key), diff --git a/lib/archethic/mining/distributed_workflow.ex b/lib/archethic/mining/distributed_workflow.ex index 177a58a6b..c1553f23f 100644 --- a/lib/archethic/mining/distributed_workflow.ex +++ b/lib/archethic/mining/distributed_workflow.ex @@ -21,7 +21,6 @@ defmodule Archethic.Mining.DistributedWorkflow do alias Archethic.Mining.Error alias Archethic.Mining.MaliciousDetection - alias Archethic.Mining.PendingTransactionValidation alias Archethic.Mining.TransactionContext alias Archethic.Mining.ValidationContext alias Archethic.Mining.WorkflowRegistry @@ -403,24 +402,11 @@ defmodule Archethic.Mining.DistributedWorkflow do :prior_validation, state, data = %{ - context: - context = %ValidationContext{transaction: tx, validation_time: validation_time}, + context: context = %ValidationContext{transaction: tx}, ref_timestamp: ref_timestamp } ) do - new_context = - case PendingTransactionValidation.validate(tx, validation_time) do - :ok -> - context - - {:error, error} -> - Logger.debug("Invalid pending transaction - #{inspect(error)}", - transaction_address: Base.encode16(tx.address), - transaction_type: tx.type - ) - - ValidationContext.set_mining_error(context, error) - end + new_context = ValidationContext.validate_pending_transaction(context) new_data = Map.put(data, :context, new_context) diff --git a/lib/archethic/mining/ledger_validation.ex b/lib/archethic/mining/ledger_validation.ex new file mode 100644 index 000000000..766ff43e5 --- /dev/null +++ b/lib/archethic/mining/ledger_validation.ex @@ -0,0 +1,514 @@ +defmodule Archethic.Mining.LedgerValidation do + @moduledoc """ + Calculate ledger operations requested by the transaction + """ + + @unit_uco 100_000_000 + + defstruct transaction_movements: [], + unspent_outputs: [], + fee: 0, + consumed_inputs: [], + inputs: [], + minted_utxos: [], + sufficient_funds?: false, + balances: %{uco: 0, token: %{}}, + amount_to_spend: %{uco: 0, token: %{}} + + alias Archethic.Contracts.Contract.Context, as: ContractContext + alias Archethic.Contracts.Contract.State + + alias Archethic.Crypto + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations + + alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.TransactionMovement + + alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.UnspentOutput + + alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput + + alias Archethic.TransactionChain.TransactionData + + @typedoc """ + - Transaction movements: represents the pending transaction ledger movements + - Unspent outputs: represents the new unspent outputs + - fee: represents the transaction fee + - Consumed inputs: represents the list of inputs consumed to produce the unspent outputs + """ + @type t() :: %__MODULE__{ + transaction_movements: list(TransactionMovement.t()), + unspent_outputs: list(UnspentOutput.t()), + fee: non_neg_integer(), + consumed_inputs: list(VersionedUnspentOutput.t()), + inputs: list(VersionedUnspentOutput.t()), + minted_utxos: list(VersionedUnspentOutput.t()), + sufficient_funds?: boolean(), + balances: %{uco: non_neg_integer(), token: map()}, + amount_to_spend: %{uco: non_neg_integer(), token: map()} + } + + @burning_address <<0::8, 0::8, 0::256>> + + @doc """ + Return the address used for the burning + """ + @spec burning_address() :: Crypto.versioned_hash() + def burning_address, do: @burning_address + + @doc """ + Filter inputs that can be used in this transaction + """ + @spec filter_usable_inputs( + ops :: t(), + inputs :: list(VersionedUnspentOutput.t()), + contract_context :: ContractContext.t() | nil + ) :: t() + def filter_usable_inputs(ops, inputs, nil), do: %__MODULE__{ops | inputs: inputs} + + def filter_usable_inputs(ops, inputs, contract_context), + do: %__MODULE__{ops | inputs: ContractContext.ledger_inputs(contract_context, inputs)} + + @doc """ + Build some ledger operations from a specific transaction + """ + @spec mint_token_utxos( + ops :: t(), + tx :: Transaction.t(), + validation_time :: DateTime.t(), + protocol_version :: non_neg_integer() + ) :: t() + def mint_token_utxos( + ops, + %Transaction{address: address, type: type, data: %TransactionData{content: content}}, + timestamp, + protocol_version + ) + when type in [:token, :mint_rewards] and not is_nil(timestamp) do + case Jason.decode(content) do + {:ok, json} -> + minted_utxos = + json + |> create_token_utxos(address, timestamp) + |> VersionedUnspentOutput.wrap_unspent_outputs(protocol_version) + + %__MODULE__{ops | minted_utxos: minted_utxos} + + _ -> + ops + end + end + + def mint_token_utxos(ops, _, _, _), do: ops + + defp create_token_utxos( + %{"token_reference" => token_ref, "supply" => supply}, + address, + timestamp + ) + when is_binary(token_ref) and is_integer(supply) do + case Base.decode16(token_ref, case: :mixed) do + {:ok, token_address} -> + [ + %UnspentOutput{ + from: address, + amount: supply, + type: {:token, token_address, 0}, + timestamp: timestamp + } + ] + + _ -> + [] + end + end + + defp create_token_utxos( + %{"type" => "fungible", "supply" => supply}, + address, + timestamp + ) do + [ + %UnspentOutput{ + from: address, + amount: supply, + type: {:token, address, 0}, + timestamp: timestamp + } + ] + end + + defp create_token_utxos( + %{ + "type" => "non-fungible", + "supply" => supply, + "collection" => collection + }, + address, + timestamp + ) do + if length(collection) == supply / @unit_uco do + collection + |> Enum.with_index() + |> Enum.map(fn {item_properties, index} -> + token_id = Map.get(item_properties, "id", index + 1) + + %UnspentOutput{ + from: address, + amount: 1 * @unit_uco, + type: {:token, address, token_id}, + timestamp: timestamp + } + end) + else + [] + end + end + + defp create_token_utxos( + %{"type" => "non-fungible", "supply" => @unit_uco}, + address, + timestamp + ) do + [ + %UnspentOutput{ + from: address, + amount: 1 * @unit_uco, + type: {:token, address, 1}, + timestamp: timestamp + } + ] + end + + defp create_token_utxos(_, _, _), do: [] + + @doc """ + Build the resolved view of the movement, with the resolved address + and convert MUCO movement to UCO movement + """ + @spec build_resolved_movements( + ops :: t(), + movements :: list(TransactionMovement.t()), + resolved_addresses :: %{Crypto.prepended_hash() => Crypto.prepended_hash()}, + tx_type :: Transaction.transaction_type() + ) :: t() + def build_resolved_movements(ops, movements, resolved_addresses, tx_type) do + resolved_movements = + movements + |> TransactionMovement.resolve_addresses(resolved_addresses) + |> Enum.map(&TransactionMovement.maybe_convert_reward(&1, tx_type)) + |> TransactionMovement.aggregate() + + %__MODULE__{ops | transaction_movements: resolved_movements} + end + + @doc """ + Determine if the transaction has enough funds for it's movements + """ + @spec validate_sufficient_funds(ops :: t()) :: t() + def validate_sufficient_funds( + ops = %__MODULE__{ + fee: fee, + inputs: inputs, + minted_utxos: minted_utxos, + transaction_movements: movements + } + ) do + balances = + %{uco: uco_balance, token: tokens_balance} = ledger_balances(inputs ++ minted_utxos) + + amount_to_spend = + %{uco: uco_to_spend, token: tokens_to_spend} = total_to_spend(fee, movements) + + %__MODULE__{ + ops + | sufficient_funds?: + sufficient_funds?(uco_balance, uco_to_spend, tokens_balance, tokens_to_spend), + balances: balances, + amount_to_spend: amount_to_spend + } + end + + defp total_to_spend(fee, movements) do + Enum.reduce(movements, %{uco: fee, token: %{}}, fn + %TransactionMovement{type: :UCO, amount: amount}, acc -> + Map.update!(acc, :uco, &(&1 + amount)) + + %TransactionMovement{type: {:token, token_address, token_id}, amount: amount}, acc -> + update_in(acc, [:token, Access.key({token_address, token_id}, 0)], &(&1 + amount)) + + _, acc -> + acc + end) + end + + defp ledger_balances(utxos) do + Enum.reduce(utxos, %{uco: 0, token: %{}}, fn + %VersionedUnspentOutput{unspent_output: %UnspentOutput{type: :UCO, amount: amount}}, acc -> + Map.update!(acc, :uco, &(&1 + amount)) + + %VersionedUnspentOutput{ + unspent_output: %UnspentOutput{type: {:token, token_address, token_id}, amount: amount} + }, + acc -> + update_in(acc, [:token, Access.key({token_address, token_id}, 0)], &(&1 + amount)) + + _, acc -> + acc + end) + end + + defp sufficient_funds?(uco_balance, uco_to_spend, tokens_balance, tokens_to_spend) do + uco_balance >= uco_to_spend and sufficient_tokens?(tokens_balance, tokens_to_spend) + end + + defp sufficient_tokens?(tokens_received = %{}, token_to_spend = %{}) + when map_size(tokens_received) == 0 and map_size(token_to_spend) > 0, + do: false + + defp sufficient_tokens?(_tokens_received, tokens_to_spend) when map_size(tokens_to_spend) == 0, + do: true + + defp sufficient_tokens?(tokens_received, tokens_to_spend) do + Enum.all?(tokens_to_spend, fn {token_key, amount_to_spend} -> + case Map.get(tokens_received, token_key) do + nil -> + false + + recv_amount -> + recv_amount >= amount_to_spend + end + end) + end + + @doc """ + Convert Mining LedgerOperations to ValidationStamp LedgerOperations + """ + @spec to_ledger_operations(ops :: t()) :: LedgerOperations.t() + def to_ledger_operations(%__MODULE__{ + transaction_movements: movements, + unspent_outputs: utxos, + fee: fee, + consumed_inputs: consumed_inputs + }) do + %LedgerOperations{ + transaction_movements: movements, + unspent_outputs: utxos, + fee: fee, + consumed_inputs: consumed_inputs + } + end + + @doc """ + Use the necessary inputs to satisfy the uco amount to spend + The remaining unspent outputs will go to the change address + Also return a boolean indicating if there was sufficient funds + """ + @spec consume_inputs( + ops :: t(), + change_address :: binary(), + timestamp :: DateTime.t(), + encoded_state :: State.encoded() | nil, + contract_context :: ContractContext.t() | nil + ) :: t() + def consumed_inputs(ops = %__MODULE__{sufficient_funds?: false}), do: ops + + def consume_inputs( + ops = %__MODULE__{ + inputs: inputs, + minted_utxos: minted_utxos, + balances: %{uco: uco_balance, token: tokens_balance}, + amount_to_spend: %{uco: uco_to_spend, token: tokens_to_spend} + }, + change_address, + timestamp = %DateTime{}, + encoded_state \\ nil, + contract_context \\ nil + ) do + # Since AEIP-19 we can consume from minted tokens + # Sort inputs, to have consistent results across all nodes + consolidated_inputs = + minted_utxos + |> Enum.map(fn utxo -> + # As the minted tokens are used internally during transaction's validation + # and doesn't not exists outside, we use the burning address + # to identify inputs coming from the token's minting. + put_in(utxo, [Access.key!(:unspent_output), Access.key!(:from)], burning_address()) + end) + |> Enum.concat(inputs) + |> Enum.sort({:asc, VersionedUnspentOutput}) + + versioned_consumed_utxos = + get_inputs_to_consume( + consolidated_inputs, + uco_to_spend, + tokens_to_spend, + uco_balance, + tokens_balance, + contract_context + ) + + consumed_utxos = VersionedUnspentOutput.unwrap_unspent_outputs(versioned_consumed_utxos) + minted_utxos = VersionedUnspentOutput.unwrap_unspent_outputs(minted_utxos) + + new_unspent_outputs = + tokens_utxos( + tokens_to_spend, + consumed_utxos, + minted_utxos, + change_address, + timestamp + ) + |> add_uco_utxo(consumed_utxos, uco_to_spend, change_address, timestamp) + |> Enum.filter(&(&1.amount > 0)) + |> add_state_utxo(encoded_state, change_address, timestamp) + + %__MODULE__{ + ops + | unspent_outputs: new_unspent_outputs, + consumed_inputs: versioned_consumed_utxos + } + end + + defp tokens_utxos( + tokens_to_spend, + consumed_utxos, + tokens_to_mint, + change_address, + timestamp + ) do + tokens_minted_not_consumed = + Enum.reject(tokens_to_mint, fn %UnspentOutput{type: {:token, token_address, token_id}} -> + Map.has_key?(tokens_to_spend, {token_address, token_id}) + end) + + consumed_utxos + |> Enum.group_by(& &1.type) + |> Enum.filter(&match?({{:token, _, _}, _}, &1)) + |> Enum.reduce([], fn {type = {:token, token_address, token_id}, utxos}, acc -> + amount_to_spend = Map.get(tokens_to_spend, {token_address, token_id}, 0) + consumed_amount = utxos |> Enum.map(& &1.amount) |> Enum.sum() + remaining_amount = consumed_amount - amount_to_spend + + if remaining_amount > 0 do + new_utxo = %UnspentOutput{ + from: change_address, + amount: remaining_amount, + type: type, + timestamp: timestamp + } + + [new_utxo | acc] + else + acc + end + end) + |> Enum.concat(tokens_minted_not_consumed) + end + + defp get_inputs_to_consume( + inputs, + uco_to_spend, + tokens_to_spend, + uco_balance, + tokens_balance, + contract_context + ) do + inputs + # We group by type to count them and determine if we need to consume the inputs + |> Enum.group_by(& &1.unspent_output.type) + |> Enum.flat_map(fn + {:UCO, inputs} -> + get_uco_to_consume(inputs, uco_to_spend, uco_balance) + + {{:token, token_address, token_id}, inputs} -> + key = {token_address, token_id} + get_token_to_consume(inputs, key, tokens_to_spend, tokens_balance) + + {:state, state_utxos} -> + state_utxos + + {:call, inputs} -> + get_call_to_consume(inputs, contract_context) + end) + end + + defp get_uco_to_consume(inputs, uco_to_spend, uco_balance) when uco_to_spend > 0, + do: optimize_inputs_to_consume(inputs, uco_to_spend, uco_balance) + + # The consolidation happens when there are at least more than one UTXO of the same type + # This reduces the storage size on both genesis's inputs and further transactions + defp get_uco_to_consume(inputs, _, _) when length(inputs) > 1, do: inputs + defp get_uco_to_consume(_, _, _), do: [] + + defp get_token_to_consume(inputs, key, tokens_to_spend, tokens_balance) + when is_map_key(tokens_to_spend, key) do + amount_to_spend = Map.get(tokens_to_spend, key) + token_balance = Map.get(tokens_balance, key) + optimize_inputs_to_consume(inputs, amount_to_spend, token_balance) + end + + defp get_token_to_consume(inputs, _, _, _) when length(inputs) > 1, do: inputs + defp get_token_to_consume(_, _, _, _), do: [] + + defp get_call_to_consume(inputs, %ContractContext{trigger: {:transaction, address, _}}) do + inputs + |> Enum.find(&(&1.unspent_output.from == address)) + |> then(fn + nil -> [] + contract_call_input -> [contract_call_input] + end) + end + + defp get_call_to_consume(_, _), do: [] + + defp optimize_inputs_to_consume(inputs, _, _) when length(inputs) == 1, do: inputs + + defp optimize_inputs_to_consume(inputs, amount_to_spend, balance_amount) + when balance_amount == amount_to_spend, + do: inputs + + defp optimize_inputs_to_consume(inputs, amount_to_spend, balance_amount) do + # Search if we can consume all inputs except one. This will avoid doing consolidation + remaining_amount = balance_amount - amount_to_spend + + case Enum.find(inputs, &(&1.unspent_output.amount == remaining_amount)) do + nil -> inputs + input -> Enum.reject(inputs, &(&1 == input)) + end + end + + defp add_uco_utxo(utxos, consumed_utxos, uco_to_spend, change_address, timestamp) do + consumed_amount = + consumed_utxos |> Enum.filter(&(&1.type == :UCO)) |> Enum.map(& &1.amount) |> Enum.sum() + + remaining_amount = consumed_amount - uco_to_spend + + if remaining_amount > 0 do + new_utxo = %UnspentOutput{ + from: change_address, + amount: remaining_amount, + type: :UCO, + timestamp: timestamp + } + + [new_utxo | utxos] + else + utxos + end + end + + defp add_state_utxo(utxos, nil, _change_address, _timestamp), do: utxos + + defp add_state_utxo(utxos, encoded_state, change_address, timestamp) do + new_utxo = %UnspentOutput{ + type: :state, + encoded_payload: encoded_state, + timestamp: timestamp, + from: change_address + } + + [new_utxo | utxos] + end +end diff --git a/lib/archethic/mining/pending_transaction_validation.ex b/lib/archethic/mining/pending_transaction_validation.ex index a9076a12b..2ddb2dc25 100644 --- a/lib/archethic/mining/pending_transaction_validation.ex +++ b/lib/archethic/mining/pending_transaction_validation.ex @@ -14,8 +14,6 @@ defmodule Archethic.Mining.PendingTransactionValidation do alias Archethic.Networking - alias Archethic.Mining.Error - alias Archethic.OracleChain alias Archethic.P2P @@ -73,34 +71,10 @@ defmodule Archethic.Mining.PendingTransactionValidation do @tx_max_size Application.compile_env!(:archethic, :transaction_data_content_max_size) @doc """ - Determines if the transaction is accepted into the network + Ensure transaction size does not exceed the limit size """ - @spec validate(Transaction.t(), DateTime.t()) :: :ok | {:error, Error.t()} - def validate(tx = %Transaction{type: type}, validation_time = %DateTime{} \\ DateTime.utc_now()) do - start = System.monotonic_time() - - with :ok <- do_accept_transaction(tx, validation_time), - :ok <- valid_previous_public_key(tx), - :ok <- valid_previous_signature(tx), - :ok <- validate_size(tx), - :ok <- validate_contract(tx), - :ok <- validate_ownerships(tx), - :ok <- validate_non_fungible_token_transfer(tx), - :ok <- validate_previous_transaction_type(tx), - :ok <- valid_not_exists(tx) do - :telemetry.execute( - [:archethic, :mining, :pending_transaction_validation], - %{duration: System.monotonic_time() - start}, - %{transaction_type: type} - ) - - :ok - else - {:error, reason} -> {:error, Error.new(:invalid_pending_transaction, reason)} - end - end - - defp validate_size(%Transaction{data: data, version: tx_version}) do + @spec validate_size(transaction :: Transaction.t()) :: :ok | {:error, String.t()} + def validate_size(%Transaction{data: data, version: tx_version}) do tx_size = data |> TransactionData.serialize(tx_version) @@ -113,7 +87,12 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp valid_not_exists(%Transaction{address: address}) do + @doc """ + Ensure the transaction does not already exists + """ + @spec validate_not_exists(transaction :: Transaction.t()) :: + :ok | {:error, String.t()} + def validate_not_exists(%Transaction{address: address}) do storage_nodes = Election.chain_storage_nodes(address, P2P.authorized_and_available_nodes()) if TransactionChain.transaction_exists_globally?(address, storage_nodes) do @@ -123,7 +102,11 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp valid_previous_public_key(tx = %Transaction{address: address}) do + @doc """ + Ensure previous public key does not correspond to the current transaction address + """ + @spec validate_previous_public_key(transaction :: Transaction.t()) :: :ok | {:error, String.t()} + def validate_previous_public_key(tx = %Transaction{address: address}) do if Transaction.previous_address(tx) == address do {:error, "Invalid previous public key (should be chain index - 1)"} else @@ -131,7 +114,11 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp valid_previous_signature(tx = %Transaction{}) do + @doc """ + Ensure previous signature is valid for the current transaction + """ + @spec validate_previous_signature(transaction :: Transaction.t()) :: :ok | {:error, String.t()} + def validate_previous_signature(tx = %Transaction{}) do if Transaction.verify_previous_signature?(tx) do :ok else @@ -139,11 +126,15 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp validate_contract(%Transaction{data: %TransactionData{code: ""}}), do: :ok + @doc """ + Ensure contract is valid (size, code, ownerships) + """ + @spec validate_contract(transaction :: Transaction.t()) :: :ok | {:error, String.t()} + def validate_contract(%Transaction{data: %TransactionData{code: ""}}), do: :ok - defp validate_contract(%Transaction{ - data: %TransactionData{code: code, ownerships: ownerships} - }) do + def validate_contract(%Transaction{ + data: %TransactionData{code: code, ownerships: ownerships} + }) do with :ok <- validate_code_size(code), {:ok, contract} <- parse_contract(code) do validate_contract_ownership(contract, ownerships) @@ -176,10 +167,13 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - @spec validate_ownerships(Transaction.t()) :: :ok | {:error, any()} - defp validate_ownerships(%Transaction{data: %TransactionData{ownerships: []}}), do: :ok + @doc """ + Ensure ownerships are well formated + """ + @spec validate_ownerships(transaction :: Transaction.t()) :: :ok | {:error, String.t()} + def validate_ownerships(%Transaction{data: %TransactionData{ownerships: []}}), do: :ok - defp validate_ownerships(%Transaction{data: %TransactionData{ownerships: ownerships}}) do + def validate_ownerships(%Transaction{data: %TransactionData{ownerships: ownerships}}) do Enum.reduce_while(ownerships, :ok, fn ownership, :ok -> case Ownership.validate_format(ownership) do :ok -> @@ -192,28 +186,38 @@ defmodule Archethic.Mining.PendingTransactionValidation do end) end - defp validate_non_fungible_token_transfer(%Transaction{ - data: %TransactionData{ledger: %Ledger{token: %TokenLedger{transfers: token_transfer}}} - }) do + @doc """ + Ensure non fungible token are sent by units + """ + @spec validate_non_fungible_token_transfer(transaction :: Transaction.t()) :: + :ok | {:error, String.t()} + def validate_non_fungible_token_transfer(%Transaction{ + data: %TransactionData{ledger: %Ledger{token: %TokenLedger{transfers: token_transfer}}} + }) do # non fungible token can be sent only by unit if Enum.any?(token_transfer, &(&1.token_id != 0 and &1.amount != @unit_uco)), do: {:error, "Non fungible token can only be sent by unit"}, else: :ok end - defp do_accept_transaction( - %Transaction{ - type: :transfer, - data: %TransactionData{ - ledger: %Ledger{ - uco: %UCOLedger{transfers: uco_transfers}, - token: %TokenLedger{transfers: token_transfers} - }, - recipients: recipients - } - }, - _ - ) do + @doc """ + Ensure transaction respects rules according to it's type + """ + @spec validate_type_rules(transaction :: Transaction.t(), validation_time :: DateTime.t()) :: + :ok | {:error, String.t()} + def validate_type_rules( + %Transaction{ + type: :transfer, + data: %TransactionData{ + ledger: %Ledger{ + uco: %UCOLedger{transfers: uco_transfers}, + token: %TokenLedger{transfers: token_transfers} + }, + recipients: recipients + } + }, + _ + ) do if length(uco_transfers) > 0 or length(token_transfers) > 0 or length(recipients) > 0 do :ok else @@ -222,13 +226,13 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction( - %Transaction{ - type: :hosting, - data: %TransactionData{content: content} - }, - _ - ) do + def validate_type_rules( + %Transaction{ + type: :hosting, + data: %TransactionData{content: content} + }, + _ + ) do with {:ok, json} <- Jason.decode(content), {:schema, :ok} <- {:schema, ExJsonSchema.Validator.validate(@aeweb_schema, json)} do :ok @@ -241,17 +245,17 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction( - tx = %Transaction{ - type: :node_rewards, - data: %TransactionData{ - ledger: %Ledger{ - token: %TokenLedger{transfers: token_transfers} - } - } - }, - validation_time - ) do + def validate_type_rules( + tx = %Transaction{ + type: :node_rewards, + data: %TransactionData{ + ledger: %Ledger{ + token: %TokenLedger{transfers: token_transfers} + } + } + }, + validation_time + ) do last_scheduling_date = Reward.get_last_scheduling_date(validation_time) genesis_address = @@ -299,21 +303,21 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction( - %Transaction{ - type: :node, - data: %TransactionData{ - content: content, - ledger: %Ledger{ - token: %TokenLedger{ - transfers: token_transfers - } - } - }, - previous_public_key: previous_public_key - }, - _ - ) do + def validate_type_rules( + %Transaction{ + type: :node, + data: %TransactionData{ + content: content, + ledger: %Ledger{ + token: %TokenLedger{ + transfers: token_transfers + } + } + }, + previous_public_key: previous_public_key + }, + _ + ) do with {:ok, ip, port, _http_port, _, _, origin_public_key, key_certificate, mining_public_key} <- Node.decode_transaction_content(content), {:auth_origin, true} <- @@ -362,15 +366,15 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction( - %Transaction{ - type: :origin, - data: %TransactionData{ - content: content - } - }, - _ - ) do + def validate_type_rules( + %Transaction{ + type: :origin, + data: %TransactionData{ + content: content + } + }, + _ + ) do with {origin_public_key, rest} <- Utils.deserialize_public_key(content), < 0 and map_size(authorized_keys) > 0 do + def validate_type_rules( + %Transaction{ + type: :node_shared_secrets, + data: %TransactionData{ + content: content, + ownerships: [%Ownership{secret: secret, authorized_keys: authorized_keys}] + } + }, + validation_time + ) + when is_binary(secret) and byte_size(secret) > 0 and map_size(authorized_keys) > 0 do last_scheduling_date = SharedSecrets.get_last_scheduling_date(validation_time) genesis_address = @@ -444,16 +448,16 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{type: :node_shared_secrets}, _) do + def validate_type_rules(%Transaction{type: :node_shared_secrets}, _) do {:error, "Invalid node shared secrets transaction"} end - defp do_accept_transaction( - tx = %Transaction{ - type: :code_proposal - }, - _ - ) do + def validate_type_rules( + tx = %Transaction{ + type: :code_proposal + }, + _ + ) do with {:ok, prop} <- CodeProposal.from_transaction(tx), true <- Governance.valid_code_changes?(prop) do :ok @@ -463,15 +467,15 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction( - tx = %Transaction{ - type: :code_approval, - data: %TransactionData{ - recipients: [%Recipient{address: proposal_address}] - } - }, - _ - ) do + def validate_type_rules( + tx = %Transaction{ + type: :code_approval, + data: %TransactionData{ + recipients: [%Recipient{address: proposal_address}] + } + }, + _ + ) do with {:ok, first_public_key} <- get_first_public_key(tx), {:member, true} <- {:member, Governance.pool_member?(first_public_key, :technical_council)}, @@ -494,33 +498,33 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction( - %Transaction{ - type: :code_approval, - data: %TransactionData{ - recipients: [] - } - }, - _ - ), - do: {:error, "No recipient specified in code approval"} - - defp do_accept_transaction( - %Transaction{ - type: :keychain, - data: %TransactionData{ - ownerships: ownerships, - content: content, - ledger: %Ledger{ - uco: %UCOLedger{transfers: []}, - token: %TokenLedger{transfers: []} - }, - recipients: [] - } - }, - _ - ) - when content != "" and ownerships != [] do + def validate_type_rules( + %Transaction{ + type: :code_approval, + data: %TransactionData{ + recipients: [] + } + }, + _ + ), + do: {:error, "No recipient specified in code approval"} + + def validate_type_rules( + %Transaction{ + type: :keychain, + data: %TransactionData{ + ownerships: ownerships, + content: content, + ledger: %Ledger{ + uco: %UCOLedger{transfers: []}, + token: %TokenLedger{transfers: []} + }, + recipients: [] + } + }, + _ + ) + when content != "" and ownerships != [] do # ownerships validate in :ok <- validate_ownerships(tx), with {:ok, json_did} <- Jason.decode(content), :ok <- ExJsonSchema.Validator.validate(@did_schema, json_did) do @@ -535,25 +539,25 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{type: :keychain, data: _}, _), + def validate_type_rules(%Transaction{type: :keychain, data: _}, _), do: {:error, "Invalid Keychain transaction"} - defp do_accept_transaction( - %Transaction{ - type: :keychain_access, - previous_public_key: previous_public_key, - data: %TransactionData{ - content: "", - ownerships: [ownership = %Ownership{secret: _, authorized_keys: _}], - ledger: %Ledger{ - uco: %UCOLedger{transfers: []}, - token: %TokenLedger{transfers: []} - }, - recipients: [] - } - }, - _ - ) do + def validate_type_rules( + %Transaction{ + type: :keychain_access, + previous_public_key: previous_public_key, + data: %TransactionData{ + content: "", + ownerships: [ownership = %Ownership{secret: _, authorized_keys: _}], + ledger: %Ledger{ + uco: %UCOLedger{transfers: []}, + token: %TokenLedger{transfers: []} + }, + recipients: [] + } + }, + _ + ) do # ownerships validate in :ok <- validate_ownerships(tx), # forbid empty ownership or more than one secret, content, uco & token transfers if Ownership.authorized_public_key?(ownership, previous_public_key) do @@ -563,28 +567,19 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{type: :keychain_access}, _), + def validate_type_rules(%Transaction{type: :keychain_access}, _), do: {:error, "Invalid Keychain Access transaction"} - defp do_accept_transaction( - tx = %Transaction{ - type: :token - }, - _ - ) do - verify_token_transaction(tx) - end + # Already check by validate_token_transaction function + def validate_type_rules(%Transaction{type: :token}, _), do: :ok # To accept mint_rewards transaction, we ensure that the supply correspond to the # burned fees from the last summary and that there is no transaction since the last # reward schedule - defp do_accept_transaction( - tx = %Transaction{ - type: :mint_rewards, - data: %TransactionData{content: content} - }, - _ - ) do + def validate_type_rules( + %Transaction{type: :mint_rewards, data: %TransactionData{content: content}}, + _ + ) do total_fee = DB.get_latest_burned_fees() genesis_address = @@ -592,32 +587,26 @@ defmodule Archethic.Mining.PendingTransactionValidation do {last_address, _} = TransactionChain.get_last_address(genesis_address) - with :ok <- verify_token_transaction(tx), - {:ok, %{"supply" => ^total_fee}} <- Jason.decode(content), + with {:ok, %{"supply" => ^total_fee}} <- Jason.decode(content), {^last_address, _} <- TransactionChain.get_last_address(genesis_address, Reward.get_last_scheduling_date()) do :ok else - {:ok, %{"supply" => _}} -> - {:error, "The supply do not match burned fees from last summary"} - - {_, _} -> - {:error, "There is already a mint rewards transaction since last schedule"} - - e -> - e + {:ok, %{"supply" => _}} -> {:error, "The supply do not match burned fees from last summary"} + {_, _} -> {:error, "There is already a mint rewards transaction since last schedule"} + e -> e end end - defp do_accept_transaction( - %Transaction{ - type: :oracle, - data: %TransactionData{ - content: content - } - }, - validation_time - ) do + def validate_type_rules( + %Transaction{ + type: :oracle, + data: %TransactionData{ + content: content + } + }, + validation_time + ) do last_scheduling_date = OracleChain.get_last_scheduling_date(validation_time) genesis_address = @@ -640,16 +629,16 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction( - %Transaction{ - type: :oracle_summary, - data: %TransactionData{ - content: content - }, - previous_public_key: previous_public_key - }, - validation_time - ) do + def validate_type_rules( + %Transaction{ + type: :oracle_summary, + data: %TransactionData{ + content: content + }, + previous_public_key: previous_public_key + }, + validation_time + ) do previous_address = Crypto.derive_address(previous_public_key) last_scheduling_date = OracleChain.get_last_scheduling_date(validation_time) @@ -682,18 +671,23 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{type: :contract, data: %TransactionData{code: ""}}, _), + def validate_type_rules(%Transaction{type: :contract, data: %TransactionData{code: ""}}, _), do: {:error, "Invalid contract type transaction - code is empty"} - defp do_accept_transaction( - %Transaction{type: :data, data: %TransactionData{content: "", ownerships: []}}, - _ - ), - do: {:error, "Invalid data type transaction - Both content & ownership are empty"} + def validate_type_rules( + %Transaction{type: :data, data: %TransactionData{content: "", ownerships: []}}, + _ + ), + do: {:error, "Invalid data type transaction - Both content & ownership are empty"} - defp do_accept_transaction(_, _), do: :ok + def validate_type_rules(_, _), do: :ok - defp validate_previous_transaction_type(tx) do + @doc """ + Ensure network transactions are in the expected chain + """ + @spec validate_network_chain(transaction :: Transaction.t()) :: + :ok | {:error, String.t()} + def validate_network_chain(tx) do case Transaction.network_type?(tx.type) do false -> # not a network tx, no need to validate with last tx @@ -707,18 +701,6 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - @spec valid_network_chain?( - :code_approval - | :code_proposal - | :mint_rewards - | :node - | :node_rewards - | :node_shared_secrets - | :oracle - | :oracle_summary - | :origin, - Archethic.TransactionChain.Transaction.t() - ) :: boolean defp valid_network_chain?(type, tx = %Transaction{}) when type in [:oracle, :oracle_summary] do with local_gen_addr when local_gen_addr != nil <- OracleChain.genesis_addresses(), @@ -768,19 +750,26 @@ defmodule Archethic.Mining.PendingTransactionValidation do |> TransactionChain.fetch_genesis_address(P2P.authorized_and_available_nodes()) end - defp verify_token_transaction(tx = %Transaction{data: %TransactionData{content: content}}) do + @doc """ + Ensure token transaction is valid and returns token decimals + """ + @spec validate_token_transaction(transaction :: Transaction.t()) :: :ok | {:error, String.t()} + def validate_token_transaction( + tx = %Transaction{type: type, data: %TransactionData{content: content}} + ) + when type in [:token, :mint_rewards] do with {:ok, json_token} <- Jason.decode(content), :ok <- verify_token_creation(tx, json_token) do verify_token_recipients(json_token) + :ok else - {:error, %Jason.DecodeError{}} -> - {:error, "Invalid token transaction - invalid JSON"} - - {:error, reason} -> - {:error, reason} + {:error, %Jason.DecodeError{}} -> {:error, "Invalid token transaction - invalid JSON"} + {:error, reason} -> {:error, reason} end end + def validate_token_transaction(_), do: :ok + defp verify_token_creation(tx, json_token) do cond do ExJsonSchema.Validator.valid?(@token_creation_schema, json_token) -> diff --git a/lib/archethic/mining/standalone_workflow.ex b/lib/archethic/mining/standalone_workflow.ex index 0d3ce8e45..fa44b6c81 100644 --- a/lib/archethic/mining/standalone_workflow.ex +++ b/lib/archethic/mining/standalone_workflow.ex @@ -16,7 +16,6 @@ defmodule Archethic.Mining.StandaloneWorkflow do alias Archethic.Election alias Archethic.Mining.Error - alias Archethic.Mining.PendingTransactionValidation alias Archethic.Mining.TransactionContext alias Archethic.Mining.ValidationContext alias Archethic.Mining.WorkflowRegistry @@ -131,11 +130,7 @@ defmodule Archethic.Mining.StandaloneWorkflow do genesis_address: genesis_address ) - validation_context = - case PendingTransactionValidation.validate(tx, validation_time) do - :ok -> validation_context - {:error, error} -> ValidationContext.set_mining_error(validation_context, error) - end + validation_context = ValidationContext.validate_pending_transaction(validation_context) validation_context = %ValidationContext{mining_error: mining_error} = diff --git a/lib/archethic/mining/validation_context.ex b/lib/archethic/mining/validation_context.ex index 1b7d4727b..de5333249 100644 --- a/lib/archethic/mining/validation_context.ex +++ b/lib/archethic/mining/validation_context.ex @@ -50,6 +50,8 @@ defmodule Archethic.Mining.ValidationContext do alias Archethic.Mining alias Archethic.Mining.Fee alias Archethic.Mining.Error + alias Archethic.Mining.LedgerValidation + alias Archethic.Mining.PendingTransactionValidation alias Archethic.Mining.ProofOfWork alias Archethic.Mining.SmartContractValidation @@ -78,15 +80,16 @@ defmodule Archethic.Mining.ValidationContext do previous_transaction: nil | Transaction.t(), unspent_outputs: list(VersionedUnspentOutput.t()), resolved_addresses: %{Crypto.prepended_hash() => Crypto.prepended_hash()}, - welcome_node: Node.t(), - coordinator_node: Node.t(), - cross_validation_nodes: list(Node.t()), + welcome_node: nil | Node.t(), + coordinator_node: nil | Node.t(), + cross_validation_nodes: nil | list(Node.t()), previous_storage_nodes: list(Node.t()), chain_storage_nodes: list(Node.t()), beacon_storage_nodes: list(Node.t()), io_storage_nodes: list(Node.t()), cross_validation_nodes_confirmation: bitstring(), validation_stamp: nil | ValidationStamp.t(), + validation_time: DateTime.t(), full_replication_tree: %{ chain: list(bitstring()), beacon: list(bitstring()), @@ -103,7 +106,7 @@ defmodule Archethic.Mining.ValidationContext do storage_nodes_confirmations: list({index :: non_neg_integer(), signature :: binary()}), sub_replication_tree_validations: list(Crypto.key()), contract_context: nil | Contract.Context.t(), - genesis_address: binary(), + genesis_address: nil | Crypto.prepended_hash(), aggregated_utxos: list(VersionedUnspentOutput.t()), mining_error: Error.t() | nil } @@ -148,15 +151,6 @@ defmodule Archethic.Mining.ValidationContext do ) end - @doc """ - Set the mining error. If one is already set, keeps the existing one - """ - @spec set_mining_error(context :: t(), mining_error :: Error.t()) :: t() - def set_mining_error(context = %__MODULE__{mining_error: nil}, mining_error), - do: %__MODULE__{context | mining_error: mining_error} - - def set_mining_error(context, _), do: context - @doc """ Determine if the enough context has been retrieved @@ -286,6 +280,51 @@ defmodule Archethic.Mining.ValidationContext do %{context | validation_stamp: stamp} end + @doc """ + Set the mining error to the mining context + """ + @spec set_mining_error(context :: t(), mining_error :: Error.t()) :: t() + def set_mining_error(context = %__MODULE__{mining_error: nil}, mining_error), + do: %__MODULE__{context | mining_error: mining_error} + + def set_mining_error(context, _), do: context + + @doc """ + Determines if the transaction is accepted into the network + """ + @spec validate_pending_transaction(context :: t()) :: t() + def validate_pending_transaction( + context = %__MODULE__{ + transaction: tx = %Transaction{type: type}, + validation_time: validation_time + } + ) do + start = System.monotonic_time() + + with :ok <- PendingTransactionValidation.validate_previous_public_key(tx), + :ok <- PendingTransactionValidation.validate_previous_signature(tx), + :ok <- PendingTransactionValidation.validate_size(tx), + :ok <- PendingTransactionValidation.validate_contract(tx), + :ok <- PendingTransactionValidation.validate_ownerships(tx), + :ok <- PendingTransactionValidation.validate_non_fungible_token_transfer(tx), + :ok <- PendingTransactionValidation.validate_token_transaction(tx), + :ok <- PendingTransactionValidation.validate_type_rules(tx, validation_time), + :ok <- PendingTransactionValidation.validate_network_chain(tx), + :ok <- PendingTransactionValidation.validate_not_exists(tx) do + :telemetry.execute( + [:archethic, :mining, :pending_transaction_validation], + %{duration: System.monotonic_time() - start}, + %{transaction_type: type} + ) + + context + else + {:error, reason} -> + error = Error.new(:invalid_pending_transaction, reason) + %__MODULE__{context | mining_error: error} + end + end + @doc """ Determines if the expected cross validation stamps have been received @@ -760,29 +799,26 @@ defmodule Archethic.Mining.ValidationContext do movements = Transaction.get_movements(tx) protocol_version = Mining.protocol_version() - ops = %LedgerOperations{fee: fee} - - case LedgerOperations.consume_inputs( - ops, - address, - validation_time |> DateTime.truncate(:millisecond), - unspent_outputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, validation_time, protocol_version), - encoded_state, - contract_context - ) do - {:ok, ops} -> - new_ops = - LedgerOperations.build_resolved_movements(ops, movements, resolved_addresses, tx_type) - - {context, new_ops} + ops = + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, validation_time, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx_type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs( + address, + validation_time, + encoded_state, + contract_context + ) - {:error, :insufficient_funds} -> - new_ops = - LedgerOperations.build_resolved_movements(ops, movements, resolved_addresses, tx_type) + case ops do + %LedgerValidation{sufficient_funds?: false} -> + {set_mining_error(context, Error.new(:insufficient_funds)), + LedgerValidation.to_ledger_operations(ops)} - {set_mining_error(context, Error.new(:insufficient_funds)), new_ops} + _ -> + {context, LedgerValidation.to_ledger_operations(ops)} end end @@ -1209,8 +1245,9 @@ defmodule Archethic.Mining.ValidationContext do movements = Transaction.get_movements(tx) %LedgerOperations{transaction_movements: resolved_movements} = - %LedgerOperations{} - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx_type) + %LedgerValidation{} + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx_type) + |> LedgerValidation.to_ledger_operations() length(resolved_movements) == length(transaction_movements) and Enum.all?(resolved_movements, &(&1 in transaction_movements)) @@ -1226,9 +1263,7 @@ defmodule Archethic.Mining.ValidationContext do defp valid_stamp_unspent_outputs?( %ValidationStamp{ - ledger_operations: %LedgerOperations{ - unspent_outputs: next_unspent_outputs - } + ledger_operations: %LedgerOperations{unspent_outputs: next_unspent_outputs} }, %LedgerOperations{unspent_outputs: expected_unspent_outputs} ) do diff --git a/lib/archethic/p2p/message/validate_smart_contract_call.ex b/lib/archethic/p2p/message/validate_smart_contract_call.ex index 16770efd3..ded9e448a 100644 --- a/lib/archethic/p2p/message/validate_smart_contract_call.ex +++ b/lib/archethic/p2p/message/validate_smart_contract_call.ex @@ -17,12 +17,14 @@ defmodule Archethic.P2P.Message.ValidateSmartContractCall do alias Archethic.Contracts.Contract.Failure alias Archethic.Crypto alias Archethic.Mining + alias Archethic.Mining.LedgerValidation alias Archethic.OracleChain alias Archethic.P2P.Message.SmartContractCallValidation alias Archethic.TransactionChain alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.Transaction.ValidationStamp - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations + + alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.UnspentOutput alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput @@ -30,8 +32,6 @@ defmodule Archethic.P2P.Message.ValidateSmartContractCall do alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.TransactionMovement - alias Archethic.UTXO - @type t :: %__MODULE__{ recipient: Recipient.t(), transaction: Transaction.t(), @@ -143,16 +143,18 @@ defmodule Archethic.P2P.Message.ValidateSmartContractCall do unspent_outputs, datetime ), + {:ok, execution_result} <- sign_next_transaction(contract, execution_result), :ok <- validate_enough_funds( transaction, recipient_address, execution_result, - unspent_outputs + unspent_outputs, + timestamp ) do %SmartContractCallValidation{ status: :ok, - fee: calculate_fee(execution_result, contract, datetime), + fee: calculate_fee(execution_result, datetime), last_chain_sync_date: timestamp } else @@ -211,84 +213,80 @@ defmodule Archethic.P2P.Message.ValidateSmartContractCall do end end - defp validate_enough_funds(transaction, recipient_address, execution_result, unspent_outputs) do - transfers_to_contract = + defp sign_next_transaction( + contract = %Contract{transaction: %Transaction{address: contract_address}}, + res = %ActionWithTransaction{next_tx: next_tx} + ) do + index = TransactionChain.get_size(contract_address) + + case Contract.sign_next_transaction(contract, next_tx, index) do + {:ok, tx} -> {:ok, %ActionWithTransaction{res | next_tx: tx}} + _ -> {:error, :parsing_error, "Unable to sign contract transaction"} + end + end + + defp sign_next_transaction(_contract, res), do: {:ok, res} + + defp validate_enough_funds( + transaction = %Transaction{address: from}, + recipient_address, + execution_result, + unspent_outputs, + timestamp + ) do + protocol_version = Mining.protocol_version() + + unspent_outputs = transaction |> Transaction.get_movements() |> Enum.filter(fn movement -> TransactionChain.get_genesis_address(movement.to) == recipient_address end) + |> Enum.map(fn %TransactionMovement{type: type, amount: amount} -> + %UnspentOutput{from: from, amount: amount, type: type, timestamp: timestamp} + end) + |> Enum.concat(unspent_outputs) + |> VersionedUnspentOutput.wrap_unspent_outputs(protocol_version) - if enough_funds_to_send?(execution_result, unspent_outputs, transfers_to_contract), + if enough_funds_to_send?(execution_result, unspent_outputs, timestamp), do: :ok, else: {:error, :insufficient_funds} end defp calculate_fee( %ActionWithTransaction{next_tx: next_tx, encoded_state: encoded_state}, - contract = %Contract{transaction: %Transaction{address: contract_address}}, timestamp ) do - index = TransactionChain.get_size(contract_address) - - case Contract.sign_next_transaction(contract, next_tx, index) do - {:ok, tx} -> - previous_usd_price = - timestamp - |> OracleChain.get_last_scheduling_date() - |> OracleChain.get_uco_price() - |> Keyword.fetch!(:usd) - - # Here we use a nil contract_context as we return the fees the user has to pay for the contract - Mining.get_transaction_fee(tx, nil, previous_usd_price, timestamp, encoded_state) - - _ -> - 0 - end + previous_usd_price = + timestamp + |> OracleChain.get_last_scheduling_date() + |> OracleChain.get_uco_price() + |> Keyword.fetch!(:usd) + + # Here we use a nil contract_context as we return the fees the user has to pay for the contract + Mining.get_transaction_fee(next_tx, nil, previous_usd_price, timestamp, encoded_state) end - defp calculate_fee(_, _, _), do: 0 - - defp enough_funds_to_send?(%ActionWithTransaction{next_tx: tx}, inputs, tx_movements) do - %{token: minted_tokens} = - tx - |> LedgerOperations.get_utxos_from_transaction( - DateTime.utc_now(), - Archethic.Mining.protocol_version() - ) - |> VersionedUnspentOutput.unwrap_unspent_outputs() - |> UTXO.get_balance() - - %{uco: uco_balance, token: token_balances} = UTXO.get_balance(inputs) + defp calculate_fee(_, _), do: 0 - movements_balances = get_movements_balance(tx_movements) - - tx - |> Transaction.get_movements() - |> get_movements_balance() - |> Enum.all?(fn - {:uco, uco_to_spend} -> - uco_transferred = Map.get(movements_balances, :uco, 0) - uco_balance + uco_transferred >= uco_to_spend - - {{:token, token}, amount} -> - token_transferred = Map.get(movements_balances, {:token, token}, 0) - token_minted = Map.get(minted_tokens, token, 0) - token_balance = Map.get(token_balances, token, 0) - - token_balance + token_transferred + token_minted >= amount - end) + defp enough_funds_to_send?( + %ActionWithTransaction{next_tx: tx = %Transaction{type: tx_type}}, + inputs, + timestamp + ) do + movements = Transaction.get_movements(tx) + protocol_version = Mining.protocol_version() + resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() + + %LedgerValidation{sufficient_funds?: sufficient_funds?} = + %LedgerValidation{fee: 0} + |> LedgerValidation.filter_usable_inputs(inputs, nil) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx_type) + |> LedgerValidation.validate_sufficient_funds() + + sufficient_funds? end defp enough_funds_to_send?(%ActionWithoutTransaction{}, _inputs, _tx_movements), do: true - - defp get_movements_balance(tx_movements) do - Enum.reduce(tx_movements, %{}, fn - %TransactionMovement{type: :UCO, amount: amount}, acc -> - Map.update(acc, :uco, amount, &(&1 + amount)) - - %TransactionMovement{type: {:token, token_address, token_id}, amount: amount}, acc -> - Map.update(acc, {:token, {token_address, token_id}}, amount, &(amount + &1)) - end) - end end diff --git a/lib/archethic/replication.ex b/lib/archethic/replication.ex index c45f5a3ee..06f38ad71 100644 --- a/lib/archethic/replication.ex +++ b/lib/archethic/replication.ex @@ -18,6 +18,7 @@ defmodule Archethic.Replication do alias Archethic.Election alias Archethic.Governance alias Archethic.Mining.Error + alias Archethic.Mining.ValidationContext alias Archethic.OracleChain alias Archethic.P2P alias Archethic.P2P.Message @@ -50,38 +51,37 @@ defmodule Archethic.Replication do validation_inputs :: list(VersionedUnspentOutput.t()) ) :: :ok | {:error, Error.t()} def validate_transaction( - tx = %Transaction{address: address, type: type}, + tx = %Transaction{ + address: address, + type: type, + validation_stamp: validation_stamp = %ValidationStamp{timestamp: validation_time} + }, contract_context, validation_inputs ) do - if TransactionChain.transaction_exists?(address) do - Logger.warning("Transaction already exists", - transaction_address: Base.encode16(address), - transaction_type: type - ) - - {:error, Error.new(:invalid_pending_transaction, "Transaction already exists")} - else - start_time = System.monotonic_time() - - Logger.info("Replication validation started", - transaction_address: Base.encode16(address), - transaction_type: type - ) + start_time = System.monotonic_time() - {genesis_address, previous_tx, inputs} = fetch_context(tx) + Logger.info("Replication validation started", + transaction_address: Base.encode16(address), + transaction_type: type + ) - with :ok <- validate_validation_inputs(tx, validation_inputs, inputs), - :ok <- - TransactionValidator.validate( - tx, - previous_tx, - genesis_address, - validation_inputs, - contract_context - ) do - # Validate the transaction and check integrity from the previous transaction + {genesis_address, previous_tx, inputs, resolved_addresses} = fetch_context(tx) + validation_context = %ValidationContext{ + transaction: tx, + resolved_addresses: resolved_addresses, + contract_context: contract_context, + aggregated_utxos: validation_inputs, + unspent_outputs: inputs, + validation_time: validation_time, + validation_stamp: validation_stamp, + previous_transaction: previous_tx, + genesis_address: genesis_address + } + + case TransactionValidator.validate(validation_context) do + %ValidationContext{mining_error: nil} -> Logger.info("Replication validation finished", transaction_address: Base.encode16(address), transaction_type: type @@ -95,18 +95,15 @@ defmodule Archethic.Replication do %{role: :chain} ) - :ok - else - {:error, reason} -> - :ok = TransactionChain.write_ko_transaction(tx) + %ValidationContext{mining_error: error} -> + :ok = TransactionChain.write_ko_transaction(tx) - Logger.warning("Invalid transaction for replication - #{inspect(reason)}", - transaction_address: Base.encode16(address), - transaction_type: type - ) + Logger.warning("Invalid transaction for replication - #{inspect(error)}", + transaction_address: Base.encode16(address), + transaction_type: type + ) - {:error, reason} - end + {:error, error} end end @@ -206,7 +203,11 @@ defmodule Archethic.Replication do opts :: sync_options() ) :: :ok | {:error, Error.t()} def validate_and_store_transaction( - tx = %Transaction{address: address, type: type}, + tx = %Transaction{ + address: address, + type: type, + validation_stamp: stamp = %ValidationStamp{timestamp: validation_time} + }, genesis_address, opts \\ [] ) @@ -233,8 +234,14 @@ defmodule Archethic.Replication do transaction_type: type ) - case TransactionValidator.validate(tx) do - :ok -> + context = %ValidationContext{ + transaction: tx, + validation_stamp: stamp, + validation_time: validation_time + } + + case TransactionValidator.validate_consensus(context) do + %ValidationContext{mining_error: nil} -> :telemetry.execute( [:archethic, :replication, :validation], %{ @@ -247,15 +254,15 @@ defmodule Archethic.Replication do do: sync_transaction_chain(tx, genesis_address), else: synchronize_io_transaction(tx, genesis_address, opts) - {:error, reason} -> + %ValidationContext{mining_error: error} -> :ok = TransactionChain.write_ko_transaction(tx) - Logger.warning("Invalid transaction for replication - #{inspect(reason)}", + Logger.warning("Invalid transaction for replication - #{inspect(error)}", transaction_address: Base.encode16(address), transaction_type: type ) - {:error, reason} + {:error, error} end end end @@ -308,37 +315,27 @@ defmodule Archethic.Replication do TransactionContext.fetch_transaction(previous_address) end) + resolved_addresses_task = + Task.Supervisor.async(TaskSupervisor, fn -> + TransactionChain.resolve_transaction_addresses!(tx) + end) + genesis_address = Task.await(genesis_task) unspent_outputs = fetch_unspent_outputs(tx, genesis_address) - previous_transaction = Task.await(previous_transaction_task, Message.get_max_timeout() + 1000) + + [previous_transaction, resolved_addresses] = + Task.await_many( + [previous_transaction_task, resolved_addresses_task], + Message.get_max_timeout() + 1000 + ) Logger.debug("Previous transaction #{inspect(previous_transaction)}", transaction_address: Base.encode16(tx.address), transaction_type: tx.type ) - {genesis_address, previous_transaction, unspent_outputs} - end - - defp validate_validation_inputs( - %Transaction{address: address, type: type}, - validation_inputs, - inputs - ) do - case Enum.reject(validation_inputs, &(&1 in inputs)) do - [] -> - :ok - - inputs_not_found -> - Logger.error( - "Invalid validation inputs - inputs not found: #{inspect(inputs_not_found)}", - transaction_address: Base.encode16(address), - transaction_type: type - ) - - {:error, Error.new(:consensus_not_reached, "Invalid validation inputs")} - end + {genesis_address, previous_transaction, unspent_outputs, resolved_addresses} end defp fetch_unspent_outputs(%Transaction{address: address, type: type}, genesis_address) do diff --git a/lib/archethic/replication/transaction_validator.ex b/lib/archethic/replication/transaction_validator.ex index 9cc5e07fc..c58063902 100644 --- a/lib/archethic/replication/transaction_validator.ex +++ b/lib/archethic/replication/transaction_validator.ex @@ -1,28 +1,16 @@ defmodule Archethic.Replication.TransactionValidator do @moduledoc false - alias Archethic.Bootstrap - alias Archethic.Contracts.Contract - alias Archethic.Crypto alias Archethic.DB alias Archethic.Election - alias Archethic.Mining alias Archethic.Mining.Error - alias Archethic.Mining.Fee - alias Archethic.Mining.SmartContractValidation - alias Archethic.OracleChain + alias Archethic.Mining.ValidationContext alias Archethic.P2P alias Archethic.P2P.Node alias Archethic.SharedSecrets - alias Archethic.TransactionChain alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.Transaction.CrossValidationStamp alias Archethic.TransactionChain.Transaction.ValidationStamp - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations - - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput - - alias Archethic.TransactionChain.TransactionData - alias Archethic.TransactionChain.TransactionData.Recipient require Logger @@ -31,161 +19,84 @@ defmodule Archethic.Replication.TransactionValidator do This function is called by the chain replication nodes """ - @spec validate( - tx :: Transaction.t(), - previous_transaction :: Transaction.t() | nil, - genesis_address :: Crypto.prepended_hash(), - inputs :: list(VersionedUnspentOutput.t()), - contract_context :: nil | Contract.Context.t() - ) :: :ok | {:error, Error.t()} - def validate( - tx = %Transaction{}, - previous_transaction, - genesis_address, - inputs, - contract_context - ) do - with :ok <- - valid_transaction(tx, previous_transaction, genesis_address, inputs, contract_context), - :ok <- validate_inheritance(previous_transaction, tx, contract_context, inputs) do - validate_chain(tx, previous_transaction) - end - end - - defp validate_inheritance(prev_tx, next_tx, contract_context, validation_inputs) do - contract_inputs = - case contract_context do - nil -> validation_inputs - %Contract.Context{inputs: inputs} -> inputs - end - - SmartContractValidation.validate_inherit_condition(prev_tx, next_tx, contract_inputs) + @spec validate(validation_context :: ValidationContext.t()) :: ValidationContext.t() + def validate(validation_context) do + validation_context + |> validate_consensus() + |> validate_pending_transaction() + |> cross_validate() + |> handle_cross_stamp_inconsistencies() end - defp validate_chain(tx, prev_tx) do - if TransactionChain.valid?([tx, prev_tx]), - do: :ok, - else: {:error, Error.new(:consensus_not_reached, "Invalid chain")} - end - - @doc """ - Validate transaction only (without chain integrity or unspent outputs) + defp validate_pending_transaction(context = %ValidationContext{mining_error: %Error{}}), + do: context - This function called by the replication nodes which are involved in the chain storage - """ - @spec validate(Transaction.t()) :: :ok | {:error, Error.t()} - def validate(tx = %Transaction{}) do - with :ok <- validate_consensus(tx), - :ok <- validate_validation_stamp(tx) do - :ok - else - {:error, _} = e -> - # TODO: start malicious detection - e - end - end + defp validate_pending_transaction(context), + do: ValidationContext.validate_pending_transaction(context) - defp valid_transaction(tx, prev_tx, genesis_address, inputs, contract_context) - when is_list(inputs) do - with :ok <- validate_consensus(tx), - :ok <- validate_validation_stamp(tx), - {:ok, encoded_state, contract_recipient_fees} <- - validate_smart_contract(tx, prev_tx, genesis_address, contract_context, inputs), - :ok <- validate_inputs(tx, inputs, encoded_state, contract_context), - :ok <- - validate_transaction_fee(tx, contract_recipient_fees, contract_context, encoded_state) do - :ok - else - {:error, _} = e -> - # TODO: start malicious detection - e - end - end + defp cross_validate(context = %ValidationContext{mining_error: %Error{}}), do: context + defp cross_validate(context), do: ValidationContext.cross_validate(context) - defp validate_smart_contract( - tx = %Transaction{ - data: %TransactionData{recipients: recipients}, - validation_stamp: %ValidationStamp{recipients: resolved_recipients} - }, - prev_tx, - genesis_address, - contract_context, - inputs - ) do - resolved_recipients = - recipients - |> Enum.zip(resolved_recipients) - |> Enum.map(fn {recipient, resolved_address} -> - %Recipient{recipient | address: resolved_address} - end) - - with :ok <- validate_contract_context_inputs(contract_context, inputs), - :ok <- validate_distinct_contract_recipients(tx, resolved_recipients), - {:ok, encoded_state} <- - validate_contract_execution(contract_context, prev_tx, genesis_address, tx, inputs), - {:ok, contract_recipients_fee} <- validate_contract_recipients(tx, resolved_recipients) do - {:ok, encoded_state, contract_recipients_fee} - end - end + defp handle_cross_stamp_inconsistencies(context = %ValidationContext{mining_error: %Error{}}), + do: context - defp validate_contract_context_inputs(contract_context, inputs) do - if Contract.Context.valid_inputs?(contract_context, inputs), - do: :ok, - else: {:error, Error.new(:invalid_contract_context_inputs)} - end + defp handle_cross_stamp_inconsistencies( + context = %ValidationContext{ + cross_validation_stamps: [%CrossValidationStamp{inconsistencies: []}] + } + ), + do: context - defp validate_distinct_contract_recipients( - %Transaction{data: %TransactionData{recipients: recipients}}, - resolved_recipients + defp handle_cross_stamp_inconsistencies( + context = %ValidationContext{ + cross_validation_stamps: [%CrossValidationStamp{inconsistencies: inconsistencies}] + } ) do - if length(recipients) == length(resolved_recipients) and - resolved_recipients == Enum.uniq_by(resolved_recipients, & &1.address), - do: :ok, - else: {:error, Error.new(:recipients_not_distinct)} - end + error_data = + inconsistencies |> Enum.map(&(&1 |> Atom.to_string() |> String.replace("_", " "))) - defp validate_contract_execution( - contract_context, - prev_tx, - genesis_address, - next_tx, - inputs - ) do - SmartContractValidation.validate_contract_execution( - contract_context, - prev_tx, - genesis_address, - next_tx, - inputs - ) + ValidationContext.set_mining_error(context, Error.new(:consensus_not_reached, error_data)) end - defp validate_contract_recipients( - tx = %Transaction{validation_stamp: %ValidationStamp{timestamp: validation_time}}, - resolved_recipients - ) do - SmartContractValidation.validate_contract_calls(resolved_recipients, tx, validation_time) - end + @doc """ + Validate transaction only (without chain integrity or unspent outputs) - defp validate_consensus( - tx = %Transaction{ - cross_validation_stamps: cross_stamps - } - ) do - with :ok <- validate_atomic_commitment(tx) do - validate_cross_validation_stamps_inconsistencies(cross_stamps) + This function called by the replication nodes which are involved in the io storage + """ + @spec validate_consensus(context :: ValidationContext.t()) :: ValidationContext.t() + def validate_consensus(context) do + context + |> validate_atomic_commitment() + |> validate_cross_validation_stamps_inconsistencies() + |> validate_proof_of_work() + |> validate_node_election() + |> validate_no_additional_error() + end + + defp validate_atomic_commitment(context = %ValidationContext{mining_error: %Error{}}), + do: context + + defp validate_atomic_commitment(context = %ValidationContext{transaction: tx}) do + if Transaction.atomic_commitment?(tx) do + context + else + ValidationContext.set_mining_error( + context, + Error.new(:consensus_not_reached, "Invalid atomic commitment") + ) end end - defp validate_atomic_commitment(tx) do - if Transaction.atomic_commitment?(tx), - do: :ok, - else: {:error, Error.new(:consensus_not_reached, "Invalid atomic commitment")} - end + defp validate_cross_validation_stamps_inconsistencies( + context = %ValidationContext{mining_error: %Error{}} + ), + do: context - defp validate_cross_validation_stamps_inconsistencies(stamps) do + defp validate_cross_validation_stamps_inconsistencies( + context = %ValidationContext{transaction: %Transaction{cross_validation_stamps: stamps}} + ) do if Enum.all?(stamps, &(&1.inconsistencies == [])) do - :ok + context else Logger.error("Inconsistencies: #{inspect(Enum.map(stamps, & &1.inconsistencies))}") @@ -195,46 +106,45 @@ defmodule Archethic.Replication.TransactionValidator do |> Enum.uniq() |> Enum.map(&(&1 |> Atom.to_string() |> String.replace("_", " "))) - {:error, Error.new(:consensus_not_reached, error_data)} + ValidationContext.set_mining_error(context, Error.new(:consensus_not_reached, error_data)) end end - defp validate_validation_stamp(tx = %Transaction{}) do - with :ok <- validate_proof_of_work(tx), - :ok <- validate_node_election(tx), - :ok <- validate_transaction_movements(tx) do - validate_no_additional_error(tx) - end - end + defp validate_proof_of_work(context = %ValidationContext{mining_error: %Error{}}), do: context defp validate_proof_of_work( - tx = %Transaction{validation_stamp: %ValidationStamp{proof_of_work: pow}} + context = %ValidationContext{ + transaction: tx = %Transaction{validation_stamp: %ValidationStamp{proof_of_work: pow}} + } ) do if Transaction.verify_origin_signature?(tx, pow) do - :ok + context else Logger.error("Invalid proof of work #{Base.encode16(pow)}", transaction_address: Base.encode16(tx.address), transaction_type: tx.type ) - {:error, Error.new(:consensus_not_reached, "Invalid proof of work")} + ValidationContext.set_mining_error( + context, + Error.new(:consensus_not_reached, "Invalid proof of work") + ) end end - defp validate_node_election(tx = %Transaction{}) do - if valid_election?(tx), - do: :ok, - else: {:error, Error.new(:consensus_not_reached, "Invalid election")} - end + defp validate_node_election(context = %ValidationContext{mining_error: %Error{}}), do: context - defp valid_election?( - tx = %Transaction{ - address: tx_address, - validation_stamp: %ValidationStamp{ - timestamp: tx_timestamp, - proof_of_election: proof_of_election - } + defp validate_node_election( + context = %ValidationContext{ + transaction: + tx = %Transaction{ + address: tx_address, + validation_stamp: %ValidationStamp{ + timestamp: tx_timestamp, + proof_of_election: proof_of_election + } + }, + validation_stamp: stamp } ) do authorized_nodes = P2P.authorized_and_available_nodes(tx_timestamp) @@ -244,12 +154,19 @@ defmodule Archethic.Replication.TransactionValidator do case authorized_nodes do [] -> # Should happens only during the network bootstrapping - daily_nonce_public_key == SharedSecrets.genesis_daily_nonce_public_key() + if daily_nonce_public_key == SharedSecrets.genesis_daily_nonce_public_key() do + %ValidationContext{context | coordinator_node: P2P.get_node_info()} + else + ValidationContext.set_mining_error( + context, + Error.new(:consensus_not_reached, "Invalid election") + ) + end _ -> storage_nodes = Election.chain_storage_nodes(tx_address, authorized_nodes) - validation_nodes_public_key = + validation_nodes = Election.validation_nodes( tx, proof_of_election, @@ -258,96 +175,51 @@ defmodule Archethic.Replication.TransactionValidator do Election.get_validation_constraints() ) # Update node last public key with the one at transaction date - |> Enum.map(fn %Node{first_public_key: public_key} -> - [DB.get_last_chain_public_key(public_key, tx_timestamp)] + |> Enum.map(fn node = %Node{first_public_key: public_key} -> + %Node{node | last_public_key: DB.get_last_chain_public_key(public_key, tx_timestamp)} end) - Transaction.valid_stamps_signature?(tx, validation_nodes_public_key) - end - end - - defp validate_transaction_fee( - tx = %Transaction{ - validation_stamp: %ValidationStamp{ledger_operations: %LedgerOperations{fee: fee}} - }, - contract_recipient_fees, - contract_context, - encoded_state - ) do - expected_fee = - get_transaction_fee(tx, contract_recipient_fees, contract_context, encoded_state) - - if Fee.valid_variation?(fee, expected_fee) do - :ok - else - Logger.error( - "Invalid fee: #{inspect(fee)}", - transaction_address: Base.encode16(tx.address), - transaction_type: tx.type - ) + validation_nodes_public_key = + Enum.map(validation_nodes, fn %Node{last_public_key: last_public_key} -> + [last_public_key] + end) - {:error, Error.new(:consensus_not_reached, "Invalid transaction fee")} + if Transaction.valid_stamps_signature?(tx, validation_nodes_public_key) do + coordinator_key = + validation_nodes_public_key + |> List.flatten() + |> Enum.find(&ValidationStamp.valid_signature?(stamp, &1)) + + coordinator_node = + Enum.find(validation_nodes, fn + %Node{last_public_key: ^coordinator_key} -> true + _ -> false + end) + + %ValidationContext{context | coordinator_node: coordinator_node} + else + ValidationContext.set_mining_error( + context, + Error.new(:consensus_not_reached, "Invalid election") + ) + end end end - defp get_transaction_fee( - tx = %Transaction{validation_stamp: %ValidationStamp{timestamp: timestamp}}, - contract_recipient_fees, - contract_context, - encoded_state - ) do - previous_usd_price = - timestamp - |> OracleChain.get_last_scheduling_date() - |> OracleChain.get_uco_price() - |> Keyword.fetch!(:usd) - - Mining.get_transaction_fee( - tx, - contract_context, - previous_usd_price, - timestamp, - encoded_state, - contract_recipient_fees - ) - end + defp validate_no_additional_error(context = %ValidationContext{mining_error: %Error{}}), + do: context - defp validate_transaction_movements( - tx = %Transaction{ - type: tx_type, - validation_stamp: %ValidationStamp{ - ledger_operations: - ops = %LedgerOperations{transaction_movements: transaction_movements} - } + defp validate_no_additional_error( + context = %ValidationContext{ + transaction: %Transaction{validation_stamp: %ValidationStamp{error: nil}} } - ) do - resolved_addresses = TransactionChain.resolve_transaction_addresses!(tx) - movements = Transaction.get_movements(tx) - - %LedgerOperations{transaction_movements: resolved_movements} = - %LedgerOperations{} - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx_type) - - with true <- length(resolved_movements) == length(transaction_movements), - true <- Enum.all?(resolved_movements, &(&1 in transaction_movements)) do - :ok - else - false -> - Logger.error( - "Invalid movements: #{inspect(ops.transaction_movements)}", - transaction_address: Base.encode16(tx.address), - transaction_type: tx.type - ) - - {:error, Error.new(:consensus_not_reached, "Invalid transaction movements")} - end - end - - defp validate_no_additional_error(%Transaction{validation_stamp: %ValidationStamp{error: nil}}), - do: :ok + ), + do: context defp validate_no_additional_error( - tx = %Transaction{validation_stamp: %ValidationStamp{error: error}} + context = %ValidationContext{ + transaction: tx = %Transaction{validation_stamp: %ValidationStamp{error: error}} + } ) do Logger.info( "Contains errors: #{inspect(error)}", @@ -355,101 +227,6 @@ defmodule Archethic.Replication.TransactionValidator do transaction_type: tx.type ) - {:error, Error.new(error)} - end - - defp validate_inputs( - tx = %Transaction{address: address}, - inputs, - encoded_state, - contract_context - ) do - if address == Bootstrap.genesis_address() do - :ok - else - do_validate_inputs(tx, inputs, encoded_state, contract_context) - end - end - - defp do_validate_inputs( - tx = %Transaction{ - address: address, - validation_stamp: %ValidationStamp{ - ledger_operations: %LedgerOperations{fee: fee}, - timestamp: timestamp, - protocol_version: protocol_version - } - }, - inputs, - encoded_state, - contract_context - ) do - case LedgerOperations.consume_inputs( - %LedgerOperations{fee: fee}, - address, - timestamp, - inputs, - Transaction.get_movements(tx), - LedgerOperations.get_utxos_from_transaction(tx, timestamp, protocol_version), - encoded_state, - contract_context - ) do - {:ok, ledger_operations} -> - case validate_consume_inputs(tx, ledger_operations) do - :ok -> validate_unspent_outputs(tx, ledger_operations) - err -> err - end - - {:error, :insufficient_funds} -> - {:error, Error.new(:insufficient_funds)} - end - end - - defp validate_consume_inputs( - %Transaction{ - address: address, - type: type, - validation_stamp: %ValidationStamp{ - ledger_operations: %LedgerOperations{consumed_inputs: consumed_inputs} - } - }, - %LedgerOperations{consumed_inputs: expected_consumed_inputs} - ) do - if length(consumed_inputs) == length(expected_consumed_inputs) and - Enum.all?(consumed_inputs, &(&1 in expected_consumed_inputs)) do - :ok - else - Logger.error( - "Invalid consumed inputs - got: #{inspect(consumed_inputs)}, expected: #{inspect(expected_consumed_inputs)}", - transaction_address: Base.encode16(address), - transaction_type: type - ) - - {:error, Error.new(:consensus_not_reached, "Invalid consumed inputs")} - end - end - - defp validate_unspent_outputs( - %Transaction{ - address: address, - type: type, - validation_stamp: %ValidationStamp{ - ledger_operations: %LedgerOperations{unspent_outputs: next_unspent_outputs} - } - }, - %LedgerOperations{unspent_outputs: expected_unspent_outputs} - ) do - if length(next_unspent_outputs) == length(expected_unspent_outputs) and - Enum.all?(next_unspent_outputs, &(&1 in expected_unspent_outputs)) do - :ok - else - Logger.error( - "Invalid unspent outputs - got: #{inspect(next_unspent_outputs)}, expected: #{inspect(expected_unspent_outputs)}", - transaction_address: Base.encode16(address), - transaction_type: type - ) - - {:error, Error.new(:consensus_not_reached, "Invalid unspent outputs")} - end + ValidationContext.set_mining_error(context, Error.new(error)) end end diff --git a/lib/archethic/transaction_chain.ex b/lib/archethic/transaction_chain.ex index 3a33106ef..f634f86e2 100644 --- a/lib/archethic/transaction_chain.ex +++ b/lib/archethic/transaction_chain.ex @@ -16,6 +16,8 @@ defmodule Archethic.TransactionChain do alias Archethic.Election + alias Archethic.Mining.LedgerValidation + alias Archethic.P2P alias Archethic.P2P.Message alias Archethic.P2P.Node @@ -54,7 +56,6 @@ defmodule Archethic.TransactionChain do alias __MODULE__.TransactionData alias __MODULE__.Transaction.ValidationStamp - alias __MODULE__.Transaction.ValidationStamp.LedgerOperations alias __MODULE__.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput alias __MODULE__.TransactionSummary alias __MODULE__.VersionedTransactionInput @@ -909,7 +910,7 @@ defmodule Archethic.TransactionChain do data: %TransactionData{recipients: recipients} } ) do - burning_address = LedgerOperations.burning_address() + burning_address = LedgerValidation.burning_address() recipient_addresses = Enum.map(recipients, & &1.address) @@ -1224,136 +1225,6 @@ defmodule Archethic.TransactionChain do |> Crypto.hash() end - @doc """ - Determines if a chain is valid according to : - - the proof of integrity - - the chained public keys and addresses - - the timestamping - - ## Examples - - iex> tx2 = %Transaction{ - ...> address: - ...> <<0, 0, 61, 7, 130, 64, 140, 226, 192, 8, 238, 88, 226, 106, 137, 45, 69, 113, 239, - ...> 240, 45, 55, 225, 169, 170, 121, 238, 136, 192, 161, 252, 33, 71, 3>>, - ...> type: :transfer, - ...> data: %TransactionData{}, - ...> previous_public_key: - ...> <<0, 0, 96, 233, 188, 240, 217, 251, 22, 2, 210, 59, 170, 25, 33, 61, 124, 135, 138, - ...> 65, 189, 207, 253, 84, 254, 193, 42, 130, 170, 159, 34, 72, 52, 162>>, - ...> previous_signature: - ...> <<232, 186, 237, 220, 71, 212, 177, 17, 156, 167, 145, 125, 92, 70, 213, 120, 216, - ...> 215, 255, 158, 104, 117, 162, 18, 142, 75, 73, 205, 71, 7, 141, 90, 178, 239, 212, - ...> 227, 167, 161, 155, 143, 43, 50, 6, 7, 97, 130, 134, 174, 7, 235, 183, 88, 165, - ...> 197, 25, 219, 84, 232, 135, 42, 112, 58, 181, 13>>, - ...> origin_signature: - ...> <<232, 186, 237, 220, 71, 212, 177, 17, 156, 167, 145, 125, 92, 70, 213, 120, 216, - ...> 215, 255, 158, 104, 117, 162, 18, 142, 75, 73, 205, 71, 7, 141, 90, 178, 239, 212, - ...> 227, 167, 161, 155, 143, 43, 50, 6, 7, 97, 130, 134, 174, 7, 235, 183, 88, 165, - ...> 197, 25, 219, 84, 232, 135, 42, 112, 58, 181, 13>> - ...> } - ...> - ...> tx1 = %Transaction{ - ...> address: - ...> <<0, 0, 109, 140, 2, 60, 50, 109, 201, 126, 206, 164, 10, 86, 225, 58, 136, 241, 118, - ...> 74, 3, 215, 6, 106, 165, 24, 51, 192, 212, 58, 143, 33, 68, 2>>, - ...> type: :transfer, - ...> data: %TransactionData{}, - ...> previous_public_key: - ...> <<0, 0, 221, 228, 196, 111, 16, 222, 0, 119, 32, 150, 228, 25, 206, 79, 37, 213, 8, - ...> 130, 22, 212, 99, 55, 72, 11, 248, 250, 11, 140, 137, 167, 118, 253>>, - ...> previous_signature: - ...> <<232, 186, 237, 220, 71, 212, 177, 17, 156, 167, 145, 125, 92, 70, 213, 120, 216, - ...> 215, 255, 158, 104, 117, 162, 18, 142, 75, 73, 205, 71, 7, 141, 90, 178, 239, 212, - ...> 227, 167, 161, 155, 143, 43, 50, 6, 7, 97, 130, 134, 174, 7, 235, 183, 88, 165, - ...> 197, 25, 219, 84, 232, 135, 42, 112, 58, 181, 13>>, - ...> origin_signature: - ...> <<232, 186, 237, 220, 71, 212, 177, 17, 156, 167, 145, 125, 92, 70, 213, 120, 216, - ...> 215, 255, 158, 104, 117, 162, 18, 142, 75, 73, 205, 71, 7, 141, 90, 178, 239, 212, - ...> 227, 167, 161, 155, 143, 43, 50, 6, 7, 97, 130, 134, 174, 7, 235, 183, 88, 165, - ...> 197, 25, 219, 84, 232, 135, 42, 112, 58, 181, 13>> - ...> } - ...> - ...> tx1 = %{ - ...> tx1 - ...> | validation_stamp: %ValidationStamp{ - ...> proof_of_integrity: TransactionChain.proof_of_integrity([tx1]), - ...> timestamp: ~U[2022-09-10 10:00:00Z] - ...> } - ...> } - ...> - ...> tx2 = %{ - ...> tx2 - ...> | validation_stamp: %ValidationStamp{ - ...> proof_of_integrity: TransactionChain.proof_of_integrity([tx2, tx1]), - ...> timestamp: ~U[2022-12-10 10:00:00Z] - ...> } - ...> } - ...> - ...> TransactionChain.valid?([tx2, tx1]) - true - - """ - @spec valid?([Transaction.t(), ...]) :: boolean - def valid?([ - tx = %Transaction{validation_stamp: %ValidationStamp{proof_of_integrity: poi}}, - nil - ]) do - if poi == proof_of_integrity([tx]) do - true - else - Logger.error("Invalid proof of integrity", - transaction_address: Base.encode16(tx.address), - transaction_type: tx.type - ) - - false - end - end - - def valid?([ - last_tx = %Transaction{ - previous_public_key: previous_public_key, - validation_stamp: %ValidationStamp{timestamp: timestamp, proof_of_integrity: poi} - }, - prev_tx = %Transaction{ - address: previous_address, - validation_stamp: %ValidationStamp{ - timestamp: previous_timestamp - } - } - | _ - ]) do - cond do - proof_of_integrity([Transaction.to_pending(last_tx), prev_tx]) != poi -> - Logger.error("Invalid proof of integrity", - transaction_address: Base.encode16(last_tx.address), - transaction_type: last_tx.type - ) - - false - - Crypto.derive_address(previous_public_key) != previous_address -> - Logger.error("Invalid previous public key", - transaction_type: last_tx.type, - transaction_address: Base.encode16(last_tx.address) - ) - - false - - DateTime.diff(timestamp, previous_timestamp) < 0 -> - Logger.error("Invalid timestamp", - transaction_type: last_tx.type, - transaction_address: Base.encode16(last_tx.address) - ) - - false - - true -> - true - end - end - @doc """ Load the transaction into the TransactionChain context filling the memory tables """ diff --git a/lib/archethic/transaction_chain/transaction/cross_validation_stamp.ex b/lib/archethic/transaction_chain/transaction/cross_validation_stamp.ex index 8d979cd52..370a854a0 100644 --- a/lib/archethic/transaction_chain/transaction/cross_validation_stamp.ex +++ b/lib/archethic/transaction_chain/transaction/cross_validation_stamp.ex @@ -17,6 +17,7 @@ defmodule Archethic.TransactionChain.Transaction.CrossValidationStamp do | :proof_of_election | :transaction_fee | :transaction_movements + | :recipients | :unspent_outputs | :error | :protocol_version @@ -130,6 +131,7 @@ defmodule Archethic.TransactionChain.Transaction.CrossValidationStamp do defp serialize_inconsistency(:protocol_version), do: 9 defp serialize_inconsistency(:consumed_inputs), do: 10 defp serialize_inconsistency(:aggregated_utxos), do: 11 + defp serialize_inconsistency(:recipients), do: 12 @doc """ Deserialize an encoded cross validation stamp @@ -198,6 +200,7 @@ defmodule Archethic.TransactionChain.Transaction.CrossValidationStamp do defp do_reduce_inconsistencies(<<9::8, rest::bitstring>>), do: {:protocol_version, rest} defp do_reduce_inconsistencies(<<10::8, rest::bitstring>>), do: {:consumed_inputs, rest} defp do_reduce_inconsistencies(<<11::8, rest::bitstring>>), do: {:aggregated_utxos, rest} + defp do_reduce_inconsistencies(<<12::8, rest::bitstring>>), do: {:recipients, rest} @spec cast(map()) :: t() def cast(stamp = %{}) do diff --git a/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations.ex b/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations.ex index 42e23f453..ce826ce98 100644 --- a/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations.ex +++ b/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations.ex @@ -3,28 +3,13 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation Represents the ledger operations defined during the transaction mining regarding the network movements """ - @unit_uco 100_000_000 - - defstruct transaction_movements: [], - unspent_outputs: [], - fee: 0, - consumed_inputs: [] - - alias Archethic.Contracts.Contract.Context, as: ContractContext - alias Archethic.Contracts.Contract.State - - alias Archethic.Crypto - - alias Archethic.TransactionChain.Transaction + defstruct transaction_movements: [], unspent_outputs: [], fee: 0, consumed_inputs: [] alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.TransactionMovement - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.UnspentOutput alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput - alias Archethic.TransactionChain.TransactionData - alias Archethic.Utils.VarInt @typedoc """ @@ -40,426 +25,6 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation consumed_inputs: list(VersionedUnspentOutput.t()) } - @burning_address <<0::8, 0::8, 0::256>> - - @doc """ - Return the address used for the burning - """ - @spec burning_address() :: Crypto.versioned_hash() - def burning_address, do: @burning_address - - @doc ~S""" - Build some ledger operations from a specific transaction - """ - @spec get_utxos_from_transaction( - tx :: Transaction.t(), - validation_time :: DateTime.t(), - protocol_version :: non_neg_integer() - ) :: list(VersionedUnspentOutput.t()) - def get_utxos_from_transaction( - %Transaction{ - address: address, - type: type, - data: %TransactionData{content: content} - }, - timestamp, - protocol_version - ) - when type in [:token, :mint_rewards] and not is_nil(timestamp) do - case Jason.decode(content) do - {:ok, json} -> - json - |> get_token_utxos(address, timestamp) - |> VersionedUnspentOutput.wrap_unspent_outputs(protocol_version) - - _ -> - [] - end - end - - def get_utxos_from_transaction(%Transaction{}, _timestamp, _), do: [] - - defp get_token_utxos( - %{"token_reference" => token_ref, "supply" => supply}, - address, - timestamp - ) - when is_binary(token_ref) and is_integer(supply) do - case Base.decode16(token_ref, case: :mixed) do - {:ok, token_address} -> - [ - %UnspentOutput{ - from: address, - amount: supply, - type: {:token, token_address, 0}, - timestamp: timestamp - } - ] - - _ -> - [] - end - end - - defp get_token_utxos( - %{"type" => "fungible", "supply" => supply}, - address, - timestamp - ) do - [ - %UnspentOutput{ - from: address, - amount: supply, - type: {:token, address, 0}, - timestamp: timestamp - } - ] - end - - defp get_token_utxos( - %{ - "type" => "non-fungible", - "supply" => supply, - "collection" => collection - }, - address, - timestamp - ) do - if length(collection) == supply / @unit_uco do - collection - |> Enum.with_index() - |> Enum.map(fn {item_properties, index} -> - token_id = Map.get(item_properties, "id", index + 1) - - %UnspentOutput{ - from: address, - amount: 1 * @unit_uco, - type: {:token, address, token_id}, - timestamp: timestamp - } - end) - else - [] - end - end - - defp get_token_utxos( - %{"type" => "non-fungible", "supply" => @unit_uco}, - address, - timestamp - ) do - [ - %UnspentOutput{ - from: address, - amount: 1 * @unit_uco, - type: {:token, address, 1}, - timestamp: timestamp - } - ] - end - - defp get_token_utxos(_, _, _), do: [] - - defp total_to_spend(fee, movements) do - Enum.reduce(movements, %{uco: fee, token: %{}}, fn - %TransactionMovement{type: :UCO, amount: amount}, acc -> - Map.update!(acc, :uco, &(&1 + amount)) - - %TransactionMovement{type: {:token, token_address, token_id}, amount: amount}, acc -> - update_in(acc, [:token, Access.key({token_address, token_id}, 0)], &(&1 + amount)) - - _, acc -> - acc - end) - end - - defp ledger_balances(utxos) do - Enum.reduce(utxos, %{uco: 0, token: %{}}, fn - %VersionedUnspentOutput{unspent_output: %UnspentOutput{type: :UCO, amount: amount}}, acc -> - Map.update!(acc, :uco, &(&1 + amount)) - - %VersionedUnspentOutput{ - unspent_output: %UnspentOutput{type: {:token, token_address, token_id}, amount: amount} - }, - acc -> - update_in(acc, [:token, Access.key({token_address, token_id}, 0)], &(&1 + amount)) - - _, acc -> - acc - end) - end - - defp sufficient_funds?(uco_balance, uco_to_spend, tokens_balance, tokens_to_spend) do - uco_balance >= uco_to_spend and sufficient_tokens?(tokens_balance, tokens_to_spend) - end - - defp sufficient_tokens?(tokens_received = %{}, token_to_spend = %{}) - when map_size(tokens_received) == 0 and map_size(token_to_spend) > 0, - do: false - - defp sufficient_tokens?(_tokens_received, tokens_to_spend) when map_size(tokens_to_spend) == 0, - do: true - - defp sufficient_tokens?(tokens_received, tokens_to_spend) do - Enum.all?(tokens_to_spend, fn {token_key, amount_to_spend} -> - case Map.get(tokens_received, token_key) do - nil -> - false - - recv_amount -> - recv_amount >= amount_to_spend - end - end) - end - - @doc """ - Use the necessary inputs to satisfy the uco amount to spend - The remaining unspent outputs will go to the change address - Also return a boolean indicating if there was sufficient funds - """ - @spec consume_inputs( - ledger_operations :: t(), - change_address :: binary(), - timestamp :: DateTime.t(), - inputs :: list(VersionedUnspentOutput.t()), - movements :: list(TransactionMovement.t()), - token_to_mint :: list(VersionedUnspentOutput.t()), - encoded_state :: State.encoded() | nil, - contract_context :: ContractContext.t() | nil - ) :: {:ok, ledger_operations :: t()} | {:error, :insufficient_funds} - def consume_inputs( - ops = %__MODULE__{fee: fee}, - change_address, - timestamp = %DateTime{}, - chain_inputs \\ [], - movements \\ [], - tokens_to_mint \\ [], - encoded_state \\ nil, - contract_context \\ nil - ) do - ledger_inputs = - case contract_context do - nil -> - chain_inputs - - context = %ContractContext{} -> - ContractContext.ledger_inputs(context, chain_inputs) - end - - # Since AEIP-19 we can consume from minted tokens - # Sort inputs, to have consistent results across all nodes - consolidated_inputs = - Enum.sort(tokens_to_mint ++ ledger_inputs, {:asc, VersionedUnspentOutput}) - |> Enum.map(fn - utxo = %VersionedUnspentOutput{unspent_output: %UnspentOutput{from: ^change_address}} -> - # As the minted tokens are used internally during transaction's validation - # and doesn't not exists outside, we use the burning address - # to identify inputs coming from the token's minting. - put_in(utxo, [Access.key!(:unspent_output), Access.key!(:from)], burning_address()) - - utxo -> - utxo - end) - - %{uco: uco_balance, token: tokens_balance} = ledger_balances(consolidated_inputs) - %{uco: uco_to_spend, token: tokens_to_spend} = total_to_spend(fee, movements) - - if sufficient_funds?(uco_balance, uco_to_spend, tokens_balance, tokens_to_spend) do - versioned_consumed_utxos = - get_inputs_to_consume( - consolidated_inputs, - uco_to_spend, - tokens_to_spend, - uco_balance, - tokens_balance, - contract_context - ) - - consumed_utxos = VersionedUnspentOutput.unwrap_unspent_outputs(versioned_consumed_utxos) - tokens_to_mint = VersionedUnspentOutput.unwrap_unspent_outputs(tokens_to_mint) - - new_unspent_outputs = - tokens_utxos( - tokens_to_spend, - consumed_utxos, - tokens_to_mint, - change_address, - timestamp - ) - |> add_uco_utxo(consumed_utxos, uco_to_spend, change_address, timestamp) - |> Enum.filter(&(&1.amount > 0)) - |> add_state_utxo(encoded_state, change_address, timestamp) - - {:ok, - ops - |> Map.put(:unspent_outputs, new_unspent_outputs) - |> Map.put(:consumed_inputs, versioned_consumed_utxos)} - else - {:error, :insufficient_funds} - end - end - - defp tokens_utxos( - tokens_to_spend, - consumed_utxos, - tokens_to_mint, - change_address, - timestamp - ) do - tokens_minted_not_consumed = - Enum.reject(tokens_to_mint, fn %UnspentOutput{type: {:token, token_address, token_id}} -> - Map.has_key?(tokens_to_spend, {token_address, token_id}) - end) - - consumed_utxos - |> Enum.group_by(& &1.type) - |> Enum.filter(&match?({{:token, _, _}, _}, &1)) - |> Enum.reduce([], fn {type = {:token, token_address, token_id}, utxos}, acc -> - amount_to_spend = Map.get(tokens_to_spend, {token_address, token_id}, 0) - consumed_amount = utxos |> Enum.map(& &1.amount) |> Enum.sum() - remaining_amount = consumed_amount - amount_to_spend - - if remaining_amount > 0 do - new_utxo = %UnspentOutput{ - from: change_address, - amount: remaining_amount, - type: type, - timestamp: timestamp - } - - [new_utxo | acc] - else - acc - end - end) - |> Enum.concat(tokens_minted_not_consumed) - end - - defp get_inputs_to_consume( - inputs, - uco_to_spend, - tokens_to_spend, - uco_balance, - tokens_balance, - contract_context - ) do - inputs - # We group by type to count them and determine if we need to consume the inputs - |> Enum.group_by(& &1.unspent_output.type) - |> Enum.flat_map(fn - {:UCO, inputs} -> - get_uco_to_consume(inputs, uco_to_spend, uco_balance) - - {{:token, token_address, token_id}, inputs} -> - key = {token_address, token_id} - get_token_to_consume(inputs, key, tokens_to_spend, tokens_balance) - - {:state, state_utxos} -> - state_utxos - - {:call, inputs} -> - get_call_to_consume(inputs, contract_context) - end) - end - - defp get_uco_to_consume(inputs, uco_to_spend, uco_balance) when uco_to_spend > 0, - do: optimize_inputs_to_consume(inputs, uco_to_spend, uco_balance) - - # The consolidation happens when there are at least more than one UTXO of the same type - # This reduces the storage size on both genesis's inputs and further transactions - defp get_uco_to_consume(inputs, _, _) when length(inputs) > 1, do: inputs - defp get_uco_to_consume(_, _, _), do: [] - - defp get_token_to_consume(inputs, key, tokens_to_spend, tokens_balance) - when is_map_key(tokens_to_spend, key) do - amount_to_spend = Map.get(tokens_to_spend, key) - token_balance = Map.get(tokens_balance, key) - optimize_inputs_to_consume(inputs, amount_to_spend, token_balance) - end - - defp get_token_to_consume(inputs, _, _, _) when length(inputs) > 1, do: inputs - defp get_token_to_consume(_, _, _, _), do: [] - - defp get_call_to_consume(inputs, %ContractContext{trigger: {:transaction, address, _}}) do - inputs - |> Enum.find(&(&1.unspent_output.from == address)) - |> then(fn - nil -> [] - contract_call_input -> [contract_call_input] - end) - end - - defp get_call_to_consume(_, _), do: [] - - defp optimize_inputs_to_consume(inputs, _, _) when length(inputs) == 1, do: inputs - - defp optimize_inputs_to_consume(inputs, amount_to_spend, balance_amount) - when balance_amount == amount_to_spend, - do: inputs - - defp optimize_inputs_to_consume(inputs, amount_to_spend, balance_amount) do - # Search if we can consume all inputs except one. This will avoid doing consolidation - remaining_amount = balance_amount - amount_to_spend - - case Enum.find(inputs, &(&1.unspent_output.amount == remaining_amount)) do - nil -> inputs - input -> Enum.reject(inputs, &(&1 == input)) - end - end - - defp add_uco_utxo(utxos, consumed_utxos, uco_to_spend, change_address, timestamp) do - consumed_amount = - consumed_utxos |> Enum.filter(&(&1.type == :UCO)) |> Enum.map(& &1.amount) |> Enum.sum() - - remaining_amount = consumed_amount - uco_to_spend - - if remaining_amount > 0 do - new_utxo = %UnspentOutput{ - from: change_address, - amount: remaining_amount, - type: :UCO, - timestamp: timestamp - } - - [new_utxo | utxos] - else - utxos - end - end - - defp add_state_utxo(utxos, nil, _change_address, _timestamp), do: utxos - - defp add_state_utxo(utxos, encoded_state, change_address, timestamp) do - new_utxo = %UnspentOutput{ - type: :state, - encoded_payload: encoded_state, - timestamp: timestamp, - from: change_address - } - - [new_utxo | utxos] - end - - @doc """ - Build the resolved view of the movement, with the resolved address - and convert MUCO movement to UCO movement - """ - @spec build_resolved_movements( - ops :: t(), - movements :: list(TransactionMovement.t()), - resolved_addresses :: %{Crypto.prepended_hash() => Crypto.prepended_hash()}, - tx_type :: Transaction.transaction_type() - ) :: t() - def build_resolved_movements(ops, movements, resolved_addresses, tx_type) do - resolved_movements = - movements - |> TransactionMovement.resolve_addresses(resolved_addresses) - |> Enum.map(&TransactionMovement.maybe_convert_reward(&1, tx_type)) - |> TransactionMovement.aggregate() - - %__MODULE__{ops | transaction_movements: resolved_movements} - end - @doc """ List all the addresses from transaction movements """ diff --git a/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations/transaction_movement.ex b/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations/transaction_movement.ex index 1584c3827..8ea8dca6b 100644 --- a/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations/transaction_movement.ex +++ b/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations/transaction_movement.ex @@ -11,7 +11,6 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation alias Archethic.TransactionChain.Transaction alias Archethic.Utils alias Archethic.Reward - alias Archethic.TransactionChain.Transaction @typedoc """ TransactionMovement is composed from: diff --git a/lib/archethic/utils/regression/playbooks/smart_contract/dex.ex b/lib/archethic/utils/regression/playbooks/smart_contract/dex.ex index d941f5249..a6b09d005 100644 --- a/lib/archethic/utils/regression/playbooks/smart_contract/dex.ex +++ b/lib/archethic/utils/regression/playbooks/smart_contract/dex.ex @@ -5,7 +5,7 @@ defmodule Archethic.Utils.Regression.Playbook.SmartContract.Dex do """ alias Archethic.Crypto - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations + alias Archethic.Mining.LedgerValidation alias Archethic.TransactionChain.TransactionData alias Archethic.TransactionChain.TransactionData.Ledger alias Archethic.TransactionChain.TransactionData.Recipient @@ -98,7 +98,7 @@ defmodule Archethic.Utils.Regression.Playbook.SmartContract.Dex do nb_add_liquidity = nb_triggers - nb_remove_liquidity add_liquidity_opts_list = - Enum.map(1..nb_remove_liquidity, fn _ -> add_liquidity_opts(seeds, 10, 10) end) + Enum.map(1..nb_add_liquidity, fn _ -> add_liquidity_opts(seeds, 10, 10) end) seeds |> Map.update!(:triggers_seeds, &Enum.take(&1, nb_remove_liquidity)) @@ -332,7 +332,7 @@ defmodule Archethic.Utils.Regression.Playbook.SmartContract.Dex do token: %TokenLedger{ transfers: [ %TokenTransfer{ - to: LedgerOperations.burning_address(), + to: LedgerValidation.burning_address(), amount: lp_token_amount * @unit_uco, token_address: lp_token_address, token_id: 0 @@ -798,7 +798,7 @@ defmodule Archethic.Utils.Regression.Playbook.SmartContract.Dex do name: "aeSwap LP Token", allow_mint: true, properties: %{token1_address: token1_address, token2_address: token2_address}, - recipients: [%{to: LedgerOperations.burning_address(), amount: 10}] + recipients: [%{to: LedgerValidation.burning_address(), amount: 10}] } |> Jason.encode!() end diff --git a/lib/archethic_web/explorer/views/explorer_view.ex b/lib/archethic_web/explorer/views/explorer_view.ex index a67a5b6fb..6d7df2fab 100644 --- a/lib/archethic_web/explorer/views/explorer_view.ex +++ b/lib/archethic_web/explorer/views/explorer_view.ex @@ -9,13 +9,14 @@ defmodule ArchethicWeb.Explorer.ExplorerView do alias Archethic.BeaconChain.Slot.EndOfNodeSync alias Archethic.BeaconChain.Summary + alias Archethic.Mining.LedgerValidation + alias Archethic.SharedSecrets alias Archethic.SharedSecrets.NodeRenewal alias Archethic.P2P.Node alias Archethic.TransactionChain.TransactionSummary - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations alias Archethic.Utils @@ -252,5 +253,5 @@ defmodule ArchethicWeb.Explorer.ExplorerView do {family, key, key_certificate} end - def burning_address, do: LedgerOperations.burning_address() + def burning_address, do: LedgerValidation.burning_address() end diff --git a/test/archethic/mining/distributed_workflow_test.exs b/test/archethic/mining/distributed_workflow_test.exs index 62b81ffa7..bf2334b53 100644 --- a/test/archethic/mining/distributed_workflow_test.exs +++ b/test/archethic/mining/distributed_workflow_test.exs @@ -18,6 +18,7 @@ defmodule Archethic.Mining.DistributedWorkflowTest do alias Archethic.Mining.DistributedWorkflow, as: Workflow alias Archethic.Mining.Error alias Archethic.Mining.Fee + alias Archethic.Mining.LedgerValidation alias Archethic.Mining.ValidationContext alias Archethic.P2P @@ -46,7 +47,6 @@ defmodule Archethic.Mining.DistributedWorkflowTest do alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.Transaction.CrossValidationStamp alias Archethic.TransactionChain.Transaction.ValidationStamp - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.UnspentOutput alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput @@ -1058,16 +1058,13 @@ defmodule Archethic.Mining.DistributedWorkflowTest do end test "should not replicate if there is a validation error", %{tx: tx} do - validation_context = create_context(tx) - error = Error.new(:invalid_pending_transaction, "Transactiion already exists") + + validation_context = %ValidationContext{create_context(tx) | mining_error: error} validation_stamp = create_validation_stamp(validation_context) validation_stamp = %ValidationStamp{validation_stamp | error: Error.to_stamp_error(error)} - context = - validation_context - |> ValidationContext.add_validation_stamp(validation_stamp) - |> ValidationContext.set_mining_error(error) + context = validation_context |> ValidationContext.add_validation_stamp(validation_stamp) me = self() @@ -1347,22 +1344,22 @@ defmodule Archethic.Mining.DistributedWorkflowTest do unspent_outputs: unspent_outputs, validation_time: timestamp }) do - fee = Fee.calculate(tx, nil, 0.07, timestamp, nil, 0, current_protocol_version()) + protocol_version = current_protocol_version() + fee = Fee.calculate(tx, nil, 0.07, timestamp, nil, 0, protocol_version) + contract_context = nil + encoded_state = nil movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() ledger_operations = - %LedgerOperations{fee: fee} - |> LedgerOperations.consume_inputs( - tx.address, - timestamp, - unspent_outputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, current_protocol_version()) - ) - |> elem(1) - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() %ValidationStamp{ timestamp: timestamp, diff --git a/test/archethic/mining/ledger_validation_test.exs b/test/archethic/mining/ledger_validation_test.exs new file mode 100644 index 000000000..3250c1ad7 --- /dev/null +++ b/test/archethic/mining/ledger_validation_test.exs @@ -0,0 +1,1502 @@ +defmodule Archethic.Mining.LedgerValidationTest do + alias Archethic.Mining.LedgerValidation + + alias Archethic.Reward.MemTables.RewardTokens + + alias Archethic.TransactionFactory + + alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.TransactionMovement + + alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.UnspentOutput + + alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput + + use ArchethicCase + import ArchethicCase + + doctest LedgerValidation + + setup do + start_supervised!(RewardTokens) + :ok + end + + describe "mint_token_utxos/4" do + test "should return empty list for non token/mint_reward transaction" do + types = Archethic.TransactionChain.Transaction.types() -- [:node, :mint_reward] + + Enum.each(types, fn t -> + assert %LedgerValidation{minted_utxos: []} = + LedgerValidation.mint_token_utxos( + %LedgerValidation{}, + TransactionFactory.create_valid_transaction([], type: t), + DateTime.utc_now(), + current_protocol_version() + ) + end) + end + + test "should return empty list if content is invalid" do + assert %LedgerValidation{minted_utxos: []} = + LedgerValidation.mint_token_utxos( + %LedgerValidation{}, + TransactionFactory.create_valid_transaction([], + type: :token, + content: "not a json" + ), + DateTime.utc_now(), + current_protocol_version() + ) + + assert %LedgerValidation{minted_utxos: []} = + LedgerValidation.mint_token_utxos( + %LedgerValidation{}, + TransactionFactory.create_valid_transaction([], type: :token, content: "{}"), + DateTime.utc_now(), + current_protocol_version() + ) + end + end + + describe "mint_token_utxos/4 with a token resupply transaction" do + test "should return a utxo" do + token_address = random_address() + token_address_hex = token_address |> Base.encode16() + now = DateTime.utc_now() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": "#{token_address_hex}", + "supply": 1000000 + } + """ + ) + + tx_address = tx.address + + assert [ + %UnspentOutput{ + amount: 1_000_000, + from: ^tx_address, + type: {:token, ^token_address, 0}, + timestamp: ^now + } + ] = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + |> Map.fetch!(:minted_utxos) + |> VersionedUnspentOutput.unwrap_unspent_outputs() + end + + test "should return an empty list if invalid tx" do + now = DateTime.utc_now() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": "nonhexadecimal", + "supply": 1000000 + } + """ + ) + + assert %LedgerValidation{minted_utxos: []} = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": {"foo": "bar"}, + "supply": 1000000 + } + """ + ) + + assert %LedgerValidation{minted_utxos: []} = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + + token_address = random_address() + token_address_hex = token_address |> Base.encode16() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": "#{token_address_hex}", + "supply": "hello" + } + """ + ) + + assert %LedgerValidation{minted_utxos: []} = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + end + end + + describe "mint_token_utxos/4 with a token creation transaction" do + test "should return a utxo (for fungible)" do + now = DateTime.utc_now() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 1000000000, + "type": "fungible", + "decimals": 8, + "name": "NAME OF MY TOKEN", + "symbol": "MTK" + } + """ + ) + + tx_address = tx.address + + assert [ + %UnspentOutput{ + amount: 1_000_000_000, + from: ^tx_address, + type: {:token, ^tx_address, 0}, + timestamp: ^now + } + ] = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + |> Map.fetch!(:minted_utxos) + |> VersionedUnspentOutput.unwrap_unspent_outputs() + end + + test "should return a utxo (for non-fungible)" do + now = DateTime.utc_now() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 100000000, + "type": "non-fungible", + "name": "My NFT", + "symbol": "MNFT", + "properties": { + "image": "base64 of the image", + "description": "This is a NFT with an image" + } + } + """ + ) + + tx_address = tx.address + + protocol_version = current_protocol_version() + + assert %LedgerValidation{ + minted_utxos: [ + %VersionedUnspentOutput{ + unspent_output: %UnspentOutput{ + amount: 100_000_000, + from: ^tx_address, + type: {:token, ^tx_address, 1}, + timestamp: ^now + }, + protocol_version: ^protocol_version + } + ] + } = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + end + + test "should return a utxo (for non-fungible collection)" do + now = DateTime.utc_now() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 300000000, + "name": "My NFT", + "type": "non-fungible", + "symbol": "MNFT", + "properties": { + "description": "this property is for all NFT" + }, + "collection": [ + { "image": "link of the 1st NFT image" }, + { "image": "link of the 2nd NFT image" }, + { + "image": "link of the 3rd NFT image", + "other_property": "other value" + } + ] + } + """ + ) + + tx_address = tx.address + + expected_utxos = + [ + %UnspentOutput{ + amount: 100_000_000, + from: tx_address, + type: {:token, tx_address, 1}, + timestamp: now + }, + %UnspentOutput{ + amount: 100_000_000, + from: tx_address, + type: {:token, tx_address, 2}, + timestamp: now + }, + %UnspentOutput{ + amount: 100_000_000, + from: tx_address, + type: {:token, tx_address, 3}, + timestamp: now + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + assert %LedgerValidation{minted_utxos: ^expected_utxos} = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + end + + test "should return an empty list if amount is incorrect (for non-fungible)" do + now = DateTime.utc_now() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 1, + "type": "non-fungible", + "name": "My NFT", + "symbol": "MNFT", + "properties": { + "image": "base64 of the image", + "description": "This is a NFT with an image" + } + } + """ + ) + + assert %LedgerValidation{minted_utxos: []} = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + end + + test "should return an empty list if invalid tx" do + now = DateTime.utc_now() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": "foo" + } + """ + ) + + assert %LedgerValidation{minted_utxos: []} = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 100000000 + } + """ + ) + + assert %LedgerValidation{minted_utxos: []} = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "type": "fungible" + } + """ + ) + + assert %LedgerValidation{minted_utxos: []} = + %LedgerValidation{} + |> LedgerValidation.mint_token_utxos(tx, now, current_protocol_version()) + end + end + + describe "validate_sufficient_funds/1" do + test "should return insufficient funds when not enough uco" do + assert %LedgerValidation{sufficient_funds?: false} = + %LedgerValidation{fee: 1_000} |> LedgerValidation.validate_sufficient_funds() + end + + test "should return insufficient funds when not enough tokens" do + inputs = [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + movements = [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@CharlieToken", 0} + } + ] + + assert %LedgerValidation{sufficient_funds?: false} = + %LedgerValidation{fee: 1_000, transaction_movements: movements, inputs: inputs} + |> LedgerValidation.validate_sufficient_funds() + end + + test "should not be able to pay with the same non-fungible token twice" do + inputs = [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + movements = [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@Token", 1} + }, + %TransactionMovement{ + to: "@JeanBob", + amount: 100_000_000, + type: {:token, "@Token", 1} + } + ] + + minted_utxos = [ + %UnspentOutput{ + from: "@Alice", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + assert %LedgerValidation{sufficient_funds?: false} = + %LedgerValidation{ + fee: 1_000, + transaction_movements: movements, + inputs: inputs, + minted_utxos: minted_utxos + } + |> LedgerValidation.validate_sufficient_funds() + end + + test "should return available balance and amount to spend and return sufficient_funds to true" do + inputs = + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 10_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@Alice", + amount: 100_000_000, + type: {:token, "@Token1", 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@Bob", + amount: 100_100_000, + type: {:token, "@Token1", 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + movements = [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@Token", 1} + }, + %TransactionMovement{ + to: "@Michel", + amount: 120_000_000, + type: {:token, "@Token1", 0} + }, + %TransactionMovement{ + to: "@Toto", + amount: 456, + type: :UCO + } + ] + + minted_utxos = [ + %UnspentOutput{ + from: "@Alice", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + expected_balance = %{ + uco: 10_000, + token: %{{"@Token1", 0} => 200_100_000, {"@Token", 1} => 100_000_000} + } + + expected_amount_to_spend = %{ + uco: 1456, + token: %{{"@Token1", 0} => 120_000_000, {"@Token", 1} => 100_000_000} + } + + assert %LedgerValidation{ + sufficient_funds?: true, + balances: ^expected_balance, + amount_to_spend: ^expected_amount_to_spend + } = + %LedgerValidation{ + fee: 1_000, + transaction_movements: movements, + inputs: inputs, + minted_utxos: minted_utxos + } + |> LedgerValidation.validate_sufficient_funds() + end + end + + describe "consume_inputs/4" do + test "When a single unspent output is sufficient to satisfy the transaction movements" do + timestamp = ~U[2022-10-10 10:44:38.983Z] + tx_address = "@Alice2" + + inputs = [ + %UnspentOutput{ + from: "@Bob3", + amount: 2_000_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + movements = [ + %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, + %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} + ] + + assert %LedgerValidation{ + fee: 40_000_000, + unspent_outputs: [ + %UnspentOutput{ + from: "@Alice2", + amount: 703_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ], + consumed_inputs: [ + %VersionedUnspentOutput{ + unspent_output: %UnspentOutput{ + from: "@Bob3", + amount: 2_000_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + } + ] + } = + %LedgerValidation{ + fee: 40_000_000, + transaction_movements: movements, + inputs: inputs + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, timestamp) + end + + test "When multiple little unspent output are sufficient to satisfy the transaction movements" do + tx_address = "@Alice2" + timestamp = ~U[2022-10-10 10:44:38.983Z] + + inputs = + [ + %UnspentOutput{ + from: "@Bob3", + amount: 500_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Tom4", + amount: 700_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Christina", + amount: 400_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Hugo", + amount: 800_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + movements = [ + %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, + %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} + ] + + expected_consumed_inputs = + [ + %UnspentOutput{ + from: "@Bob3", + amount: 500_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Christina", + amount: 400_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Hugo", + amount: 800_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Tom4", + amount: 700_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + assert %LedgerValidation{ + fee: 40_000_000, + unspent_outputs: [ + %UnspentOutput{ + from: "@Alice2", + amount: 1_103_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ], + consumed_inputs: ^expected_consumed_inputs + } = + %LedgerValidation{ + fee: 40_000_000, + transaction_movements: movements, + inputs: inputs + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, timestamp) + end + + test "When using Token unspent outputs are sufficient to satisfy the transaction movements" do + tx_address = "@Alice2" + timestamp = ~U[2022-10-10 10:44:38.983Z] + + inputs = + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 200_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@Bob3", + amount: 1_200_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + movements = [ + %TransactionMovement{ + to: "@Bob4", + amount: 1_000_000_000, + type: {:token, "@CharlieToken", 0} + } + ] + + expected_consumed_inputs = + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 200_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@Bob3", + amount: 1_200_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + assert %LedgerValidation{ + fee: 40_000_000, + unspent_outputs: [ + %UnspentOutput{ + from: "@Alice2", + amount: 160_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Alice2", + amount: 200_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ], + consumed_inputs: ^expected_consumed_inputs + } = + %LedgerValidation{ + fee: 40_000_000, + transaction_movements: movements, + inputs: inputs + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, timestamp) + end + + test "When multiple Token unspent outputs are sufficient to satisfy the transaction movements" do + tx_address = "@Alice2" + timestamp = ~U[2022-10-10 10:44:38.983Z] + + inputs = + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 200_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Bob3", + amount: 500_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Hugo5", + amount: 700_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Tom1", + amount: 700_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + movements = [ + %TransactionMovement{ + to: "@Bob4", + amount: 1_000_000_000, + type: {:token, "@CharlieToken", 0} + } + ] + + expected_consumed_inputs = + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 200_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Bob3", + amount: 500_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Hugo5", + amount: 700_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + amount: 700_000_000, + from: "@Tom1", + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + assert %LedgerValidation{ + fee: 40_000_000, + unspent_outputs: [ + %UnspentOutput{ + from: "@Alice2", + amount: 160_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %UnspentOutput{ + from: "@Alice2", + amount: 900_000_000, + type: {:token, "@CharlieToken", 0}, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ], + consumed_inputs: ^expected_consumed_inputs + } = + %LedgerValidation{ + fee: 40_000_000, + transaction_movements: movements, + inputs: inputs + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, timestamp) + end + + test "When non-fungible tokens are used as input but want to consume only a single input" do + tx_address = "@Alice2" + timestamp = ~U[2022-10-10 10:44:38.983Z] + + inputs = + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 200_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@CharlieToken", + amount: 100_000_000, + type: {:token, "@CharlieToken", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@CharlieToken", + amount: 100_000_000, + type: {:token, "@CharlieToken", 2}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@CharlieToken", + amount: 100_000_000, + type: {:token, "@CharlieToken", 3}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + movements = [ + %TransactionMovement{ + to: "@Bob4", + amount: 100_000_000, + type: {:token, "@CharlieToken", 2} + } + ] + + expected_consumed_inputs = + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 200_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@CharlieToken", + amount: 100_000_000, + type: {:token, "@CharlieToken", 2}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + assert %LedgerValidation{ + fee: 40_000_000, + unspent_outputs: [ + %UnspentOutput{ + from: "@Alice2", + amount: 160_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ], + consumed_inputs: ^expected_consumed_inputs + } = + %LedgerValidation{ + fee: 40_000_000, + transaction_movements: movements, + inputs: inputs + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, timestamp) + end + + test "should be able to pay with the minted fungible tokens" do + tx_address = "@Alice" + now = DateTime.utc_now() + + inputs = [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + movements = [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 50_000_000, + type: {:token, "@Token", 0} + } + ] + + minted_utxos = [ + %UnspentOutput{ + from: "@Alice", + amount: 100_000_000, + type: {:token, "@Token", 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + assert ops_result = + %LedgerValidation{ + fee: 1_000, + transaction_movements: movements, + inputs: inputs, + minted_utxos: minted_utxos + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, now) + + assert [ + %UnspentOutput{ + from: "@Alice", + amount: 50_000_000, + type: {:token, "@Token", 0}, + timestamp: ^now + } + ] = ops_result.unspent_outputs + + burn_address = LedgerValidation.burning_address() + + assert [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: ^burn_address, + amount: 100_000_000, + type: {:token, "@Token", 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] = ops_result.consumed_inputs |> VersionedUnspentOutput.unwrap_unspent_outputs() + end + + test "should be able to pay with the minted non-fungible tokens" do + tx_address = "@Alice" + now = DateTime.utc_now() + + inputs = [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + movements = [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@Token", 1} + } + ] + + minted_utxos = [ + %UnspentOutput{ + from: "@Alice", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + assert ops_result = + %LedgerValidation{ + fee: 1_000, + transaction_movements: movements, + inputs: inputs, + minted_utxos: minted_utxos + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, now) + + assert [] = ops_result.unspent_outputs + + burn_address = LedgerValidation.burning_address() + + assert [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: ^burn_address, + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] = ops_result.consumed_inputs |> VersionedUnspentOutput.unwrap_unspent_outputs() + end + + test "should be able to pay with the minted non-fungible tokens (collection)" do + tx_address = "@Alice" + now = DateTime.utc_now() + + inputs = [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + movements = [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@Token", 2} + } + ] + + minted_utxos = + [ + %UnspentOutput{ + from: "@Alice", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@Alice", + amount: 100_000_000, + type: {:token, "@Token", 2}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + assert ops_result = + %LedgerValidation{ + fee: 1_000, + transaction_movements: movements, + inputs: inputs, + minted_utxos: minted_utxos + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, now) + + assert [ + %UnspentOutput{ + from: "@Alice", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] = ops_result.unspent_outputs + + burn_address = LedgerValidation.burning_address() + + assert [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: ^burn_address, + amount: 100_000_000, + type: {:token, "@Token", 2}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] = ops_result.consumed_inputs |> VersionedUnspentOutput.unwrap_unspent_outputs() + end + + test "should merge two similar tokens and update the from & timestamp" do + transaction_address = random_address() + transaction_timestamp = DateTime.utc_now() + + from = random_address() + token_address = random_address() + old_timestamp = ~U[2023-11-09 10:39:10Z] + + inputs = + [ + %UnspentOutput{ + from: from, + amount: 200_000_000, + type: :UCO, + timestamp: old_timestamp + }, + %UnspentOutput{ + from: from, + amount: 100_000_000, + type: {:token, token_address, 0}, + timestamp: old_timestamp + }, + %UnspentOutput{ + from: from, + amount: 100_000_000, + type: {:token, token_address, 0}, + timestamp: old_timestamp + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + expected_consumed_inputs = + [ + %UnspentOutput{ + from: from, + amount: 200_000_000, + type: :UCO, + timestamp: old_timestamp + }, + %UnspentOutput{ + from: from, + amount: 100_000_000, + type: {:token, token_address, 0}, + timestamp: old_timestamp + }, + %UnspentOutput{ + from: from, + amount: 100_000_000, + type: {:token, token_address, 0}, + timestamp: old_timestamp + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + assert %LedgerValidation{ + unspent_outputs: [ + %UnspentOutput{ + from: ^transaction_address, + amount: 160_000_000, + type: :UCO, + timestamp: ^transaction_timestamp + }, + %UnspentOutput{ + from: ^transaction_address, + amount: 200_000_000, + type: {:token, ^token_address, 0}, + timestamp: ^transaction_timestamp + } + ], + consumed_inputs: ^expected_consumed_inputs, + fee: 40_000_000 + } = + %LedgerValidation{fee: 40_000_000, inputs: inputs} + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(transaction_address, transaction_timestamp) + + tx_address = "@Alice2" + now = DateTime.utc_now() + + inputs = + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 300_000_000, + type: {:token, "@Token1", 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@Tom5", + amount: 300_000_000, + type: {:token, "@Token1", 0}, + timestamp: ~U[2022-10-20 08:00:20.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + movements = [ + %TransactionMovement{ + to: "@Bob3", + amount: 100_000_000, + type: {:token, "@Token1", 0} + } + ] + + assert %LedgerValidation{ + unspent_outputs: [ + %UnspentOutput{ + from: "@Alice2", + amount: 500_000_000, + type: {:token, "@Token1", 0} + } + ], + consumed_inputs: [ + %VersionedUnspentOutput{unspent_output: %UnspentOutput{from: "@Charlie1"}}, + %VersionedUnspentOutput{unspent_output: %UnspentOutput{from: "@Tom5"}} + ] + } = + %LedgerValidation{inputs: inputs, transaction_movements: movements} + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx_address, now) + end + + test "should consume state if it's not the same" do + inputs = + [ + %UnspentOutput{ + type: :state, + from: random_address(), + encoded_payload: :crypto.strong_rand_bytes(32), + timestamp: DateTime.utc_now() + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + new_state = :crypto.strong_rand_bytes(32) + + assert %LedgerValidation{ + consumed_inputs: ^inputs, + unspent_outputs: [%UnspentOutput{type: :state, encoded_payload: ^new_state}] + } = + %LedgerValidation{fee: 0, inputs: inputs} + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs("@Alice2", DateTime.utc_now(), new_state, nil) + end + + # test "should not consume state if it's the same" do + # state = :crypto.strong_rand_bytes(32) + # + # inputs = + # [ + # %UnspentOutput{ + # type: :state, + # from: random_address(), + # encoded_payload: state, + # timestamp: DateTime.utc_now() + # } + # ] + # |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + # + # tx_validation_time = DateTime.utc_now() + # + # assert {:ok, + # %LedgerValidation{ + # consumed_inputs: [], + # unspent_outputs: [] + # }} = + # LedgerValidation.consume_inputs( + # %LedgerValidation{fee: 0}, + # "@Alice2", + # tx_validation_time, + # inputs, + # [], + # [], + # state, + # nil + # ) + # end + + test "should not return any utxo if nothing is spent" do + inputs = [ + %UnspentOutput{ + from: "@Bob3", + amount: 2_000_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) + ] + + assert %LedgerValidation{fee: 0, unspent_outputs: [], consumed_inputs: []} = + %LedgerValidation{fee: 0, inputs: inputs} + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs("@Alice2", ~U[2022-10-10 10:44:38.983Z]) + end + + test "should not update utxo if not consumed" do + token_address = random_address() + + utxo_not_used = [ + %UnspentOutput{ + from: random_address(), + amount: 200_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: random_address(), + amount: 500_000_000, + type: {:token, token_address, 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + + consumed_utxo = + [ + %UnspentOutput{ + from: random_address(), + amount: 700_000_000, + type: {:token, token_address, 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + amount: 700_000_000, + from: random_address(), + type: {:token, token_address, 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + all_utxos = + VersionedUnspentOutput.wrap_unspent_outputs(utxo_not_used, current_protocol_version()) ++ + consumed_utxo + + movements = [ + %TransactionMovement{ + to: random_address(), + amount: 1_400_000_000, + type: {:token, token_address, 0} + } + ] + + assert %LedgerValidation{fee: 0, unspent_outputs: [], consumed_inputs: consumed_inputs} = + %LedgerValidation{fee: 0, inputs: all_utxos, transaction_movements: movements} + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(random_address(), ~U[2022-10-10 10:44:38.983Z]) + + # order does not matter + assert Enum.all?(consumed_inputs, &(&1 in consumed_utxo)) and + length(consumed_inputs) == length(consumed_utxo) + end + + test "should optimize consumed utxo to avoid consolidation" do + optimized_utxo = [ + %UnspentOutput{ + from: random_address(), + amount: 200_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + + consumed_utxo = + [ + %UnspentOutput{ + from: random_address(), + amount: 10_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: random_address(), + amount: 40_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: random_address(), + amount: 150_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + all_utxos = + VersionedUnspentOutput.wrap_unspent_outputs(optimized_utxo, current_protocol_version()) ++ + consumed_utxo + + movements = [%TransactionMovement{to: random_address(), amount: 200_000_000, type: :UCO}] + + assert %LedgerValidation{fee: 0, unspent_outputs: [], consumed_inputs: consumed_inputs} = + %LedgerValidation{fee: 0, inputs: all_utxos, transaction_movements: movements} + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(random_address(), ~U[2022-10-10 10:44:38.983Z]) + + # order does not matter + assert Enum.all?(consumed_inputs, &(&1 in consumed_utxo)) and + length(consumed_inputs) == length(consumed_utxo) + end + + test "should sort utxo to be consistent across nodes" do + [lower_address, higher_address] = [random_address(), random_address()] |> Enum.sort() + + optimized_utxo = [ + %UnspentOutput{ + from: lower_address, + amount: 150_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:07.463Z] + } + ] + + consumed_utxo = + [ + %UnspentOutput{ + from: random_address(), + amount: 10_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:00.463Z] + }, + %UnspentOutput{ + from: higher_address, + amount: 150_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:07.463Z] + }, + %UnspentOutput{ + from: random_address(), + amount: 150_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) + + all_utxo = + VersionedUnspentOutput.wrap_unspent_outputs(optimized_utxo, current_protocol_version()) ++ + consumed_utxo + + movements = [%TransactionMovement{to: random_address(), amount: 310_000_000, type: :UCO}] + + Enum.each(1..5, fn _ -> + randomized_utxo = Enum.shuffle(all_utxo) + + assert %LedgerValidation{fee: 0, unspent_outputs: [], consumed_inputs: consumed_inputs} = + %LedgerValidation{ + fee: 0, + inputs: randomized_utxo, + transaction_movements: movements + } + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs( + random_address(), + ~U[2022-10-10 10:44:38.983Z] + ) + + # order does not matter + assert Enum.all?(consumed_inputs, &(&1 in consumed_utxo)) and + length(consumed_inputs) == length(consumed_utxo) + end) + end + end + + describe "build_resoved_movements/3" do + test "should resolve, convert reward and aggregate movements" do + address1 = random_address() + address2 = random_address() + + resolved_address1 = random_address() + resolved_address2 = random_address() + + token_address = random_address() + reward_token_address = random_address() + + resolved_addresses = %{address1 => resolved_address1, address2 => resolved_address2} + + RewardTokens.add_reward_token_address(reward_token_address) + + movement = [ + %TransactionMovement{to: address1, amount: 10, type: :UCO}, + %TransactionMovement{to: address1, amount: 10, type: {:token, token_address, 0}}, + %TransactionMovement{to: address1, amount: 40, type: {:token, token_address, 0}}, + %TransactionMovement{to: address2, amount: 30, type: {:token, reward_token_address, 0}}, + %TransactionMovement{to: address1, amount: 50, type: {:token, reward_token_address, 0}} + ] + + expected_resolved_movement = [ + %TransactionMovement{to: resolved_address1, amount: 60, type: :UCO}, + %TransactionMovement{to: resolved_address1, amount: 50, type: {:token, token_address, 0}}, + %TransactionMovement{to: resolved_address2, amount: 30, type: :UCO} + ] + + assert %LedgerValidation{transaction_movements: resolved_movements} = + LedgerValidation.build_resolved_movements( + %LedgerValidation{}, + movement, + resolved_addresses, + :transfer + ) + + # Order does not matters + assert length(expected_resolved_movement) == length(resolved_movements) + assert Enum.all?(expected_resolved_movement, &Enum.member?(resolved_movements, &1)) + end + end +end diff --git a/test/archethic/mining/pending_transaction_validation_test.exs b/test/archethic/mining/pending_transaction_validation_test.exs index 861edcebe..84ca849a0 100644 --- a/test/archethic/mining/pending_transaction_validation_test.exs +++ b/test/archethic/mining/pending_transaction_validation_test.exs @@ -4,7 +4,6 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do alias Archethic.Crypto - alias Archethic.Mining.Error alias Archethic.Mining.PendingTransactionValidation alias Archethic.P2P @@ -86,8 +85,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :transfer, ledger: ledger) - assert {:error, %Error{data: "Non fungible token can only be sent by unit"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Non fungible token can only be sent by unit"} = + PendingTransactionValidation.validate_non_fungible_token_transfer(tx) ledger = %Ledger{ token: %TokenLedger{ @@ -104,8 +103,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :transfer, ledger: ledger) - assert {:error, %Error{data: "Non fungible token can only be sent by unit"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Non fungible token can only be sent by unit"} = + PendingTransactionValidation.validate_non_fungible_token_transfer(tx) end test "should return ok if nft token is sent in unit" do @@ -124,7 +123,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :transfer, ledger: ledger) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_non_fungible_token_transfer(tx) end test "should return ok if fungible token is sent in fraction" do @@ -143,7 +142,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :transfer, ledger: ledger) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_non_fungible_token_transfer(tx) end end @@ -155,18 +154,18 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do content: :crypto.strong_rand_bytes(3_145_711) ) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_size(tx) end - test "should return transaction data exceeds limit when the transaction size is greater than 3.1MB" do + test "should return transaction data exceeds limit when the transaction size is greater than 3.1MB" do tx = TransactionFactory.create_non_valided_transaction( type: :data, content: :crypto.strong_rand_bytes(3_145_728) ) - assert {:error, %Error{data: "Transaction data exceeds limit"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Transaction data exceeds limit"} = + PendingTransactionValidation.validate_size(tx) end end @@ -191,8 +190,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do public_key ) - assert {:error, %Error{data: "Invalid previous public key (should be chain index - 1)"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid previous public key (should be chain index - 1)"} = + PendingTransactionValidation.validate_previous_public_key(tx) end end @@ -202,31 +201,29 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do end test "validate conditions for ownerships" do - assert {:error, - %Error{data: "Invalid data type transaction - Both content & ownership are empty"}} = - PendingTransactionValidation.validate(get_tx([])) + assert :ok = PendingTransactionValidation.validate_ownerships(get_tx([])) - assert {:error, %Error{data: "Ownership: empty secret"}} = + assert {:error, "Ownership: empty secret"} = [%Ownership{secret: "", authorized_keys: %{}}] |> get_tx() - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_ownerships() - assert {:error, %Error{data: "Ownership: empty authorized keys"}} = + assert {:error, "Ownership: empty authorized keys"} = [%Ownership{secret: random_secret(), authorized_keys: %{}}] |> get_tx() - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_ownerships() - assert {:error, %Error{data: "Ownership: invalid public key"}} = + assert {:error, "Ownership: invalid public key"} = [%Ownership{secret: random_secret(), authorized_keys: %{"" => "encrypted_key"}}] |> get_tx() - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_ownerships() - assert {:error, %Error{data: "Ownership: invalid public key"}} = + assert {:error, "Ownership: invalid public key"} = [%Ownership{secret: random_secret(), authorized_keys: %{"abc" => "cba"}}] |> get_tx() - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_ownerships() - assert {:error, %Error{data: "Ownership: invalid encrypted key"}} = + assert {:error, "Ownership: invalid encrypted key"} = [ %Ownership{ secret: random_secret(), @@ -234,7 +231,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do } ] |> get_tx() - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_ownerships() pub = random_public_key() @@ -246,7 +243,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do } ] |> get_tx() - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_ownerships() end end @@ -259,29 +256,30 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do """ assert :ok = - ContractFactory.create_valid_contract_tx(code) - |> PendingTransactionValidation.validate() + code + |> ContractFactory.create_valid_contract_tx() + |> PendingTransactionValidation.validate_contract() end test "exceeds max code size" do code = generate_code_that_exceed_limit_when_compressed() - assert {:error, %Error{data: "Invalid transaction, code exceed max size"}} = - ContractFactory.create_valid_contract_tx(code) - |> PendingTransactionValidation.validate() + assert {:error, "Invalid transaction, code exceed max size"} = + code + |> ContractFactory.create_valid_contract_tx() + |> PendingTransactionValidation.validate_contract() end end describe "Data" do test "Should return error when both content and ownerships are empty" do - assert {:error, - %Error{data: "Invalid data type transaction - Both content & ownership are empty"}} = + assert {:error, "Invalid data type transaction - Both content & ownership are empty"} = TransactionFactory.create_non_valided_transaction(type: :data) - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_type_rules(DateTime.utc_now()) pub = random_public_key() - assert :ok == + assert :ok = [ %Ownership{ secret: random_secret(), @@ -289,11 +287,11 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do } ] |> get_tx() - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_type_rules(DateTime.utc_now()) - assert :ok == + assert :ok = TransactionFactory.create_non_valided_transaction(type: :data, content: "content") - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_type_rules(DateTime.utc_now()) end end @@ -362,15 +360,15 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do {:ok, %NotFound{}} end) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end end describe "Contract" do test "should return error when code is empty" do - assert {:error, %Error{data: "Invalid contract type transaction - code is empty"}} = + assert {:error, "Invalid contract type transaction - code is empty"} = ContractFactory.create_valid_contract_tx("") - |> PendingTransactionValidation.validate() + |> PendingTransactionValidation.validate_type_rules(DateTime.utc_now()) end end @@ -396,7 +394,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :hosting, content: content) - assert :ok = PendingTransactionValidation.validate(tx, DateTime.utc_now()) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "should return :ok when we deploy a aeweb ref transaction with publicationStatus" do @@ -421,7 +419,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :hosting, content: content) - assert :ok = PendingTransactionValidation.validate(tx, DateTime.utc_now()) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "should return :ok when we deploy a aeweb ref transaction (unpublished)" do @@ -433,7 +431,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :hosting, content: content) - assert :ok = PendingTransactionValidation.validate(tx, DateTime.utc_now()) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "should return :ok when we deploy a aeweb file transaction" do @@ -444,7 +442,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :hosting, content: content) - assert :ok = PendingTransactionValidation.validate(tx, DateTime.utc_now()) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "should return :error when we deploy a wrong aeweb file transaction" do @@ -452,7 +450,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :hosting, content: content) - assert {:error, _error} = PendingTransactionValidation.validate(tx, DateTime.utc_now()) + assert {:error, _} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "should return :error when we deploy a wrong aeweb ref transaction" do @@ -476,7 +475,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :hosting, content: content) - assert {:error, _reason} = PendingTransactionValidation.validate(tx, DateTime.utc_now()) + assert {:error, _} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "should return :error when we deploy a wrong aeweb ref transaction (unpublished)" do @@ -501,7 +501,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :hosting, content: content) - assert {:error, _error} = PendingTransactionValidation.validate(tx, DateTime.utc_now()) + assert {:error, _} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "should return :error when it does not respect the schema" do @@ -526,7 +527,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :hosting, content: content) - assert {:error, _reason} = PendingTransactionValidation.validate(tx, DateTime.utc_now()) + assert {:error, _} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end end @@ -558,11 +560,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do |> stub(:get_last_chain_address, fn address -> address end) - |> stub(:get_transaction, fn _address, [:address, :type], _ -> - {:error, :transaction_not_exists} - end) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "should return an error when a node transaction public key used on non allowed origin" do @@ -597,26 +596,9 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do |> stub(:get_last_chain_address, fn address -> address end) - |> stub(:get_transaction, fn _address, [:address, :type], _ -> - {:error, :transaction_not_exists} - end) - - assert {:error, %Error{data: "Invalid node transaction with invalid key origin"}} = - PendingTransactionValidation.validate(tx) - end - - test "should return an error when a node transaction content is greater than content_max_size " do - content = :crypto.strong_rand_bytes(4 * 1024 * 1024) - - tx = - TransactionFactory.create_non_valided_transaction( - type: :data, - content: content, - seed: "seed" - ) - assert {:error, %Error{data: "Transaction data exceeds limit"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid node transaction with invalid key origin"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end end @@ -682,7 +664,9 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ) :persistent_term.put(:node_shared_secrets_gen_addr, Transaction.previous_address(tx)) - assert :ok = PendingTransactionValidation.validate(tx) + + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) + assert :ok = PendingTransactionValidation.validate_network_chain(tx) tx = TransactionFactory.create_non_valided_transaction( @@ -692,7 +676,9 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ownerships: [ownership] ) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) + assert :ok = PendingTransactionValidation.validate_network_chain(tx) + :persistent_term.put(:node_shared_secrets_gen_addr, nil) end @@ -741,8 +727,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do :persistent_term.put(:node_shared_secrets_gen_addr, Transaction.previous_address(tx)) - assert {:error, %Error{data: "Invalid node shared secrets transaction authorized nodes"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid node shared secrets transaction authorized nodes"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) :persistent_term.put(:node_shared_secrets_gen_addr, nil) end @@ -767,8 +753,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ownerships: [ownership] ) - assert {:error, %Error{data: "Invalid node shared secrets trigger time"}} = - PendingTransactionValidation.validate(tx, ~U[2022-01-01 00:00:03Z]) + assert {:error, "Invalid node shared secrets trigger time"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end end @@ -781,8 +767,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :oracle) - assert {:error, %Error{data: "Invalid oracle trigger time"}} = - PendingTransactionValidation.validate(tx, ~U[2022-01-01 00:10:03Z]) + assert {:error, "Invalid oracle trigger time"} = + PendingTransactionValidation.validate_type_rules(tx, ~U[2022-01-01 00:10:03Z]) end end @@ -827,7 +813,9 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ) :persistent_term.put(:origin_gen_addr, [Transaction.previous_address(tx)]) - assert :ok = PendingTransactionValidation.validate(tx) + + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) + :persistent_term.put(:origin_gen_addr, nil) end @@ -877,8 +865,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do :persistent_term.put(:origin_gen_addr, [Transaction.previous_address(tx)]) - assert {:error, %Error{data: "Invalid Origin transaction Public Key Already Exists"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Origin transaction Public Key Already Exists"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) :persistent_term.put(:origin_gen_addr, nil) end @@ -928,7 +916,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do :persistent_term.put(:origin_gen_addr, [Transaction.previous_address(tx)]) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) :persistent_term.put(:origin_gen_addr, nil) end @@ -963,7 +951,9 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do |> stub(:get_last_chain_address, fn _ -> {tx.address, DateTime.utc_now()} end) :persistent_term.put(:reward_gen_addr, Transaction.previous_address(tx)) - assert :ok = PendingTransactionValidation.validate(tx) + + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) + :persistent_term.put(:reward_gen_addr, nil) :persistent_term.put(:archethic_up, :up) end @@ -995,8 +985,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do |> stub(:get_last_chain_address, fn _, _ -> {tx.address, DateTime.utc_now()} end) |> stub(:get_last_chain_address, fn _ -> {tx.address, DateTime.utc_now()} end) - assert {:error, %Error{data: "The supply do not match burned fees from last summary"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "The supply do not match burned fees from last summary"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) :persistent_term.put(:archethic_up, :up) end @@ -1027,9 +1017,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do |> stub(:get_latest_burned_fees, fn -> 300_000_000 end) |> stub(:get_last_chain_address, fn _, _ -> {tx.address, DateTime.utc_now()} end) - assert {:error, - %Error{data: "There is already a mint rewards transaction since last schedule"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "There is already a mint rewards transaction since last schedule"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) :persistent_term.put(:archethic_up, :up) end @@ -1045,8 +1034,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :node_rewards) - assert {:error, %Error{data: "Invalid node rewards trigger time"}} = - PendingTransactionValidation.validate(tx, ~U[2022-01-01 00:00:03Z]) + assert {:error, "Invalid node rewards trigger time"} = + PendingTransactionValidation.validate_type_rules(tx, ~U[2022-01-01 00:00:03Z]) end end @@ -1058,6 +1047,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do name: "MyToken", type: "non-fungible", symbol: "MTK", + decimals: 8, properties: %{ global: "property" }, @@ -1070,7 +1060,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :token, content: content) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_token_transaction(tx) end test "should return ok with a token creation with allow_mint flag" do @@ -1078,7 +1068,6 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do Jason.encode!(%{ aeip: [2, 18], supply: 100_000_000_000, - decimals: 8, name: "CoinCoin", type: "fungible", symbol: "CC", @@ -1087,7 +1076,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :token, content: content) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_token_transaction(tx) end test "should return ok with a valid token resupply" do @@ -1107,7 +1096,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do { "supply": 10000000000, "type": "fungible", - "decimals": 8, + "decimals": 7, "name": "CoinCoin", "allow_mint": true, "aeip": [2, 18] @@ -1122,7 +1111,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do fetch_genesis_address: fn _, _ -> {:ok, genesis_address} end, fetch_transaction: fn _, _ -> {:ok, token_tx} end ) do - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_token_transaction(tx) + assert_called_exactly(TransactionChain.fetch_genesis_address(:_, :_), 2) end end @@ -1164,10 +1154,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do fetch_transaction: fn _, _ -> {:ok, token_tx} end ) do assert {:error, - %Error{ - data: - "Invalid token transaction - token_reference is not in the same transaction chain" - }} = PendingTransactionValidation.validate(tx) + "Invalid token transaction - token_reference is not in the same transaction chain"} = + PendingTransactionValidation.validate_token_transaction(tx) assert_called_exactly(TransactionChain.fetch_genesis_address(:_, :_), 2) end @@ -1205,10 +1193,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do fetch_transaction: fn _, _ -> {:ok, token_tx} end ) do assert {:error, - %Error{ - data: - "Invalid token transaction - token_reference does not have allow_mint: true" - }} = PendingTransactionValidation.validate(tx) + "Invalid token transaction - token_reference does not have allow_mint: true"} = + PendingTransactionValidation.validate_token_transaction(tx) assert_called_exactly(TransactionChain.fetch_genesis_address(:_, :_), 2) end @@ -1255,9 +1241,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do fetch_genesis_address: fn _, _ -> {:ok, genesis_address} end, fetch_transaction: fn _, _ -> {:ok, token_tx} end ) do - assert {:error, - %Error{data: "Invalid token transaction - token_reference must be fungible"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid token transaction - token_reference must be fungible"} = + PendingTransactionValidation.validate_token_transaction(tx) assert_called_exactly(TransactionChain.fetch_genesis_address(:_, :_), 2) end @@ -1282,8 +1267,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do fetch_genesis_address: fn _, _ -> {:ok, genesis_address} end, fetch_transaction: fn _, _ -> {:error, :transaction_not_exists} end ) do - assert {:error, %Error{data: "Invalid token transaction - token_reference not found"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid token transaction - token_reference not found"} = + PendingTransactionValidation.validate_token_transaction(tx) assert_called_exactly(TransactionChain.fetch_genesis_address(:_, :_), 2) end @@ -1311,10 +1296,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do end ) do assert {:error, - %Error{ - data: - "Invalid token transaction - token_reference exists but does not contain a valid JSON" - }} = PendingTransactionValidation.validate(tx) + "Invalid token transaction - token_reference exists but does not contain a valid JSON"} = + PendingTransactionValidation.validate_token_transaction(tx) assert_called_exactly(TransactionChain.fetch_genesis_address(:_, :_), 2) end @@ -1330,10 +1313,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do tx = TransactionFactory.create_non_valided_transaction(type: :token, content: content) - assert {:error, - %Error{ - data: "Invalid token transaction - neither a token creation nor a token resupply" - }} = PendingTransactionValidation.validate(tx) + assert {:error, "Invalid token transaction - neither a token creation nor a token resupply"} = + PendingTransactionValidation.validate_token_transaction(tx) end test "should return error if token transaction is incorrect" do @@ -1343,10 +1324,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do content: Jason.encode!(%{}) ) - assert {:error, - %Error{ - data: "Invalid token transaction - neither a token creation nor a token resupply" - }} = PendingTransactionValidation.validate(tx) + assert {:error, "Invalid token transaction - neither a token creation nor a token resupply"} = + PendingTransactionValidation.validate_token_transaction(tx) end end @@ -1388,7 +1367,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ownerships: ownerships ) - assert :ok = PendingTransactionValidation.validate(tx) + assert :ok = PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end end @@ -1396,15 +1375,15 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do test "should reject empty content in keychain transaction" do tx = TransactionFactory.create_non_valided_transaction(type: :keychain, content: "") - assert {:error, %Error{data: "Invalid Keychain transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "Should Reject keychain tx with empty Ownerships list in keychain transaction" do tx = TransactionFactory.create_non_valided_transaction(type: :keychain, content: "content") - assert {:error, %Error{data: "Invalid Keychain transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end test "Should Reject keychain tx with UCO tranfers" do @@ -1428,8 +1407,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ownerships: ownerships ) - assert {:error, %Error{data: "Invalid Keychain transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) ledger = %Ledger{ token: %TokenLedger{ @@ -1451,8 +1430,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ownerships: ownerships ) - assert {:error, %Error{data: "Invalid Keychain transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) ledger = %Ledger{ uco: %UCOLedger{ @@ -1479,8 +1458,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ownerships: ownerships ) - assert {:error, %Error{data: "Invalid Keychain transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end end @@ -1501,13 +1480,13 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ownerships: ownerships ) - assert {:error, %Error{data: "Invalid Keychain Access transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain Access transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) tx = TransactionFactory.create_non_valided_transaction(type: :keychain_access) - assert {:error, %Error{data: "Invalid Keychain Access transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain Access transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) ownerships = [ Ownership.new(random_secret(), :crypto.strong_rand_bytes(32), [ @@ -1522,8 +1501,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ownerships: ownerships ) - assert {:error, %Error{data: "Invalid Keychain Access transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain Access transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) tx = TransactionFactory.create_non_valided_transaction( @@ -1533,10 +1512,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do ) assert {:error, - %Error{ - data: - "Invalid Keychain access transaction - Previous public key must be authorized" - }} = PendingTransactionValidation.validate(tx) + "Invalid Keychain access transaction - Previous public key must be authorized"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) tx = TransactionFactory.create_non_valided_transaction( @@ -1546,8 +1523,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do recipients: [%Recipient{address: random_address()}] ) - assert {:error, %Error{data: "Invalid Keychain Access transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain Access transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) tx = TransactionFactory.create_non_valided_transaction( @@ -1557,8 +1534,8 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do recipients: [%Recipient{address: random_address(), action: "do_something", args: []}] ) - assert {:error, %Error{data: "Invalid Keychain Access transaction"}} = - PendingTransactionValidation.validate(tx) + assert {:error, "Invalid Keychain Access transaction"} = + PendingTransactionValidation.validate_type_rules(tx, DateTime.utc_now()) end end end diff --git a/test/archethic/mining/validation_context_test.exs b/test/archethic/mining/validation_context_test.exs index 3b605a743..02d9b936e 100644 --- a/test/archethic/mining/validation_context_test.exs +++ b/test/archethic/mining/validation_context_test.exs @@ -7,6 +7,7 @@ defmodule Archethic.Mining.ValidationContextTest do alias Archethic.Election alias Archethic.Mining.Fee + alias Archethic.Mining.LedgerValidation alias Archethic.Mining.ValidationContext alias Archethic.P2P @@ -536,21 +537,20 @@ defmodule Archethic.Mining.ValidationContextTest do validation_time: timestamp }) do fee = Fee.calculate(tx, nil, 0.07, timestamp, nil, 0, current_protocol_version()) + contract_context = nil + encoded_state = nil movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() ledger_operations = - %LedgerOperations{fee: fee} - |> LedgerOperations.consume_inputs( - tx.address, - timestamp, - unspent_outputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, current_protocol_version()) - ) - |> elem(1) - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, current_protocol_version()) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() %ValidationStamp{ timestamp: timestamp, @@ -569,21 +569,20 @@ defmodule Archethic.Mining.ValidationContextTest do validation_time: timestamp }) do fee = Fee.calculate(tx, nil, 0.07, timestamp, nil, 0, current_protocol_version()) + contract_context = nil + encoded_state = nil movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() ledger_operations = - %LedgerOperations{fee: fee} - |> LedgerOperations.consume_inputs( - tx.address, - timestamp, - unspent_outputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, current_protocol_version()) - ) - |> elem(1) - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, current_protocol_version()) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() %ValidationStamp{ timestamp: timestamp, @@ -602,21 +601,20 @@ defmodule Archethic.Mining.ValidationContextTest do validation_time: timestamp }) do fee = Fee.calculate(tx, nil, 0.07, timestamp, nil, 0, current_protocol_version()) + contract_context = nil + encoded_state = nil movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() ledger_operations = - %LedgerOperations{fee: fee} - |> LedgerOperations.consume_inputs( - tx.address, - timestamp, - unspent_outputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, current_protocol_version()) - ) - |> elem(1) - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, current_protocol_version()) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() %ValidationStamp{ timestamp: timestamp, @@ -639,18 +637,17 @@ defmodule Archethic.Mining.ValidationContextTest do ) do movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() + contract_context = nil + encoded_state = nil ledger_operations = - %LedgerOperations{fee: fee} - |> LedgerOperations.consume_inputs( - tx.address, - timestamp, - unspent_outputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, current_protocol_version()) - ) - |> elem(1) - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, current_protocol_version()) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() %ValidationStamp{ timestamp: timestamp, @@ -733,18 +730,17 @@ defmodule Archethic.Mining.ValidationContextTest do fee = Fee.calculate(tx, nil, 0.07, timestamp, nil, 0, current_protocol_version()) movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() + contract_context = nil + encoded_state = nil ledger_operations = - %LedgerOperations{fee: fee} - |> LedgerOperations.consume_inputs( - tx.address, - timestamp, - unspent_outputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, current_protocol_version()) - ) - |> elem(1) - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, current_protocol_version()) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() %ValidationStamp{ timestamp: timestamp, @@ -766,18 +762,17 @@ defmodule Archethic.Mining.ValidationContextTest do fee = Fee.calculate(tx, nil, 0.07, timestamp, nil, 0, current_protocol_version()) movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() + contract_context = nil + encoded_state = nil ledger_operations = - %LedgerOperations{fee: fee} - |> LedgerOperations.consume_inputs( - tx.address, - timestamp, - unspent_outputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, current_protocol_version()) - ) - |> elem(1) - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(unspent_outputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, current_protocol_version()) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() |> Map.put( :consumed_inputs, [ diff --git a/test/archethic/replication/transaction_validator_test.exs b/test/archethic/replication/transaction_validator_test.exs index 78e3dd9a8..92788327d 100644 --- a/test/archethic/replication/transaction_validator_test.exs +++ b/test/archethic/replication/transaction_validator_test.exs @@ -6,6 +6,7 @@ defmodule Archethic.Replication.TransactionValidatorTest do alias Archethic.Contracts.Contract.State alias Archethic.Crypto alias Archethic.Mining.Error + alias Archethic.Mining.ValidationContext alias Archethic.P2P alias Archethic.P2P.Message.GetLastTransactionAddress alias Archethic.P2P.Message.LastTransactionAddress @@ -86,7 +87,7 @@ defmodule Archethic.Replication.TransactionValidatorTest do }} end - describe "validate/1" do + describe "validate_consensus/1" do test "should return error when the atomic commitment is not reached" do unspent_outputs = [ %UnspentOutput{ @@ -97,27 +98,39 @@ defmodule Archethic.Replication.TransactionValidatorTest do } ] - assert {:error, %Error{data: "Invalid atomic commitment"}} = - TransactionFactory.create_transaction_with_not_atomic_commitment(unspent_outputs) - |> TransactionValidator.validate() + tx = TransactionFactory.create_transaction_with_not_atomic_commitment(unspent_outputs) + + validation_context = %ValidationContext{ + transaction: tx, + validation_stamp: tx.validation_stamp + } + + assert %ValidationContext{mining_error: %Error{data: "Invalid atomic commitment"}} = + TransactionValidator.validate_consensus(validation_context) end test "should return error when an invalid proof of work" do - assert {:error, %Error{data: "Invalid proof of work"}} = - TransactionFactory.create_transaction_with_invalid_proof_of_work() - |> TransactionValidator.validate() + tx = TransactionFactory.create_transaction_with_invalid_proof_of_work() + + validation_context = %ValidationContext{ + transaction: tx, + validation_stamp: tx.validation_stamp + } + + assert %ValidationContext{mining_error: %Error{data: "Invalid proof of work"}} = + TransactionValidator.validate_consensus(validation_context) end test "should return error when the validation stamp signature is invalid" do - assert {:error, %Error{data: "Invalid election"}} = - TransactionFactory.create_transaction_with_invalid_validation_stamp_signature() - |> TransactionValidator.validate() - end + tx = TransactionFactory.create_transaction_with_invalid_validation_stamp_signature() + + validation_context = %ValidationContext{ + transaction: tx, + validation_stamp: tx.validation_stamp + } - test "should return error when the transaction movements are invalid" do - assert {:error, %Error{data: "Invalid transaction movements"}} = - TransactionFactory.create_transaction_with_invalid_transaction_movements() - |> TransactionValidator.validate() + assert %ValidationContext{mining_error: %Error{data: "Invalid election"}} = + TransactionValidator.validate_consensus(validation_context) end test "should return error when there is an atomic commitment but with inconsistencies" do @@ -130,9 +143,15 @@ defmodule Archethic.Replication.TransactionValidatorTest do } ] - assert {:error, %Error{data: "Invalid atomic commitment"}} = - TransactionFactory.create_valid_transaction_with_inconsistencies(unspent_outputs) - |> TransactionValidator.validate() + tx = TransactionFactory.create_valid_transaction_with_inconsistencies(unspent_outputs) + + validation_context = %ValidationContext{ + transaction: tx, + validation_stamp: tx.validation_stamp + } + + assert %ValidationContext{mining_error: %Error{data: "Invalid atomic commitment"}} = + TransactionValidator.validate_consensus(validation_context) end test "should return :ok when the transaction is valid" do @@ -145,13 +164,19 @@ defmodule Archethic.Replication.TransactionValidatorTest do } ] - assert :ok = - TransactionFactory.create_valid_transaction(unspent_outputs) - |> TransactionValidator.validate() + tx = TransactionFactory.create_valid_transaction(unspent_outputs) + + validation_context = %ValidationContext{ + transaction: tx, + validation_stamp: tx.validation_stamp + } + + assert %ValidationContext{mining_error: nil} = + TransactionValidator.validate_consensus(validation_context) end end - describe "validate/5" do + describe "validate/1" do test "should return :ok when the transaction is valid" do unspent_outputs = [ %UnspentOutput{ @@ -165,10 +190,27 @@ defmodule Archethic.Replication.TransactionValidatorTest do v_unspent_outputs = VersionedUnspentOutput.wrap_unspent_outputs(unspent_outputs, current_protocol_version()) - tx = TransactionFactory.create_valid_transaction(unspent_outputs) + tx = + TransactionFactory.create_valid_transaction(unspent_outputs, + type: :data, + content: "content" + ) + genesis = Transaction.previous_address(tx) - assert :ok = TransactionValidator.validate(tx, nil, genesis, v_unspent_outputs, nil) + validation_context = %ValidationContext{ + transaction: tx, + previous_transaction: nil, + genesis_address: genesis, + aggregated_utxos: v_unspent_outputs, + unspent_outputs: v_unspent_outputs, + contract_context: nil, + validation_stamp: tx.validation_stamp, + validation_time: tx.validation_stamp.timestamp + } + + assert %ValidationContext{mining_error: nil} = + TransactionValidator.validate(validation_context) end test "should validate when the transaction coming from a contract is valid" do @@ -208,19 +250,26 @@ defmodule Archethic.Replication.TransactionValidatorTest do genesis = Transaction.previous_address(prev_tx) - assert :ok = - TransactionValidator.validate( - next_tx, - prev_tx, - genesis, - versioned_inputs, - %Contract.Context{ - status: :tx_output, - timestamp: now, - trigger: {:datetime, now}, - inputs: versioned_inputs - } - ) + contract_context = %Contract.Context{ + status: :tx_output, + timestamp: now, + trigger: {:datetime, now}, + inputs: versioned_inputs + } + + validation_context = %ValidationContext{ + transaction: next_tx, + previous_transaction: prev_tx, + genesis_address: genesis, + aggregated_utxos: versioned_inputs, + unspent_outputs: versioned_inputs, + contract_context: contract_context, + validation_stamp: next_tx.validation_stamp, + validation_time: next_tx.validation_stamp.timestamp + } + + assert %ValidationContext{mining_error: nil} = + TransactionValidator.validate(validation_context) end test "should return error when the fees are invalid" do @@ -239,8 +288,19 @@ defmodule Archethic.Replication.TransactionValidatorTest do v_unspent_outputs = VersionedUnspentOutput.wrap_unspent_outputs(unspent_outputs, current_protocol_version()) - assert {:error, %Error{data: "Invalid transaction fee"}} = - TransactionValidator.validate(tx, nil, genesis, v_unspent_outputs, nil) + validation_context = %ValidationContext{ + transaction: tx, + previous_transaction: nil, + genesis_address: genesis, + aggregated_utxos: v_unspent_outputs, + unspent_outputs: v_unspent_outputs, + contract_context: nil, + validation_stamp: tx.validation_stamp, + validation_time: tx.validation_stamp.timestamp + } + + assert %ValidationContext{mining_error: %Error{data: ["transaction fee"]}} = + TransactionValidator.validate(validation_context) end test "should return error when the fees are invalid using contract context" do @@ -302,14 +362,19 @@ defmodule Archethic.Replication.TransactionValidatorTest do {:ok, trigger_tx} end) - assert {:error, %Error{data: "Invalid transaction fee"}} = - TransactionValidator.validate( - next_tx, - prev_tx, - contract_genesis, - v_unspent_outputs, - contract_context - ) + validation_context = %ValidationContext{ + transaction: next_tx, + previous_transaction: prev_tx, + genesis_address: contract_genesis, + aggregated_utxos: v_unspent_outputs, + unspent_outputs: v_unspent_outputs, + contract_context: contract_context, + validation_stamp: next_tx.validation_stamp, + validation_time: next_tx.validation_stamp.timestamp + } + + assert %ValidationContext{mining_error: %Error{data: ["transaction fee"]}} = + TransactionValidator.validate(validation_context) end test "should return error if recipient contract execution invalid" do @@ -348,7 +413,20 @@ defmodule Archethic.Replication.TransactionValidatorTest do {:ok, %GenesisAddress{address: recipient_genesis, timestamp: DateTime.utc_now()}} end) - assert {:error, _} = TransactionValidator.validate(tx, nil, genesis, v_unspent_outputs, nil) + validation_context = %ValidationContext{ + transaction: tx, + previous_transaction: nil, + genesis_address: genesis, + aggregated_utxos: v_unspent_outputs, + unspent_outputs: v_unspent_outputs, + contract_context: nil, + validation_stamp: tx.validation_stamp, + validation_time: tx.validation_stamp.timestamp, + resolved_addresses: %{recipient_address => recipient_genesis} + } + + assert %ValidationContext{mining_error: %Error{message: "Invalid recipients execution"}} = + TransactionValidator.validate(validation_context) end test "should return error when the inputs are invalid using contract context" do @@ -410,14 +488,19 @@ defmodule Archethic.Replication.TransactionValidatorTest do {:ok, trigger_tx} end) - assert {:error, %Error{message: "Invalid contract context inputs"}} = - TransactionValidator.validate( - next_tx, - prev_tx, - contract_genesis, - v_unspent_outputs, - contract_context - ) + validation_context = %ValidationContext{ + transaction: next_tx, + previous_transaction: prev_tx, + genesis_address: contract_genesis, + aggregated_utxos: v_unspent_outputs, + unspent_outputs: v_unspent_outputs, + contract_context: contract_context, + validation_stamp: next_tx.validation_stamp, + validation_time: next_tx.validation_stamp.timestamp + } + + assert %ValidationContext{mining_error: %Error{message: "Invalid contract context inputs"}} = + TransactionValidator.validate(validation_context) end end end diff --git a/test/archethic/replication_test.exs b/test/archethic/replication_test.exs index 3468e5be0..4c2b6201a 100644 --- a/test/archethic/replication_test.exs +++ b/test/archethic/replication_test.exs @@ -74,7 +74,9 @@ defmodule Archethic.ReplicationTest do ] p2p_context() - tx = TransactionFactory.create_valid_transaction(unspent_outputs) + + tx = + TransactionFactory.create_valid_transaction(unspent_outputs, type: :data, content: "content") MockClient |> stub(:send_message, fn diff --git a/test/archethic/transaction_chain/transaction/validation_stamp/ledger_operations_test.exs b/test/archethic/transaction_chain/transaction/validation_stamp/ledger_operations_test.exs index 7b7245d51..489afbe8e 100644 --- a/test/archethic/transaction_chain/transaction/validation_stamp/ledger_operations_test.exs +++ b/test/archethic/transaction_chain/transaction/validation_stamp/ledger_operations_test.exs @@ -1,7 +1,6 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperationsTest do alias Archethic.Reward.MemTables.RewardTokens - alias Archethic.TransactionFactory alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.TransactionMovement @@ -47,1329 +46,11 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation for version <- 1..current_protocol_version() do assert {^ops, <<>>} = - LedgerOperations.serialize(ops, version) - |> LedgerOperations.deserialize(version) + LedgerOperations.serialize(ops, version) |> LedgerOperations.deserialize(version) end end end - describe("get_utxos_from_transaction/2") do - test "should return empty list for non token/mint_reward transactiosn" do - types = Archethic.TransactionChain.Transaction.types() -- [:node, :mint_reward] - - Enum.each(types, fn t -> - assert [] = - LedgerOperations.get_utxos_from_transaction( - TransactionFactory.create_valid_transaction([], type: t), - DateTime.utc_now(), - current_protocol_version() - ) - end) - end - - test "should return empty list if content is invalid" do - assert [] = - LedgerOperations.get_utxos_from_transaction( - TransactionFactory.create_valid_transaction([], - type: :token, - content: "not a json" - ), - DateTime.utc_now(), - current_protocol_version() - ) - - assert [] = - LedgerOperations.get_utxos_from_transaction( - TransactionFactory.create_valid_transaction([], type: :token, content: "{}"), - DateTime.utc_now(), - current_protocol_version() - ) - end - end - - describe("get_utxos_from_transaction/2 with a token resupply transaction") do - test "should return a utxo" do - token_address = random_address() - token_address_hex = token_address |> Base.encode16() - now = DateTime.utc_now() - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "token_reference": "#{token_address_hex}", - "supply": 1000000 - } - """ - ) - - tx_address = tx.address - - assert [ - %UnspentOutput{ - amount: 1_000_000, - from: ^tx_address, - type: {:token, ^token_address, 0}, - timestamp: ^now - } - ] = - LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - |> VersionedUnspentOutput.unwrap_unspent_outputs() - end - - test "should return an empty list if invalid tx" do - now = DateTime.utc_now() - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "token_reference": "nonhexadecimal", - "supply": 1000000 - } - """ - ) - - assert [] = LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "token_reference": {"foo": "bar"}, - "supply": 1000000 - } - """ - ) - - assert [] = LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - - token_address = random_address() - token_address_hex = token_address |> Base.encode16() - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "token_reference": "#{token_address_hex}", - "supply": "hello" - } - """ - ) - - assert [] = LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - end - end - - describe("get_utxos_from_transaction/2 with a token creation transaction") do - test "should return a utxo (for fungible)" do - now = DateTime.utc_now() - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "supply": 1000000000, - "type": "fungible", - "decimals": 8, - "name": "NAME OF MY TOKEN", - "symbol": "MTK" - } - """ - ) - - tx_address = tx.address - - assert [ - %UnspentOutput{ - amount: 1_000_000_000, - from: ^tx_address, - type: {:token, ^tx_address, 0}, - timestamp: ^now - } - ] = - LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - |> VersionedUnspentOutput.unwrap_unspent_outputs() - end - - test "should return a utxo (for non-fungible)" do - now = DateTime.utc_now() - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "supply": 100000000, - "type": "non-fungible", - "name": "My NFT", - "symbol": "MNFT", - "properties": { - "image": "base64 of the image", - "description": "This is a NFT with an image" - } - } - """ - ) - - tx_address = tx.address - - protocol_version = current_protocol_version() - - assert [ - %VersionedUnspentOutput{ - unspent_output: %UnspentOutput{ - amount: 100_000_000, - from: ^tx_address, - type: {:token, ^tx_address, 1}, - timestamp: ^now - }, - protocol_version: ^protocol_version - } - ] = LedgerOperations.get_utxos_from_transaction(tx, now, protocol_version) - end - - test "should return a utxo (for non-fungible collection)" do - now = DateTime.utc_now() - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "supply": 300000000, - "name": "My NFT", - "type": "non-fungible", - "symbol": "MNFT", - "properties": { - "description": "this property is for all NFT" - }, - "collection": [ - { "image": "link of the 1st NFT image" }, - { "image": "link of the 2nd NFT image" }, - { - "image": "link of the 3rd NFT image", - "other_property": "other value" - } - ] - } - """ - ) - - tx_address = tx.address - - expected_utxos = - [ - %UnspentOutput{ - amount: 100_000_000, - from: tx_address, - type: {:token, tx_address, 1}, - timestamp: now - }, - %UnspentOutput{ - amount: 100_000_000, - from: tx_address, - type: {:token, tx_address, 2}, - timestamp: now - }, - %UnspentOutput{ - amount: 100_000_000, - from: tx_address, - type: {:token, tx_address, 3}, - timestamp: now - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - assert ^expected_utxos = - LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - end - - test "should return an empty list if amount is incorrect (for non-fungible)" do - now = DateTime.utc_now() - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "supply": 1, - "type": "non-fungible", - "name": "My NFT", - "symbol": "MNFT", - "properties": { - "image": "base64 of the image", - "description": "This is a NFT with an image" - } - } - """ - ) - - assert [] = LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - end - - test "should return an empty list if invalid tx" do - now = DateTime.utc_now() - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "supply": "foo" - } - """ - ) - - assert [] = LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "supply": 100000000 - } - """ - ) - - assert [] = LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - - tx = - TransactionFactory.create_valid_transaction([], - type: :token, - content: """ - { - "type": "fungible" - } - """ - ) - - assert [] = LedgerOperations.get_utxos_from_transaction(tx, now, current_protocol_version()) - end - end - - describe "consume_inputs/4" do - test "When a single unspent output is sufficient to satisfy the transaction movements" do - assert %LedgerOperations{ - fee: 40_000_000, - unspent_outputs: [ - %UnspentOutput{ - from: "@Alice2", - amount: 703_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ], - consumed_inputs: [ - %VersionedUnspentOutput{ - unspent_output: %UnspentOutput{ - from: "@Bob3", - amount: 2_000_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - } - ] - } = - LedgerOperations.consume_inputs( - %LedgerOperations{fee: 40_000_000}, - "@Alice2", - ~U[2022-10-10 10:44:38.983Z], - [ - %UnspentOutput{ - from: "@Bob3", - amount: 2_000_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ], - [ - %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, - %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} - ] - ) - |> elem(1) - end - - test "When multiple little unspent output are sufficient to satisfy the transaction movements" do - expected_consumed_inputs = - [ - %UnspentOutput{ - from: "@Bob3", - amount: 500_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Christina", - amount: 400_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Hugo", - amount: 800_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Tom4", - amount: 700_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - assert %LedgerOperations{ - fee: 40_000_000, - unspent_outputs: [ - %UnspentOutput{ - from: "@Alice2", - amount: 1_103_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ], - consumed_inputs: ^expected_consumed_inputs - } = - %LedgerOperations{fee: 40_000_000} - |> LedgerOperations.consume_inputs( - "@Alice2", - ~U[2022-10-10 10:44:38.983Z], - [ - %UnspentOutput{ - from: "@Bob3", - amount: 500_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Tom4", - amount: 700_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Christina", - amount: 400_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Hugo", - amount: 800_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()), - [ - %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, - %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} - ] - ) - |> elem(1) - end - - test "When using Token unspent outputs are sufficient to satisfy the transaction movements" do - expected_consumed_inputs = - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 200_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: "@Bob3", - amount: 1_200_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - assert %LedgerOperations{ - fee: 40_000_000, - unspent_outputs: [ - %UnspentOutput{ - from: "@Alice2", - amount: 160_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Alice2", - amount: 200_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ], - consumed_inputs: ^expected_consumed_inputs - } = - %LedgerOperations{fee: 40_000_000} - |> LedgerOperations.consume_inputs( - "@Alice2", - ~U[2022-10-10 10:44:38.983Z], - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 200_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: "@Bob3", - amount: 1_200_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()), - [ - %TransactionMovement{ - to: "@Bob4", - amount: 1_000_000_000, - type: {:token, "@CharlieToken", 0} - } - ] - ) - |> elem(1) - end - - test "When multiple Token unspent outputs are sufficient to satisfy the transaction movements" do - expected_consumed_inputs = - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 200_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Bob3", - amount: 500_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Hugo5", - amount: 700_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - amount: 700_000_000, - from: "@Tom1", - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - assert %LedgerOperations{ - fee: 40_000_000, - unspent_outputs: [ - %UnspentOutput{ - from: "@Alice2", - amount: 160_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Alice2", - amount: 900_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ], - consumed_inputs: ^expected_consumed_inputs - } = - %LedgerOperations{fee: 40_000_000} - |> LedgerOperations.consume_inputs( - "@Alice2", - ~U[2022-10-10 10:44:38.983Z], - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 200_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Bob3", - amount: 500_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Hugo5", - amount: 700_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-10 10:44:38.983Z] - }, - %UnspentOutput{ - from: "@Tom1", - amount: 700_000_000, - type: {:token, "@CharlieToken", 0}, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()), - [ - %TransactionMovement{ - to: "@Bob4", - amount: 1_000_000_000, - type: {:token, "@CharlieToken", 0} - } - ] - ) - |> elem(1) - end - - test "When non-fungible tokens are used as input but want to consume only a single input" do - expected_consumed_inputs = - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 200_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: "@CharlieToken", - amount: 100_000_000, - type: {:token, "@CharlieToken", 2}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - assert %LedgerOperations{ - fee: 40_000_000, - unspent_outputs: [ - %UnspentOutput{ - from: "@Alice2", - amount: 160_000_000, - type: :UCO, - timestamp: ~U[2022-10-10 10:44:38.983Z] - } - ], - consumed_inputs: ^expected_consumed_inputs - } = - %LedgerOperations{fee: 40_000_000} - |> LedgerOperations.consume_inputs( - "@Alice2", - ~U[2022-10-10 10:44:38.983Z], - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 200_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: "@CharlieToken", - amount: 100_000_000, - type: {:token, "@CharlieToken", 1}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: "@CharlieToken", - amount: 100_000_000, - type: {:token, "@CharlieToken", 2}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: "@CharlieToken", - amount: 100_000_000, - type: {:token, "@CharlieToken", 3}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()), - [ - %TransactionMovement{ - to: "@Bob4", - amount: 100_000_000, - type: {:token, "@CharlieToken", 2} - } - ] - ) - |> elem(1) - end - - test "should return insufficient funds when not enough uco" do - ops = %LedgerOperations{fee: 1_000} - - assert {:error, :insufficient_funds} = - LedgerOperations.consume_inputs(ops, "@Alice", DateTime.utc_now()) - end - - test "should return insufficient funds when not enough tokens" do - ops = %LedgerOperations{fee: 1_000} - - assert {:error, :insufficient_funds} = - LedgerOperations.consume_inputs( - ops, - "@Alice", - DateTime.utc_now(), - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 1_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ], - [ - %TransactionMovement{ - to: "@JeanClaude", - amount: 100_000_000, - type: {:token, "@CharlieToken", 0} - } - ] - ) - end - - test "should be able to pay with the minted fungible tokens" do - now = DateTime.utc_now() - - ops = %LedgerOperations{fee: 1_000} - - assert {:ok, ops_result} = - LedgerOperations.consume_inputs( - ops, - "@Alice", - now, - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 1_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ], - [ - %TransactionMovement{ - to: "@JeanClaude", - amount: 50_000_000, - type: {:token, "@Token", 0} - } - ], - [ - %UnspentOutput{ - from: "@Alice", - amount: 100_000_000, - type: {:token, "@Token", 0}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ] - ) - - assert [ - %UnspentOutput{ - from: "@Alice", - amount: 50_000_000, - type: {:token, "@Token", 0}, - timestamp: ^now - } - ] = ops_result.unspent_outputs - - burn_address = LedgerOperations.burning_address() - - assert [ - %UnspentOutput{ - from: "@Charlie1", - amount: 1_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: ^burn_address, - amount: 100_000_000, - type: {:token, "@Token", 0}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] = ops_result.consumed_inputs |> VersionedUnspentOutput.unwrap_unspent_outputs() - end - - test "should be able to pay with the minted non-fungible tokens" do - now = DateTime.utc_now() - - ops = %LedgerOperations{fee: 1_000} - - assert {:ok, ops_result} = - LedgerOperations.consume_inputs( - ops, - "@Alice", - now, - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 1_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ], - [ - %TransactionMovement{ - to: "@JeanClaude", - amount: 100_000_000, - type: {:token, "@Token", 1} - } - ], - [ - %UnspentOutput{ - from: "@Alice", - amount: 100_000_000, - type: {:token, "@Token", 1}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ] - ) - - assert [] = ops_result.unspent_outputs - - burn_address = LedgerOperations.burning_address() - - assert [ - %UnspentOutput{ - from: "@Charlie1", - amount: 1_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: ^burn_address, - amount: 100_000_000, - type: {:token, "@Token", 1}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] = ops_result.consumed_inputs |> VersionedUnspentOutput.unwrap_unspent_outputs() - end - - test "should be able to pay with the minted non-fungible tokens (collection)" do - now = DateTime.utc_now() - - ops = %LedgerOperations{fee: 1_000} - - assert {:ok, ops_result} = - LedgerOperations.consume_inputs( - ops, - "@Alice", - now, - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 1_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ], - [ - %TransactionMovement{ - to: "@JeanClaude", - amount: 100_000_000, - type: {:token, "@Token", 2} - } - ], - [ - %UnspentOutput{ - from: "@Alice", - amount: 100_000_000, - type: {:token, "@Token", 1}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: "@Alice", - amount: 100_000_000, - type: {:token, "@Token", 2}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - ) - - assert [ - %UnspentOutput{ - from: "@Alice", - amount: 100_000_000, - type: {:token, "@Token", 1}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] = ops_result.unspent_outputs - - burn_address = LedgerOperations.burning_address() - - assert [ - %UnspentOutput{ - from: "@Charlie1", - amount: 1_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: ^burn_address, - amount: 100_000_000, - type: {:token, "@Token", 2}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] = ops_result.consumed_inputs |> VersionedUnspentOutput.unwrap_unspent_outputs() - end - - test "should not be able to pay with the same non-fungible token twice" do - now = DateTime.utc_now() - - ops = %LedgerOperations{fee: 1_000} - - assert {:error, :insufficient_funds} = - LedgerOperations.consume_inputs( - ops, - "@Alice", - now, - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 1_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ], - [ - %TransactionMovement{ - to: "@JeanClaude", - amount: 100_000_000, - type: {:token, "@Token", 1} - }, - %TransactionMovement{ - to: "@JeanBob", - amount: 100_000_000, - type: {:token, "@Token", 1} - } - ], - [ - %UnspentOutput{ - from: "@Alice", - amount: 100_000_000, - type: {:token, "@Token", 1}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ] - ) - end - - test "should merge two similar tokens and update the from & timestamp" do - transaction_address = random_address() - transaction_timestamp = DateTime.utc_now() - - from = random_address() - token_address = random_address() - old_timestamp = ~U[2023-11-09 10:39:10Z] - - expected_consumed_inputs = - [ - %UnspentOutput{ - from: from, - amount: 200_000_000, - type: :UCO, - timestamp: old_timestamp - }, - %UnspentOutput{ - from: from, - amount: 100_000_000, - type: {:token, token_address, 0}, - timestamp: old_timestamp - }, - %UnspentOutput{ - from: from, - amount: 100_000_000, - type: {:token, token_address, 0}, - timestamp: old_timestamp - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - assert {:ok, - %LedgerOperations{ - unspent_outputs: [ - %UnspentOutput{ - from: ^transaction_address, - amount: 160_000_000, - type: :UCO, - timestamp: ^transaction_timestamp - }, - %UnspentOutput{ - from: ^transaction_address, - amount: 200_000_000, - type: {:token, ^token_address, 0}, - timestamp: ^transaction_timestamp - } - ], - consumed_inputs: ^expected_consumed_inputs, - fee: 40_000_000 - }} = - LedgerOperations.consume_inputs( - %LedgerOperations{fee: 40_000_000}, - transaction_address, - transaction_timestamp, - [ - %UnspentOutput{ - from: from, - amount: 200_000_000, - type: :UCO, - timestamp: old_timestamp - }, - %UnspentOutput{ - from: from, - amount: 100_000_000, - type: {:token, token_address, 0}, - timestamp: old_timestamp - }, - %UnspentOutput{ - from: from, - amount: 100_000_000, - type: {:token, token_address, 0}, - timestamp: old_timestamp - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - ) - - assert {:ok, - %LedgerOperations{ - unspent_outputs: [ - %UnspentOutput{ - from: "@Alice2", - amount: 500_000_000, - type: {:token, "@Token1", 0} - } - ], - consumed_inputs: [ - %VersionedUnspentOutput{unspent_output: %UnspentOutput{from: "@Charlie1"}}, - %VersionedUnspentOutput{unspent_output: %UnspentOutput{from: "@Tom5"}} - ] - }} = - LedgerOperations.consume_inputs( - %LedgerOperations{}, - "@Alice2", - DateTime.utc_now(), - [ - %UnspentOutput{ - from: "@Charlie1", - amount: 300_000_000, - type: {:token, "@Token1", 0}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: "@Tom5", - amount: 300_000_000, - type: {:token, "@Token1", 0}, - timestamp: ~U[2022-10-20 08:00:20.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()), - [ - %TransactionMovement{ - to: "@Bob3", - amount: 100_000_000, - type: {:token, "@Token1", 0} - } - ] - ) - end - - test "should consume state if it's not the same" do - inputs = - [ - %UnspentOutput{ - type: :state, - from: random_address(), - encoded_payload: :crypto.strong_rand_bytes(32), - timestamp: DateTime.utc_now() - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - new_state = :crypto.strong_rand_bytes(32) - - assert {:ok, - %LedgerOperations{ - consumed_inputs: ^inputs, - unspent_outputs: [ - %UnspentOutput{ - type: :state, - encoded_payload: ^new_state - } - ] - }} = - LedgerOperations.consume_inputs( - %LedgerOperations{fee: 0}, - "@Alice2", - DateTime.utc_now(), - inputs, - [], - [], - new_state, - nil - ) - end - - # test "should not consume state if it's the same" do - # state = :crypto.strong_rand_bytes(32) - # - # inputs = - # [ - # %UnspentOutput{ - # type: :state, - # from: random_address(), - # encoded_payload: state, - # timestamp: DateTime.utc_now() - # } - # ] - # |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - # - # tx_validation_time = DateTime.utc_now() - # - # assert {:ok, - # %LedgerOperations{ - # consumed_inputs: [], - # unspent_outputs: [] - # }} = - # LedgerOperations.consume_inputs( - # %LedgerOperations{fee: 0}, - # "@Alice2", - # tx_validation_time, - # inputs, - # [], - # [], - # state, - # nil - # ) - # end - - test "should not return any utxo if nothing is spent" do - assert {:ok, - %LedgerOperations{ - fee: 0, - unspent_outputs: [], - consumed_inputs: [] - }} = - LedgerOperations.consume_inputs( - %LedgerOperations{fee: 0}, - "@Alice2", - ~U[2022-10-10 10:44:38.983Z], - [ - %UnspentOutput{ - from: "@Bob3", - amount: 2_000_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - |> VersionedUnspentOutput.wrap_unspent_output(current_protocol_version()) - ] - ) - end - - test "should not update utxo if not consumed" do - token_address = random_address() - - utxo_not_used = [ - %UnspentOutput{ - from: random_address(), - amount: 200_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: random_address(), - amount: 500_000_000, - type: {:token, token_address, 0}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - - consumed_utxo = - [ - %UnspentOutput{ - from: random_address(), - amount: 700_000_000, - type: {:token, token_address, 0}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - amount: 700_000_000, - from: random_address(), - type: {:token, token_address, 0}, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - all_utxos = - VersionedUnspentOutput.wrap_unspent_outputs(utxo_not_used, current_protocol_version()) ++ - consumed_utxo - - assert {:ok, - %LedgerOperations{ - fee: 0, - unspent_outputs: [], - consumed_inputs: consumed_inputs - }} = - LedgerOperations.consume_inputs( - %LedgerOperations{fee: 0}, - random_address(), - ~U[2022-10-10 10:44:38.983Z], - all_utxos, - [ - %TransactionMovement{ - to: random_address(), - amount: 1_400_000_000, - type: {:token, token_address, 0} - } - ] - ) - - # order does not matter - assert Enum.all?(consumed_inputs, &(&1 in consumed_utxo)) and - length(consumed_inputs) == length(consumed_utxo) - end - - test "should optimize consumed utxo to avoid consolidation" do - optimized_utxo = [ - %UnspentOutput{ - from: random_address(), - amount: 200_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - - consumed_utxo = - [ - %UnspentOutput{ - from: random_address(), - amount: 10_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: random_address(), - amount: 40_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - }, - %UnspentOutput{ - from: random_address(), - amount: 150_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - all_utxos = - VersionedUnspentOutput.wrap_unspent_outputs(optimized_utxo, current_protocol_version()) ++ - consumed_utxo - - assert {:ok, - %LedgerOperations{ - fee: 0, - unspent_outputs: [], - consumed_inputs: consumed_inputs - }} = - LedgerOperations.consume_inputs( - %LedgerOperations{fee: 0}, - random_address(), - ~U[2022-10-10 10:44:38.983Z], - all_utxos, - [%TransactionMovement{to: random_address(), amount: 200_000_000, type: :UCO}] - ) - - # order does not matter - assert Enum.all?(consumed_inputs, &(&1 in consumed_utxo)) and - length(consumed_inputs) == length(consumed_utxo) - end - - test "should sort utxo to be consistent across nodes" do - [lower_address, higher_address] = [random_address(), random_address()] |> Enum.sort() - - optimized_utxo = [ - %UnspentOutput{ - from: lower_address, - amount: 150_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:07.463Z] - } - ] - - consumed_utxo = - [ - %UnspentOutput{ - from: random_address(), - amount: 10_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:00.463Z] - }, - %UnspentOutput{ - from: higher_address, - amount: 150_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:07.463Z] - }, - %UnspentOutput{ - from: random_address(), - amount: 150_000_000, - type: :UCO, - timestamp: ~U[2022-10-09 08:39:10.463Z] - } - ] - |> VersionedUnspentOutput.wrap_unspent_outputs(current_protocol_version()) - - all_utxo = - VersionedUnspentOutput.wrap_unspent_outputs(optimized_utxo, current_protocol_version()) ++ - consumed_utxo - - Enum.each(1..5, fn _ -> - randomized_utxo = Enum.shuffle(all_utxo) - - assert {:ok, - %LedgerOperations{ - fee: 0, - unspent_outputs: [], - consumed_inputs: consumed_inputs - }} = - LedgerOperations.consume_inputs( - %LedgerOperations{fee: 0}, - random_address(), - ~U[2022-10-10 10:44:38.983Z], - randomized_utxo, - [%TransactionMovement{to: random_address(), amount: 310_000_000, type: :UCO}] - ) - - # order does not matter - assert Enum.all?(consumed_inputs, &(&1 in consumed_utxo)) and - length(consumed_inputs) == length(consumed_utxo) - end) - end - end - describe "symmetric serialization" do test "should support latest protocol version" do ops = %LedgerOperations{ @@ -1444,47 +125,4 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation |> elem(0) end end - - describe "build_resoved_movements/3" do - test "should resolve, convert reward and aggregate movements" do - address1 = random_address() - address2 = random_address() - - resolved_address1 = random_address() - resolved_address2 = random_address() - - token_address = random_address() - reward_token_address = random_address() - - resolved_addresses = %{address1 => resolved_address1, address2 => resolved_address2} - - RewardTokens.add_reward_token_address(reward_token_address) - - movement = [ - %TransactionMovement{to: address1, amount: 10, type: :UCO}, - %TransactionMovement{to: address1, amount: 10, type: {:token, token_address, 0}}, - %TransactionMovement{to: address1, amount: 40, type: {:token, token_address, 0}}, - %TransactionMovement{to: address2, amount: 30, type: {:token, reward_token_address, 0}}, - %TransactionMovement{to: address1, amount: 50, type: {:token, reward_token_address, 0}} - ] - - expected_resolved_movement = [ - %TransactionMovement{to: resolved_address1, amount: 60, type: :UCO}, - %TransactionMovement{to: resolved_address1, amount: 50, type: {:token, token_address, 0}}, - %TransactionMovement{to: resolved_address2, amount: 30, type: :UCO} - ] - - assert %LedgerOperations{transaction_movements: resolved_movements} = - LedgerOperations.build_resolved_movements( - %LedgerOperations{}, - movement, - resolved_addresses, - :transfer - ) - - # Order does not matters - assert length(expected_resolved_movement) == length(resolved_movements) - assert Enum.all?(expected_resolved_movement, &Enum.member?(resolved_movements, &1)) - end - end end diff --git a/test/support/transaction_factory.ex b/test/support/transaction_factory.ex index bedd5997d..daa37e749 100644 --- a/test/support/transaction_factory.ex +++ b/test/support/transaction_factory.ex @@ -6,12 +6,12 @@ defmodule Archethic.TransactionFactory do alias Archethic.Election alias Archethic.Mining.Fee + alias Archethic.Mining.LedgerValidation alias Archethic.TransactionChain alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.Transaction.CrossValidationStamp alias Archethic.TransactionChain.Transaction.ValidationStamp - alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.UnspentOutput alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.VersionedUnspentOutput @@ -97,23 +97,14 @@ defmodule Archethic.TransactionFactory do resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() - ledger_operations = %LedgerOperations{fee: fee} - ledger_operations = - case LedgerOperations.consume_inputs( - ledger_operations, - tx.address, - timestamp, - inputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, protocol_version), - encoded_state, - contract_context - ) do - {:ok, ledger_operations} -> ledger_operations - {:error, _} -> ledger_operations - end - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(inputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() poi = case prev_tx do @@ -162,22 +153,17 @@ defmodule Archethic.TransactionFactory do movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() - - ledger_operations = %LedgerOperations{fee: fee} + contract_context = nil + encoded_state = nil ledger_operations = - case LedgerOperations.consume_inputs( - ledger_operations, - tx.address, - timestamp, - inputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, protocol_version) - ) do - {:ok, ops} -> ops - _ -> ledger_operations - end - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(inputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() validation_stamp = %ValidationStamp{ @@ -212,22 +198,17 @@ defmodule Archethic.TransactionFactory do movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() - - ledger_operations = %LedgerOperations{fee: fee} + contract_context = nil + encoded_state = nil ledger_operations = - case LedgerOperations.consume_inputs( - ledger_operations, - tx.address, - timestamp, - inputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, protocol_version) - ) do - {:ok, ops} -> ops - _ -> ledger_operations - end - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(inputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() validation_stamp = %ValidationStamp{ timestamp: timestamp, @@ -265,22 +246,17 @@ defmodule Archethic.TransactionFactory do movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() - - ledger_operations = %LedgerOperations{fee: fee} + contract_context = nil + encoded_state = nil ledger_operations = - case LedgerOperations.consume_inputs( - ledger_operations, - tx.address, - timestamp, - inputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, protocol_version) - ) do - {:ok, ops} -> ops - _ -> ledger_operations - end - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(inputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() validation_stamp = %ValidationStamp{ timestamp: timestamp, @@ -302,7 +278,7 @@ defmodule Archethic.TransactionFactory do end def create_transaction_with_invalid_fee(inputs \\ []) do - tx = Transaction.new(:transfer, %TransactionData{}, "seed", 0) + tx = Transaction.new(:data, %TransactionData{content: "content"}, "seed", 0) timestamp = DateTime.utc_now() |> DateTime.truncate(:millisecond) protocol_version = current_protocol_version() @@ -311,22 +287,17 @@ defmodule Archethic.TransactionFactory do movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() - - ledger_operations = %LedgerOperations{fee: 1_000_000_000} + contract_context = nil + encoded_state = nil ledger_operations = - case LedgerOperations.consume_inputs( - ledger_operations, - tx.address, - timestamp, - inputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, protocol_version) - ) do - {:ok, ops} -> ops - _ -> ledger_operations - end - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: 1_000_000_000} + |> LedgerValidation.filter_usable_inputs(inputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() validation_stamp = %ValidationStamp{ @@ -360,22 +331,17 @@ defmodule Archethic.TransactionFactory do movements = [%TransactionMovement{to: "@Bob4", amount: 30_330_000_000, type: :UCO}] resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() - - ledger_operations = %LedgerOperations{fee: fee} + contract_context = nil + encoded_state = nil ledger_operations = - case LedgerOperations.consume_inputs( - ledger_operations, - tx.address, - timestamp, - inputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, protocol_version) - ) do - {:ok, ops} -> ops - _ -> ledger_operations - end - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(inputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() validation_stamp = %ValidationStamp{ @@ -426,22 +392,17 @@ defmodule Archethic.TransactionFactory do movements = Transaction.get_movements(tx) resolved_addresses = Enum.map(movements, &{&1.to, &1.to}) |> Map.new() - - ledger_operations = %LedgerOperations{fee: fee} + contract_context = nil + encoded_state = nil ledger_operations = - case LedgerOperations.consume_inputs( - ledger_operations, - tx.address, - timestamp, - inputs, - movements, - LedgerOperations.get_utxos_from_transaction(tx, timestamp, protocol_version) - ) do - {:ok, ops} -> ops - _ -> ledger_operations - end - |> LedgerOperations.build_resolved_movements(movements, resolved_addresses, tx.type) + %LedgerValidation{fee: fee} + |> LedgerValidation.filter_usable_inputs(inputs, contract_context) + |> LedgerValidation.mint_token_utxos(tx, timestamp, protocol_version) + |> LedgerValidation.build_resolved_movements(movements, resolved_addresses, tx.type) + |> LedgerValidation.validate_sufficient_funds() + |> LedgerValidation.consume_inputs(tx.address, timestamp, encoded_state, contract_context) + |> LedgerValidation.to_ledger_operations() validation_stamp = %ValidationStamp{