From 8a4e80ffd0ddcc9f14b1051d3dba844f8a474098 Mon Sep 17 00:00:00 2001 From: bchamagne <74045243+bchamagne@users.noreply.github.com> Date: Tue, 22 Aug 2023 14:43:05 +0200 Subject: [PATCH] AEIP-19 token recipients (#1214) * add recipients to token creation schema * add recipients to resupply schema * verify token recipients address & amount * wip - inconsisentencies tx movment * wip - error insufficient funds * wip * refactor consume_inputs * lint: add ledgerops.tokens_to_mint * unit test ledger operations * unit test transaction.get_movements/1 * add more unit tests * credo * Fetch in parallel * lint after feedback * Fees take token recipients into account * Lint + add token_id verification --------- Co-authored-by: Neylix --- lib/archethic/bootstrap/network_init.ex | 11 +- lib/archethic/mining/fee.ex | 60 +- .../mining/pending_transaction_validation.ex | 162 +++- lib/archethic/mining/validation_context.ex | 45 +- .../replication/transaction_validator.ex | 81 +- .../transaction_chain/transaction.ex | 114 ++- .../validation_stamp/ledger_operations.ex | 284 ++----- priv/json-schemas/token-core.json | 43 +- priv/json-schemas/token-resupply.json | 42 +- .../mining/distributed_workflow_test.exs | 7 +- test/archethic/mining/fee_test.exs | 113 +++ .../pending_transaction_validation_test.exs | 2 +- .../mining/validation_context_test.exs | 33 +- test/archethic/replication_test.exs | 1 + .../ledger_operations_test.exs | 799 +++++++++++++++++- .../transaction_chain/transaction_test.exs | 301 ++++++- test/support/transaction_factory.ex | 7 + 17 files changed, 1696 insertions(+), 409 deletions(-) diff --git a/lib/archethic/bootstrap/network_init.ex b/lib/archethic/bootstrap/network_init.ex index 794f02cfa..841448940 100644 --- a/lib/archethic/bootstrap/network_init.ex +++ b/lib/archethic/bootstrap/network_init.ex @@ -185,10 +185,15 @@ defmodule Archethic.Bootstrap.NetworkInit do operations = %LedgerOperations{ fee: Mining.get_transaction_fee(tx, 0.07, timestamp), - transaction_movements: Transaction.get_movements(tx) + transaction_movements: Transaction.get_movements(tx), + tokens_to_mint: LedgerOperations.get_utxos_from_transaction(tx, timestamp) } - |> LedgerOperations.from_transaction(tx, timestamp) - |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> LedgerOperations.consume_inputs( + tx.address, + unspent_outputs, + timestamp + ) + |> elem(1) validation_stamp = %ValidationStamp{ diff --git a/lib/archethic/mining/fee.ex b/lib/archethic/mining/fee.ex index 9385e1ae2..22a885ac9 100644 --- a/lib/archethic/mining/fee.ex +++ b/lib/archethic/mining/fee.ex @@ -15,6 +15,17 @@ defmodule Archethic.Mining.Fee do alias Archethic.TransactionChain.TransactionData.UCOLedger @unit_uco 100_000_000 + @token_creation_schema :archethic + |> Application.app_dir("priv/json-schemas/token-core.json") + |> File.read!() + |> Jason.decode!() + |> ExJsonSchema.Schema.resolve() + + @token_resupply_schema :archethic + |> Application.app_dir("priv/json-schemas/token-resupply.json") + |> File.read!() + |> Jason.decode!() + |> ExJsonSchema.Schema.resolve() @doc """ Determine the fee to paid for the given transaction @@ -97,16 +108,22 @@ defmodule Archethic.Mining.Fee do |> byte_size() end - defp get_number_recipients(%Transaction{ - data: %TransactionData{ - ledger: %Ledger{ - uco: %UCOLedger{transfers: uco_transfers}, - token: %TokenLedger{transfers: token_transfers} + defp get_number_recipients( + tx = %Transaction{ + data: %TransactionData{ + ledger: %Ledger{ + uco: %UCOLedger{transfers: uco_transfers}, + token: %TokenLedger{transfers: token_transfers} + } } } - }) do - (uco_transfers ++ token_transfers) - |> Enum.uniq_by(& &1.to) + ) do + uco_transfers_addresses = uco_transfers |> Enum.map(& &1.to) + token_transfers_addresses = token_transfers |> Enum.map(& &1.to) + token_recipients_addresses = get_token_recipients(tx) |> Enum.map(& &1["to"]) + + (uco_transfers_addresses ++ token_transfers_addresses ++ token_recipients_addresses) + |> Enum.uniq() |> length() end @@ -134,4 +151,31 @@ defmodule Archethic.Mining.Fee do defp cost_per_recipients(nb_recipients, uco_price_in_usd) do nb_recipients * (0.1 / uco_price_in_usd) end + + defp get_token_recipients(%Transaction{ + type: :token, + data: %TransactionData{content: content} + }) do + case Jason.decode(content) do + {:ok, json} -> + cond do + ExJsonSchema.Validator.valid?(@token_creation_schema, json) -> + get_token_recipients_from_json(json) + + ExJsonSchema.Validator.valid?(@token_resupply_schema, json) -> + get_token_recipients_from_json(json) + + true -> + [] + end + + {:error, _} -> + [] + end + end + + defp get_token_recipients(_tx), do: [] + + defp get_token_recipients_from_json(%{"recipients" => recipients}), do: recipients + defp get_token_recipients_from_json(_json), do: [] end diff --git a/lib/archethic/mining/pending_transaction_validation.ex b/lib/archethic/mining/pending_transaction_validation.ex index a82709b3b..9f2d70b1d 100644 --- a/lib/archethic/mining/pending_transaction_validation.ex +++ b/lib/archethic/mining/pending_transaction_validation.ex @@ -26,6 +26,7 @@ defmodule Archethic.Mining.PendingTransactionValidation do alias Archethic.SharedSecrets alias Archethic.SharedSecrets.NodeRenewal + alias Archethic.TaskSupervisor alias Archethic.TransactionChain alias Archethic.TransactionChain.Transaction alias Archethic.TransactionChain.TransactionData @@ -55,11 +56,11 @@ defmodule Archethic.Mining.PendingTransactionValidation do |> Jason.decode!() |> ExJsonSchema.Schema.resolve() - @token_schema :archethic - |> Application.app_dir("priv/json-schemas/token-core.json") - |> File.read!() - |> Jason.decode!() - |> ExJsonSchema.Schema.resolve() + @token_creation_schema :archethic + |> Application.app_dir("priv/json-schemas/token-core.json") + |> File.read!() + |> Jason.decode!() + |> ExJsonSchema.Schema.resolve() @token_resupply_schema :archethic |> Application.app_dir("priv/json-schemas/token-resupply.json") @@ -769,22 +770,28 @@ defmodule Archethic.Mining.PendingTransactionValidation do end defp verify_token_transaction(tx = %Transaction{data: %TransactionData{content: content}}) do - case Jason.decode(content) do - {:error, _} -> + with {:ok, json_token} <- Jason.decode(content), + :ok <- verify_token_creation(tx, json_token) do + verify_token_recipients(json_token) + else + {:error, %Jason.DecodeError{}} -> {:error, "Invalid token transaction - invalid JSON"} - {:ok, json_token} -> - case {ExJsonSchema.Validator.validate(@token_schema, json_token), - ExJsonSchema.Validator.validate(@token_resupply_schema, json_token)} do - {:ok, _} -> - verify_token_creation(json_token) + {:error, reason} -> + {:error, reason} + end + end - {_, :ok} -> - verify_token_resupply(tx, json_token) + defp verify_token_creation(tx, json_token) do + cond do + ExJsonSchema.Validator.valid?(@token_creation_schema, json_token) -> + verify_token_creation(json_token) - _ -> - {:error, "Invalid token transaction - neither a token creation nor a token resupply"} - end + ExJsonSchema.Validator.valid?(@token_resupply_schema, json_token) -> + verify_token_resupply(tx, json_token) + + true -> + {:error, "Invalid token transaction - neither a token creation nor a token resupply"} end end @@ -826,52 +833,133 @@ defmodule Archethic.Mining.PendingTransactionValidation do end defp verify_token_resupply(tx, %{"token_reference" => token_ref}) do - with {:ok, token_address} <- Base.decode16(token_ref, case: :mixed), - # verify same chain - {:ok, genesis_address} <- fetch_previous_tx_genesis_address(tx), - storage_nodes <- - Election.chain_storage_nodes(token_address, P2P.authorized_and_available_nodes()), - {:ok, ^genesis_address} <- - TransactionChain.fetch_genesis_address(token_address, storage_nodes), - # verify token_reference - {:ok, - %Transaction{ - data: %TransactionData{ - content: content - } - }} <- TransactionChain.fetch_transaction(token_address, storage_nodes), + # strict because there was a json schema validation before + token_address = Base.decode16!(token_ref, case: :mixed) + + storage_nodes = + Election.chain_storage_nodes(token_address, P2P.authorized_and_available_nodes()) + + # fetch in parallel the data we need + tasks = [ + Task.Supervisor.async_nolink(TaskSupervisor, fn -> + fetch_previous_tx_genesis_address(tx) + end), + Task.Supervisor.async_nolink(TaskSupervisor, fn -> + TransactionChain.fetch_genesis_address(token_address, storage_nodes) + end), + Task.Supervisor.async_nolink(TaskSupervisor, fn -> + TransactionChain.fetch_transaction(token_address, storage_nodes) + end) + ] + + # Shut down the tasks that did not reply nor exit + [tx_genesis_result, ref_genesis_result, ref_tx_result] = + Task.yield_many(tasks) + |> Enum.map(fn {task, res} -> + res || Task.shutdown(task, :brutal_kill) + end) + + with {:ok, {:ok, genesis_address}} <- tx_genesis_result, + {:ok, {:ok, ^genesis_address}} <- ref_genesis_result, + {:ok, {:ok, %Transaction{data: %TransactionData{content: content}}}} <- ref_tx_result, {:ok, reference_json_token} <- Jason.decode(content), %{"type" => "fungible", "allow_mint" => true} <- reference_json_token do :ok else + nil -> + {:error, "Timeout when fetching the reference token or the genesis address"} + + {:exit, _} -> + {:error, "Error when fetching the reference token or the genesis address"} + %{"type" => "non-fungible"} -> {:error, "Invalid token transaction - token_reference must be fungible"} %{"type" => "fungible"} -> {:error, "Invalid token transaction - token_reference does not have allow_mint: true"} - {:ok, _} -> + {:ok, {:ok, _}} -> {:error, "Invalid token transaction - token_reference is not in the same transaction chain"} - {:error, :transaction_not_exists} -> + {:ok, {:error, :transaction_not_exists}} -> {:error, "Invalid token transaction - token_reference not found"} - {:error, :invalid_transaction} -> + {:ok, {:error, :invalid_transaction}} -> {:error, "Invalid token transaction - token_reference is invalid"} - {:error, :network_issue} -> + {:ok, {:error, :network_issue}} -> {:error, "A network issue was raised, please retry later"} {:error, %Jason.DecodeError{}} -> {:error, "Invalid token transaction - token_reference exists but does not contain a valid JSON"} + end + end - :error -> - {:error, "Invalid token transaction - token_reference is not an hexadecimal"} + defp verify_token_recipients(json_token = %{"recipients" => recipients, "supply" => supply}) + when is_list(recipients) do + # resupply token transactions do not have a type, but is applied only to fungible tokens + fungible? = Map.get(json_token, "type", "fungible") == "fungible" + + %{res: res} = + Enum.reduce_while( + recipients, + %{sum: 0, token_ids: MapSet.new(), res: :ok}, + fn recipient = %{"amount" => amount}, acc = %{sum: sum, token_ids: token_ids} -> + with :ok <- validate_token_recipient_amount(amount, fungible?), + :ok <- validate_token_recipient_total(amount, sum, supply), + :ok <- validate_token_recipient_token_id(recipient, fungible?, token_ids) do + token_id = Map.get(recipient, "token_id", 0) + + new_acc = + acc + |> Map.update!(:sum, &(&1 + amount)) + |> Map.update!(:token_ids, &MapSet.put(&1, token_id)) + + {:cont, new_acc} + else + error -> {:halt, Map.put(acc, :res, error)} + end + end + ) + + res + end + + defp verify_token_recipients(_), do: :ok + + defp validate_token_recipient_amount(_, true), do: :ok + defp validate_token_recipient_amount(amount, false) when amount == @unit_uco, do: :ok + + defp validate_token_recipient_amount(_, false), + do: {:error, "Invalid token transaction - invalid amount in recipients"} + + defp validate_token_recipient_total(amount, sum, supply) when sum + amount <= supply, do: :ok + + defp validate_token_recipient_total(_, _, _), + do: {:error, "Invalid token transaction - sum of recipients' amounts is bigger than supply"} + + defp validate_token_recipient_token_id(%{"token_id" => _}, true, _), + do: + {:error, + "Invalid token transaction - recipient with token_id is now allowed on fungible token"} + + defp validate_token_recipient_token_id(_recipient, true, _), do: :ok + + defp validate_token_recipient_token_id(%{"token_id" => token_id}, false, token_ids) do + if MapSet.member?(token_ids, token_id) do + {:error, + "Invalid token transaction - recipient must have unique token_id for non fungible token"} + else + :ok end end + defp validate_token_recipient_token_id(_, false, _), + do: + {:error, "Invalid token transaction - recipient must have token_id for non fungible token"} + defp valid_collection_id?(collection) do # If an id is specified in an item of the collection, # all items must have a different specified id diff --git a/lib/archethic/mining/validation_context.ex b/lib/archethic/mining/validation_context.ex index c7f8a140a..bc5cb53c2 100644 --- a/lib/archethic/mining/validation_context.ex +++ b/lib/archethic/mining/validation_context.ex @@ -720,14 +720,13 @@ defmodule Archethic.Mining.ValidationContext do context = %__MODULE__{ transaction: tx = %Transaction{data: %TransactionData{recipients: recipients}}, previous_transaction: prev_tx, - unspent_outputs: unspent_outputs, valid_pending_transaction?: valid_pending_transaction?, validation_time: validation_time, resolved_addresses: resolved_addresses, contract_context: contract_context } ) do - ledger_operations = get_ledger_operations(context) + {sufficient_funds?, ledger_operations} = get_ledger_operations(context) resolved_recipients = resolved_recipients(recipients, resolved_addresses) @@ -744,8 +743,7 @@ defmodule Archethic.Mining.ValidationContext do get_validation_error( prev_tx, tx, - ledger_operations, - unspent_outputs, + sufficient_funds?, valid_pending_transaction?, resolved_recipients, validation_time, @@ -799,9 +797,9 @@ defmodule Archethic.Mining.ValidationContext do %LedgerOperations{ fee: fee, - transaction_movements: resolved_movements + transaction_movements: resolved_movements, + tokens_to_mint: LedgerOperations.get_utxos_from_transaction(tx, validation_time) } - |> LedgerOperations.from_transaction(tx, validation_time) |> LedgerOperations.consume_inputs( tx.address, unspent_outputs, @@ -812,18 +810,16 @@ defmodule Archethic.Mining.ValidationContext do @spec get_validation_error( previous_transaction :: nil | Transaction.t(), pending_transaction :: Transaction.t(), - ledger_operations :: LedgerOperations.t(), - unspent_outputs :: list(UnspentOutput.t()), - valid_pending_transaction :: boolean(), - resolved_addresses :: list({binary(), binary()}), + sufficient_funds? :: boolean(), + valid_pending_transaction? :: boolean(), + resolved_recipients :: list(Recipient.t()), validation_time :: DateTime.t(), contract_context :: nil | Contract.Context.t() ) :: nil | ValidationStamp.error() defp get_validation_error( prev_tx, tx, - ledger_operations, - unspent_outputs, + sufficient_funds?, valid_pending_transaction?, resolved_recipients, validation_time, @@ -833,6 +829,9 @@ defmodule Archethic.Mining.ValidationContext do not valid_pending_transaction? -> :invalid_pending_transaction + not sufficient_funds? -> + :insufficient_funds + not valid_inherit_condition?(prev_tx, tx, validation_time) -> :invalid_inherit_constraints @@ -845,9 +844,6 @@ defmodule Archethic.Mining.ValidationContext do not valid_contract_recipients?(tx, resolved_recipients, validation_time) -> :invalid_recipients_execution - not sufficient_funds?(ledger_operations, unspent_outputs) -> - :insufficient_funds - true -> nil end @@ -864,9 +860,6 @@ defmodule Archethic.Mining.ValidationContext do defp valid_contract_recipients?(tx, resolved_recipients, validation_time), do: SmartContractValidation.valid_contract_calls?(resolved_recipients, tx, validation_time) - defp sufficient_funds?(ledger_ops, inputs), - do: LedgerOperations.sufficient_funds?(ledger_ops, inputs) - #################### defp valid_inherit_condition?( prev_tx = %Transaction{data: %TransactionData{code: code}}, @@ -1153,7 +1146,6 @@ defmodule Archethic.Mining.ValidationContext do transaction: tx = %Transaction{data: %TransactionData{recipients: recipients}}, previous_transaction: prev_tx, valid_pending_transaction?: valid_pending_transaction?, - unspent_outputs: unspent_outputs, resolved_addresses: resolved_addresses, validation_time: validation_time, contract_context: contract_context @@ -1162,13 +1154,13 @@ defmodule Archethic.Mining.ValidationContext do validated_context = %{context | transaction: %{tx | validation_stamp: stamp}} resolved_recipients = resolved_recipients(recipients, resolved_addresses) + {sufficient_funds?, _} = get_ledger_operations(validated_context) expected_error = get_validation_error( prev_tx, tx, - get_ledger_operations(validated_context), - unspent_outputs, + sufficient_funds?, valid_pending_transaction?, resolved_recipients, validation_time, @@ -1236,10 +1228,15 @@ defmodule Archethic.Mining.ValidationContext do %LedgerOperations{unspent_outputs: expected_unspent_outputs} = %LedgerOperations{ fee: fee, - transaction_movements: Transaction.get_movements(tx) + transaction_movements: Transaction.get_movements(tx), + tokens_to_mint: LedgerOperations.get_utxos_from_transaction(tx, timestamp) } - |> LedgerOperations.from_transaction(tx, timestamp) - |> LedgerOperations.consume_inputs(tx.address, previous_unspent_outputs, timestamp) + |> LedgerOperations.consume_inputs( + tx.address, + previous_unspent_outputs, + timestamp + ) + |> elem(1) expected_unspent_outputs == next_unspent_outputs end diff --git a/lib/archethic/replication/transaction_validator.ex b/lib/archethic/replication/transaction_validator.ex index 3322021b2..34c34bf97 100644 --- a/lib/archethic/replication/transaction_validator.ex +++ b/lib/archethic/replication/transaction_validator.ex @@ -109,7 +109,7 @@ defmodule Archethic.Replication.TransactionValidator do with :ok <- validate_consensus(tx), :ok <- validate_validation_stamp(tx) do if chain_node? do - check_inputs(tx, inputs) + validate_inputs(tx, inputs) else :ok end @@ -311,33 +311,23 @@ defmodule Archethic.Replication.TransactionValidator do {:error, error} end - defp check_inputs( + defp validate_inputs( tx = %Transaction{address: address}, inputs ) do if address == Bootstrap.genesis_address() do :ok else - do_check_inputs(tx, inputs) - end - end - - defp do_check_inputs( - tx = %Transaction{ - validation_stamp: %ValidationStamp{ - ledger_operations: ops = %LedgerOperations{} - } - }, - inputs - ) do - with :ok <- validate_inputs(tx, inputs) do - validate_funds(ops, inputs) + do_validate_inputs(tx, inputs) end end - defp validate_inputs( + defp do_validate_inputs( tx = %Transaction{ + type: type, + address: address, validation_stamp: %ValidationStamp{ + timestamp: timestamp, ledger_operations: %LedgerOperations{ unspent_outputs: next_unspent_outputs, fee: fee, @@ -347,37 +337,36 @@ defmodule Archethic.Replication.TransactionValidator do }, inputs ) do - %LedgerOperations{unspent_outputs: expected_unspent_outputs} = - %LedgerOperations{ - fee: fee, - transaction_movements: transaction_movements - } - |> LedgerOperations.from_transaction(tx, tx.validation_stamp.timestamp) - |> LedgerOperations.consume_inputs(tx.address, inputs, tx.validation_stamp.timestamp) - - same? = - Enum.all?(next_unspent_outputs, fn %{amount: amount, from: from} -> - Enum.any?(expected_unspent_outputs, &(&1.from == from and &1.amount >= amount)) - end) - - if same? do - :ok - else - Logger.error( - "Invalid unspent outputs - got: #{inspect(next_unspent_outputs)}, expected: #{inspect(expected_unspent_outputs)}", - transaction_address: Base.encode16(tx.address), - transaction_type: tx.type - ) + case LedgerOperations.consume_inputs( + %LedgerOperations{ + fee: fee, + transaction_movements: transaction_movements, + tokens_to_mint: LedgerOperations.get_utxos_from_transaction(tx, timestamp) + }, + address, + inputs, + timestamp + ) do + {false, _} -> + {:error, :insufficient_funds} + + {true, %LedgerOperations{unspent_outputs: expected_unspent_outputs}} -> + same? = + Enum.all?(next_unspent_outputs, fn %{amount: amount, from: from} -> + Enum.any?(expected_unspent_outputs, &(&1.from == from and &1.amount >= amount)) + end) - {:error, :invalid_unspent_outputs} - end - end + if same? 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 + ) - defp validate_funds(ops = %LedgerOperations{}, inputs) do - if LedgerOperations.sufficient_funds?(ops, inputs) do - :ok - else - {:error, :insufficient_funds} + {:error, :invalid_unspent_outputs} + end end end end diff --git a/lib/archethic/transaction_chain/transaction.ex b/lib/archethic/transaction_chain/transaction.ex index 8f8a9f59b..773783611 100755 --- a/lib/archethic/transaction_chain/transaction.ex +++ b/lib/archethic/transaction_chain/transaction.ex @@ -18,6 +18,20 @@ defmodule Archethic.TransactionChain.Transaction do alias Archethic.Utils + @token_creation_schema :archethic + |> Application.app_dir("priv/json-schemas/token-core.json") + |> File.read!() + |> Jason.decode!() + |> ExJsonSchema.Schema.resolve() + + @token_resupply_schema :archethic + |> Application.app_dir("priv/json-schemas/token-resupply.json") + |> File.read!() + |> Jason.decode!() + |> ExJsonSchema.Schema.resolve() + + @unit_uco 100_000_000 + @version 2 defstruct [ @@ -425,44 +439,21 @@ defmodule Archethic.TransactionChain.Transaction do @doc """ Get the transfers and transaction movements from a transaction - - ## Examples - - iex> %Transaction{ - ...> data: %TransactionData{ - ...> ledger: %Ledger{ - ...> uco: %UCOLedger{ - ...> transfers: [ - ...> %UCOLedger.Transfer{to: "@Alice1", amount: 10} - ...> ] - ...> }, - ...> token: %TokenLedger{ - ...> transfers: [ - ...> %TokenLedger.Transfer{to: "@Alice1", amount: 3, token_address: "@BobToken", token_id: 0} - ...> ] - ...> } - ...> } - ...> } - ...> } |> Transaction.get_movements() - [ - %TransactionMovement{ - to: "@Alice1", amount: 10, type: :UCO, - }, - %TransactionMovement{ - to: "@Alice1", amount: 3, type: {:token, "@BobToken", 0}, - } - ] """ @spec get_movements(t()) :: list(TransactionMovement.t()) def get_movements(%__MODULE__{ + type: type, + address: tx_address, data: %TransactionData{ + content: content, ledger: %Ledger{ uco: %UCOLedger{transfers: uco_transfers}, token: %TokenLedger{transfers: token_transfers} } } }) do - Enum.map(uco_transfers, &%TransactionMovement{to: &1.to, amount: &1.amount, type: :UCO}) ++ + List.flatten([ + Enum.map(uco_transfers, &%TransactionMovement{to: &1.to, amount: &1.amount, type: :UCO}), Enum.map( token_transfers, &%TransactionMovement{ @@ -470,7 +461,13 @@ defmodule Archethic.TransactionChain.Transaction do amount: &1.amount, type: {:token, &1.token_address, &1.token_id} } - ) + ), + case type do + :token -> get_movements_from_token_transaction(tx_address, content) + :mint_reward -> get_movements_from_token_transaction(tx_address, content) + _ -> [] + end + ]) end @doc """ @@ -955,4 +952,63 @@ defmodule Archethic.TransactionChain.Transaction do |> Enum.map(&CrossValidationStamp.cast/1) } end + + defp get_movements_from_token_transaction(tx_address, tx_content) do + case Jason.decode(tx_content) do + {:ok, json} -> + cond do + ExJsonSchema.Validator.valid?(@token_creation_schema, json) -> + get_movements_from_token_creation(tx_address, json) + + ExJsonSchema.Validator.valid?(@token_resupply_schema, json) -> + get_movements_from_token_resupply(json) + + true -> + [] + end + + {:error, _} -> + [] + end + end + + defp get_movements_from_token_creation(tx_address, %{"recipients" => recipients, "type" => type}) do + fungible? = type == "fungible" + + Enum.map(recipients, fn recipient = %{"to" => address_hex, "amount" => amount} -> + token_id = Map.get(recipient, "token_id", 0) + address = Base.decode16!(address_hex, case: :mixed) + + if not fungible? and amount != @unit_uco do + nil + else + %TransactionMovement{ + to: address, + amount: amount, + type: {:token, tx_address, token_id} + } + end + end) + |> Enum.reject(&is_nil/1) + end + + defp get_movements_from_token_creation(_tx_address, _json), do: [] + + defp get_movements_from_token_resupply(%{ + "recipients" => recipients, + "token_reference" => token_reference + }) do + Enum.map(recipients, fn %{"to" => address_hex, "amount" => amount} -> + token_address = Base.decode16!(token_reference, case: :mixed) + address = Base.decode16!(address_hex, case: :mixed) + + %TransactionMovement{ + to: address, + amount: amount, + type: {:token, token_address, 0} + } + end) + end + + defp get_movements_from_token_resupply(_json), do: [] end 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 c8227cfcb..89a2090e7 100644 --- a/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations.ex +++ b/lib/archethic/transaction_chain/transaction/validation_stamp/ledger_operations.ex @@ -10,6 +10,7 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation defstruct transaction_movements: [], unspent_outputs: [], + tokens_to_mint: [], fee: 0 alias Archethic.Crypto @@ -32,6 +33,7 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation @type t() :: %__MODULE__{ transaction_movements: list(TransactionMovement.t()), unspent_outputs: list(UnspentOutput.t()), + tokens_to_mint: list(UnspentOutput.t()), fee: non_neg_integer() } @@ -45,94 +47,9 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation @doc ~S""" Build some ledger operations from a specific transaction - ## Examples - iex> LedgerOperations.from_transaction(%LedgerOperations{}, - ...> %Transaction{ - ...> address: "@Token2", - ...> type: :token, - ...> data: %TransactionData{content: "{\"supply\": 1000000000, \"type\": \"fungible\" }"} - ...> },~U[2022-10-10 08:07:31.784Z] - ...> ) - %LedgerOperations{ - unspent_outputs: [%UnspentOutput{from: "@Token2", amount: 1000000000, type: {:token, "@Token2", 0},timestamp: ~U[2022-10-10 08:07:31.784Z]}] - } - - iex> LedgerOperations.from_transaction(%LedgerOperations{}, - ...> %Transaction{ - ...> address: "@Token2", - ...> type: :token, - ...> data: %TransactionData{content: "{\"supply\": 1000000000, \"type\": \"non-fungible\", \"collection\": [{},{},{},{},{},{},{},{},{},{}]}"} - ...> },~U[2022-10-10 08:07:31.784Z] - ...> ) - %LedgerOperations{ - unspent_outputs: [ - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 1}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 2}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 3}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 4}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 5}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 6}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 7}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 8}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 9}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 10},timestamp: ~U[2022-10-10 08:07:31.784Z]} - ] - } - - iex> LedgerOperations.from_transaction(%LedgerOperations{}, - ...> %Transaction{ - ...> address: "@Token2", - ...> type: :token, - ...> data: %TransactionData{content: "{\"supply\": 100000000, \"type\": \"non-fungible\"}"} - ...> },~U[2022-10-10 08:07:31.784Z] - ...> ) - %LedgerOperations{ - unspent_outputs: [ - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 1}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - ] - } - - iex> LedgerOperations.from_transaction(%LedgerOperations{}, - ...> %Transaction{ - ...> address: "@Token2", - ...> type: :token, - ...> data: %TransactionData{content: "{\"supply\": 200000000, \"type\": \"non-fungible\", \"collection\": [{\"id\": 42}, {\"id\": 38}]}"} - ...> },~U[2022-10-10 08:07:31.784Z] - ...> ) - %LedgerOperations{ - unspent_outputs: [ - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 42}, timestamp: ~U[2022-10-10 08:07:31.784Z]}, - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@Token2", 38}, timestamp: ~U[2022-10-10 08:07:31.784Z]} - ] - } - - iex> LedgerOperations.from_transaction(%LedgerOperations{}, - ...> %Transaction{ - ...> address: "@Token2", - ...> type: :token, - ...> data: %TransactionData{content: "{\"supply\": 1000000000, \"type\": \"non-fungible\", \"collection\": [{}]}"} - ...> }, ~U[2022-10-10 08:07:31.784Z] - ...> ) - %LedgerOperations{ - unspent_outputs: [] - } - - iex> LedgerOperations.from_transaction(%LedgerOperations{}, - ...> %Transaction{ - ...> address: "@Token2", - ...> type: :token, - ...> data: %TransactionData{content: "{\"supply\": 100000000, \"token_reference\": \"40546F6B656E526566\", \"aeip\": [2, 18]}"} - ...> }, ~U[2022-10-10 08:07:31.784Z] - ...> ) - %LedgerOperations{ - unspent_outputs: [ - %UnspentOutput{from: "@Token2", amount: 100_000_000, type: {:token, "@TokenRef", 0}, timestamp: ~U[2022-10-10 08:07:31.784Z]} - ] - } """ - @spec from_transaction(t(), Transaction.t(), DateTime.t()) :: t() - def from_transaction( - ops = %__MODULE__{}, + @spec get_utxos_from_transaction(Transaction.t(), DateTime.t()) :: list(UnspentOutput.t()) + def get_utxos_from_transaction( %Transaction{ address: address, type: type, @@ -143,28 +60,42 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation when type in [:token, :mint_rewards] and not is_nil(timestamp) do case Jason.decode(content) do {:ok, json} -> - utxos = get_token_utxos(json, address, timestamp) - Map.update(ops, :unspent_outputs, utxos, &(utxos ++ &1)) + get_token_utxos(json, address, timestamp) _ -> - ops + [] end end - def from_transaction(ops = %__MODULE__{}, %Transaction{}, _timestamp), do: ops + def get_utxos_from_transaction(%Transaction{}, _timestamp), do: [] - defp get_token_utxos(%{"token_reference" => token_ref, "supply" => supply}, address, timestamp) do - [ - %UnspentOutput{ - from: address, - amount: supply, - type: {:token, Base.decode16!(token_ref), 0}, - timestamp: timestamp - } - ] + 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 + defp get_token_utxos( + %{"type" => "fungible", "supply" => supply}, + address, + timestamp + ) do [ %UnspentOutput{ from: address, @@ -176,7 +107,11 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation end defp get_token_utxos( - %{"type" => "non-fungible", "supply" => supply, "collection" => collection}, + %{ + "type" => "non-fungible", + "supply" => supply, + "collection" => collection + }, address, timestamp ) do @@ -321,132 +256,8 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation @doc """ Use the necessary inputs to satisfy the uco amount to spend - The remaining unspent outputs will go to the change address - - ## Examples - - # When a single unspent output is sufficient to satisfy the transaction movements - - iex> %LedgerOperations{ - ...> transaction_movements: [ - ...> %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, - ...> %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} - ...> ], - ...> fee: 40_000_000 - ...> } - ...> |> LedgerOperations.consume_inputs("@Alice2", [ - ...> %UnspentOutput{from: "@Bob3", amount: 2_000_000_000, type: :UCO,timestamp: ~U[2022-10-09 08:39:10.463Z]} - ...> ], ~U[2022-10-10 10:44:38.983Z]) - %LedgerOperations{ - transaction_movements: [ - %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, - %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} - ], - fee: 40_000_000, - unspent_outputs: [ - %UnspentOutput{from: "@Alice2", amount: 703_000_000, type: :UCO, timestamp: ~U[2022-10-10 10:44:38.983Z]} - ] - } - - # When multiple little unspent output are sufficient to satisfy the transaction movements - - iex> %LedgerOperations{ - ...> transaction_movements: [ - ...> %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, - ...> %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} - ...> ], - ...> fee: 40_000_000 - ...> } - ...> |> LedgerOperations.consume_inputs("@Alice2", [ - ...> %UnspentOutput{from: "@Bob3", amount: 500_000_000, type: :UCO}, - ...> %UnspentOutput{from: "@Tom4", amount: 700_000_000, type: :UCO}, - ...> %UnspentOutput{from: "@Christina", amount: 400_000_000, type: :UCO}, - ...> %UnspentOutput{from: "@Hugo", amount: 800_000_000, type: :UCO} - ...> ],~U[2022-10-10 10:44:38.983Z]) - %LedgerOperations{ - transaction_movements: [ - %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, - %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO}, - ], - 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]}, - ] - } - - # When using Token unspent outputs are sufficient to satisfy the transaction movements - - iex> %LedgerOperations{ - ...> transaction_movements: [ - ...> %TransactionMovement{to: "@Bob4", amount: 1_000_000_000, type: {:token, "@CharlieToken", 0}} - ...> ], - ...> fee: 40_000_000 - ...> } - ...> |> LedgerOperations.consume_inputs("@Alice2", [ - ...> %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]} - ...> ],~U[2022-10-10 10:44:38.983Z]) - %LedgerOperations{ - transaction_movements: [ - %TransactionMovement{to: "@Bob4", amount: 1_000_000_000, type: {:token, "@CharlieToken", 0}} - ], - 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]} - ] - } - - # When multiple Token unspent outputs are sufficient to satisfy the transaction movements - - iex> %LedgerOperations{ - ...> transaction_movements: [ - ...> %TransactionMovement{to: "@Bob4", amount: 1_000_000_000, type: {:token, "@CharlieToken", 0}} - ...> ], - ...> fee: 40_000_000 - ...> } - ...> |> LedgerOperations.consume_inputs("@Alice2", [ - ...> %UnspentOutput{from: "@Charlie1", amount: 200_000_000, type: :UCO}, - ...> %UnspentOutput{from: "@Bob3", amount: 500_000_000, type: {:token, "@CharlieToken", 0}}, - ...> %UnspentOutput{from: "@Hugo5", amount: 700_000_000, type: {:token, "@CharlieToken", 0}}, - ...> %UnspentOutput{from: "@Tom1", amount: 700_000_000, type: {:token, "@CharlieToken", 0}} - ...> ], ~U[2022-10-10 10:44:38.983Z]) - %LedgerOperations{ - transaction_movements: [ - %TransactionMovement{to: "@Bob4", amount: 1_000_000_000, type: {:token, "@CharlieToken", 0}} - ], - 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]} - ] - } - - # When non-fungible tokens are used as input but want to consume only a single input - - iex> %LedgerOperations{ - ...> transaction_movements: [ - ...> %TransactionMovement{to: "@Bob4", amount: 100_000_000, type: {:token, "@CharlieToken", 2}} - ...> ], - ...> fee: 40_000_000 - ...> } |> LedgerOperations.consume_inputs("@Alice2", [ - ...> %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]} - ...> ], ~U[2022-10-10 10:44:38.983Z]) - %LedgerOperations{ - fee: 40_000_000, - transaction_movements: [ - %TransactionMovement{to: "@Bob4", amount: 100_000_000, type: {:token, "@CharlieToken", 2}} - ], - unspent_outputs: [ - %UnspentOutput{from: "@Alice2", amount: 160_000_000, type: :UCO, timestamp: ~U[2022-10-10 10:44:38.983Z]}, - %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", 3}, timestamp: ~U[2022-10-09 08:39:10.463Z]} - ] - } + Also return a boolean indicating if there was sufficient funds """ @spec consume_inputs( ledger_operations :: t(), @@ -454,9 +265,17 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation inputs :: list(UnspentOutput.t() | TransactionInput.t()), timestamp :: DateTime.t() ) :: - t() - def consume_inputs(ops = %__MODULE__{}, change_address, inputs, timestamp) + {boolean(), t()} + def consume_inputs( + ops = %__MODULE__{tokens_to_mint: tokens_to_mint}, + change_address, + inputs, + timestamp + ) when is_binary(change_address) and is_list(inputs) and not is_nil(timestamp) do + # Since AEIP-19 we can consume from minted tokens + inputs = inputs ++ tokens_to_mint + if sufficient_funds?(ops, inputs) do %{uco: uco_balance, token: tokens_received} = ledger_balances(inputs) %{uco: uco_to_spend, token: tokens_to_spend} = total_to_spend(ops) @@ -477,9 +296,12 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperation ) ] - Map.update!(ops, :unspent_outputs, &(new_unspent_outputs ++ &1)) + {true, + ops + |> Map.put(:unspent_outputs, new_unspent_outputs) + |> Map.put(:tokens_to_mint, [])} else - ops + {false, ops} end end diff --git a/priv/json-schemas/token-core.json b/priv/json-schemas/token-core.json index 4aa54c71b..1e3f2fb89 100644 --- a/priv/json-schemas/token-core.json +++ b/priv/json-schemas/token-core.json @@ -1,10 +1,24 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$defs": { + "address": { + "oneOf": [ + { + "type": "string", + "pattern": "^0[0-2]0[025][0-9a-fA-F]{64}$" + }, + { + "type": "string", + "pattern": "^0[0-2]0[134][0-9a-fA-F]{128}$" + } + ] + } + }, "type": "object", "properties": { "supply": { "type": "integer", - "description": "Number of tokens to create", + "description": "Number of tokens to create (100 000 000 for 1 token if decimals=8)", "exclusiveMinimum": 0, "maximum": 1.84467440737095e19 }, @@ -38,6 +52,33 @@ "type": "boolean", "description": "This token can be resupplied later or not (AEIP-18)" }, + "recipients": { + "type": "array", + "description": "Token recipients (AEIP-19)", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "to", + "amount" + ], + "properties": { + "token_id": { + "type": "integer", + "description": "The index of the token in a collection" + }, + "to": { + "$ref": "#/$defs/address", + "description": "Recipient address" + }, + "amount": { + "type": "integer", + "minimum": 1, + "description": "Amount of tokens to sent to this recipient (100 000 000 for 1 token if decimals=8)" + } + } + } + }, "properties": { "description": "List of the global token properties", "type": "object" diff --git a/priv/json-schemas/token-resupply.json b/priv/json-schemas/token-resupply.json index e4326bce4..5b68ce468 100644 --- a/priv/json-schemas/token-resupply.json +++ b/priv/json-schemas/token-resupply.json @@ -1,6 +1,20 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", + "$defs": { + "address": { + "oneOf": [ + { + "type": "string", + "pattern": "^0[0-2]0[025][0-9a-fA-F]{64}$" + }, + { + "type": "string", + "pattern": "^0[0-2]0[134][0-9a-fA-F]{128}$" + } + ] + } + }, "properties": { "supply": { "type": "integer", @@ -16,8 +30,34 @@ } }, "token_reference": { - "type": "string", + "$ref": "#/$defs/address", "description": "Address of the fungible token to resupply" + }, + "recipients": { + "type": "array", + "description": "Token recipients (AEIP-19)", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "to", + "amount" + ], + "properties": { + "token_id": { + "type": "integer", + "description": "The index of the token in a collection" + }, + "to": { + "$ref": "#/$defs/address", + "description": "The recipient's address" + }, + "amount": { + "type": "integer", + "description": "Amount of tokens to sent to this recipient (100 000 000 for 1 token if decimals=8)" + } + } + } } }, "required": [ diff --git a/test/archethic/mining/distributed_workflow_test.exs b/test/archethic/mining/distributed_workflow_test.exs index 618cc13be..89a052429 100644 --- a/test/archethic/mining/distributed_workflow_test.exs +++ b/test/archethic/mining/distributed_workflow_test.exs @@ -1275,10 +1275,11 @@ defmodule Archethic.Mining.DistributedWorkflowTest do ledger_operations: %LedgerOperations{ fee: Fee.calculate(tx, 0.07, timestamp), - transaction_movements: Transaction.get_movements(tx) + transaction_movements: Transaction.get_movements(tx), + tokens_to_mint: LedgerOperations.get_utxos_from_transaction(tx, timestamp) } - |> LedgerOperations.from_transaction(tx, timestamp) - |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp), + |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> elem(1), protocol_version: ArchethicCase.current_protocol_version() } end diff --git a/test/archethic/mining/fee_test.exs b/test/archethic/mining/fee_test.exs index 56bf22d1e..a37ac9272 100644 --- a/test/archethic/mining/fee_test.exs +++ b/test/archethic/mining/fee_test.exs @@ -1,6 +1,8 @@ defmodule Archethic.Mining.FeeTest do use ArchethicCase + import ArchethicCase + alias Archethic.Mining.Fee alias Archethic.P2P @@ -91,6 +93,117 @@ defmodule Archethic.Mining.FeeTest do |> Fee.calculate(2.0, DateTime.utc_now()) end + test "should take token unique recipients into account (token creation)" do + address1 = random_address() + # 0.21 UCO for 4 recipients (3 unique in content + 1 in ledger) + 1 token at $2.0 + assert 21_016_950 == + %Transaction{ + address: <<0::8, :crypto.strong_rand_bytes(32)::binary>>, + type: :token, + data: %TransactionData{ + content: """ + { + "aeip": [2, 8, 19], + "supply": 300000000, + "type": "fungible", + "name": "My token", + "symbol": "MTK", + "properties": {}, + "recipients": [ + { + "to": "#{Base.encode16(address1)}", + "amount": 100000000 + }, + { + "to": "#{Base.encode16(address1)}", + "amount": 100000000 + }, + { + "to": "#{Base.encode16(random_address())}", + "amount": 100000000 + }, + { + "to": "#{Base.encode16(random_address())}", + "amount": 100000000 + } + ] + } + """, + ledger: %Ledger{ + uco: %UCOLedger{ + transfers: [ + %Transfer{ + amount: 100_000_000, + to: <<0::8, :crypto.strong_rand_bytes(32)::binary>> + } + ] + } + } + }, + previous_public_key: <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>>, + previous_signature: :crypto.strong_rand_bytes(32), + origin_signature: :crypto.strong_rand_bytes(32) + } + |> Fee.calculate(2.0, DateTime.utc_now()) + end + + test "should take token unique recipients into account (token resupply)" do + # 0.11 UCO for 2 recipients + 1 token at $2.0 + assert 11_010_100 == + %Transaction{ + address: <<0::8, :crypto.strong_rand_bytes(32)::binary>>, + type: :token, + data: %TransactionData{ + content: """ + { + "aeip": [8, 18], + "supply": 1000, + "token_reference": "0000C13373C96538B468CCDAB8F95FDC3744EBFA2CD36A81C3791B2A205705D9C3A2", + "recipients": [ + { + "to": "#{Base.encode16(random_address())}", + "amount": 100000000 + }, + { + "to": "#{Base.encode16(random_address())}", + "amount": 100000000 + } + ] + } + """ + }, + previous_public_key: <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>>, + previous_signature: :crypto.strong_rand_bytes(32), + origin_signature: :crypto.strong_rand_bytes(32) + } + |> Fee.calculate(2.0, DateTime.utc_now()) + end + + test "should pay additional fee for tokens without recipient" do + # 0.01 UCO for 0 transfer + 1 token at $2.0 + assert 1_003_524 == + %Transaction{ + address: <<0::8, :crypto.strong_rand_bytes(32)::binary>>, + type: :token, + data: %TransactionData{ + content: """ + { + "aeip": [2, 8, 19], + "supply": 300000000, + "type": "fungible", + "name": "My token", + "symbol": "MTK", + "properties": {} + } + """ + }, + previous_public_key: <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>>, + previous_signature: :crypto.strong_rand_bytes(32), + origin_signature: :crypto.strong_rand_bytes(32) + } + |> Fee.calculate(2.0, DateTime.utc_now()) + end + test "should decrease the fee when the amount stays the same but the price of UCO increases" do # 0.00501425 UCO for 1 UCO at $ 2.0 assert 501_425 = diff --git a/test/archethic/mining/pending_transaction_validation_test.exs b/test/archethic/mining/pending_transaction_validation_test.exs index 9807cb1b4..6e1a19b37 100644 --- a/test/archethic/mining/pending_transaction_validation_test.exs +++ b/test/archethic/mining/pending_transaction_validation_test.exs @@ -1349,7 +1349,7 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do 0 ) - assert {:error, "Invalid token transaction - token_reference is not an hexadecimal"} = + assert {:error, "Invalid token transaction - neither a token creation nor a token resupply"} = PendingTransactionValidation.validate(tx) end diff --git a/test/archethic/mining/validation_context_test.exs b/test/archethic/mining/validation_context_test.exs index de9a63620..451f50f11 100644 --- a/test/archethic/mining/validation_context_test.exs +++ b/test/archethic/mining/validation_context_test.exs @@ -59,8 +59,7 @@ defmodule Archethic.Mining.ValidationContextTest do validation_context = %ValidationContext{ create_context() - | unspent_outputs: [], - resolved_addresses: [ + | resolved_addresses: [ {contract_address1, latest_contract_address}, {contract_address2, latest_contract_address} ], @@ -292,10 +291,11 @@ defmodule Archethic.Mining.ValidationContextTest do ledger_operations: %LedgerOperations{ fee: Fee.calculate(tx, 0.07, timestamp), - transaction_movements: Transaction.get_movements(tx) + transaction_movements: Transaction.get_movements(tx), + tokens_to_mint: LedgerOperations.get_utxos_from_transaction(tx, timestamp) } - |> LedgerOperations.from_transaction(tx, timestamp) - |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp), + |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> elem(1), signature: :crypto.strong_rand_bytes(32), protocol_version: ArchethicCase.current_protocol_version() } @@ -314,10 +314,11 @@ defmodule Archethic.Mining.ValidationContextTest do ledger_operations: %LedgerOperations{ fee: Fee.calculate(tx, 0.07, timestamp), - transaction_movements: Transaction.get_movements(tx) + transaction_movements: Transaction.get_movements(tx), + tokens_to_mint: LedgerOperations.get_utxos_from_transaction(tx, timestamp) } - |> LedgerOperations.from_transaction(tx, timestamp) - |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp), + |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> elem(1), protocol_version: ArchethicCase.current_protocol_version() } |> ValidationStamp.sign() @@ -336,10 +337,11 @@ defmodule Archethic.Mining.ValidationContextTest do ledger_operations: %LedgerOperations{ fee: Fee.calculate(tx, 0.07, timestamp), - transaction_movements: Transaction.get_movements(tx) + transaction_movements: Transaction.get_movements(tx), + tokens_to_mint: LedgerOperations.get_utxos_from_transaction(tx, timestamp) } - |> LedgerOperations.from_transaction(tx, timestamp) - |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp), + |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> elem(1), protocol_version: ArchethicCase.current_protocol_version() } |> ValidationStamp.sign() @@ -360,7 +362,8 @@ defmodule Archethic.Mining.ValidationContextTest do fee: 2_020_000_000, transaction_movements: Transaction.get_movements(tx) } - |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp), + |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> elem(1), protocol_version: ArchethicCase.current_protocol_version() } |> ValidationStamp.sign() @@ -393,7 +396,8 @@ defmodule Archethic.Mining.ValidationContextTest do } ] } - |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp), + |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> elem(1), protocol_version: ArchethicCase.current_protocol_version() } |> ValidationStamp.sign() @@ -441,7 +445,8 @@ defmodule Archethic.Mining.ValidationContextTest do fee: Fee.calculate(tx, 0.07, timestamp), transaction_movements: Transaction.get_movements(tx) } - |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp), + |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> elem(1), error: :invalid_pending_transaction, protocol_version: ArchethicCase.current_protocol_version() } diff --git a/test/archethic/replication_test.exs b/test/archethic/replication_test.exs index 0e4d2b02a..81826efe7 100644 --- a/test/archethic/replication_test.exs +++ b/test/archethic/replication_test.exs @@ -271,6 +271,7 @@ defmodule Archethic.ReplicationTest do fee: Fee.calculate(tx, 0.07, timestamp) } |> LedgerOperations.consume_inputs(tx.address, unspent_outputs, timestamp) + |> elem(1) validation_stamp = %ValidationStamp{ 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 1d044d23a..b8ca20a54 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,16 +1,801 @@ defmodule Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperationsTest do - use ArchethicCase - - import ArchethicCase, only: [current_protocol_version: 0] - use ExUnitProperties - - alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionFactory alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.TransactionMovement alias Archethic.TransactionChain.Transaction.ValidationStamp.LedgerOperations.UnspentOutput - alias Archethic.TransactionChain.TransactionData + + use ArchethicCase + import ArchethicCase doctest LedgerOperations + + 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() + ) + 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() + ) + + assert [] = + LedgerOperations.get_utxos_from_transaction( + TransactionFactory.create_valid_transaction([], type: :token, content: "{}"), + DateTime.utc_now() + ) + 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) + 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) + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": {"foo": "bar"}, + "supply": 1000000 + } + """ + ) + + assert [] = LedgerOperations.get_utxos_from_transaction(tx, now) + + 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) + 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) + 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 + + assert [ + %UnspentOutput{ + amount: 100_000_000, + from: ^tx_address, + type: {:token, ^tx_address, 1}, + timestamp: ^now + } + ] = LedgerOperations.get_utxos_from_transaction(tx, now) + 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 + + assert [ + %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 + } + ] = LedgerOperations.get_utxos_from_transaction(tx, now) + 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) + 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) + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 100000000 + } + """ + ) + + assert [] = LedgerOperations.get_utxos_from_transaction(tx, now) + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "type": "fungible" + } + """ + ) + + assert [] = LedgerOperations.get_utxos_from_transaction(tx, now) + end + end + + describe "consume_inputs/4" do + test "When a single unspent output is sufficient to satisfy the transaction movements" do + assert %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, + %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} + ], + fee: 40_000_000, + unspent_outputs: [ + %UnspentOutput{ + from: "@Alice2", + amount: 703_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + } + ] + } = + LedgerOperations.consume_inputs( + %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, + %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} + ], + fee: 40_000_000 + }, + "@Alice2", + [ + %UnspentOutput{ + from: "@Bob3", + amount: 2_000_000_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + ~U[2022-10-10 10:44:38.983Z] + ) + |> elem(1) + end + + test "When multiple little unspent output are sufficient to satisfy the transaction movements" do + assert %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, + %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} + ], + 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] + } + ] + } = + %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{to: "@Bob4", amount: 1_040_000_000, type: :UCO}, + %TransactionMovement{to: "@Charlie2", amount: 217_000_000, type: :UCO} + ], + fee: 40_000_000 + } + |> LedgerOperations.consume_inputs( + "@Alice2", + [ + %UnspentOutput{from: "@Bob3", amount: 500_000_000, type: :UCO}, + %UnspentOutput{from: "@Tom4", amount: 700_000_000, type: :UCO}, + %UnspentOutput{from: "@Christina", amount: 400_000_000, type: :UCO}, + %UnspentOutput{from: "@Hugo", amount: 800_000_000, type: :UCO} + ], + ~U[2022-10-10 10:44:38.983Z] + ) + |> elem(1) + end + + test "When using Token unspent outputs are sufficient to satisfy the transaction movements" do + assert %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{ + to: "@Bob4", + amount: 1_000_000_000, + type: {:token, "@CharlieToken", 0} + } + ], + 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] + } + ] + } = + %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{ + to: "@Bob4", + amount: 1_000_000_000, + type: {:token, "@CharlieToken", 0} + } + ], + fee: 40_000_000 + } + |> LedgerOperations.consume_inputs( + "@Alice2", + [ + %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] + } + ], + ~U[2022-10-10 10:44:38.983Z] + ) + |> elem(1) + end + + test "When multiple Token unspent outputs are sufficient to satisfy the transaction movements" do + assert %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{ + to: "@Bob4", + amount: 1_000_000_000, + type: {:token, "@CharlieToken", 0} + } + ], + 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] + } + ] + } = + %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{ + to: "@Bob4", + amount: 1_000_000_000, + type: {:token, "@CharlieToken", 0} + } + ], + fee: 40_000_000 + } + |> LedgerOperations.consume_inputs( + "@Alice2", + [ + %UnspentOutput{from: "@Charlie1", amount: 200_000_000, type: :UCO}, + %UnspentOutput{ + from: "@Bob3", + amount: 500_000_000, + type: {:token, "@CharlieToken", 0} + }, + %UnspentOutput{ + from: "@Hugo5", + amount: 700_000_000, + type: {:token, "@CharlieToken", 0} + }, + %UnspentOutput{ + from: "@Tom1", + amount: 700_000_000, + type: {:token, "@CharlieToken", 0} + } + ], + ~U[2022-10-10 10:44:38.983Z] + ) + |> elem(1) + end + + test "When non-fungible tokens are used as input but want to consume only a single input" do + assert %LedgerOperations{ + fee: 40_000_000, + transaction_movements: [ + %TransactionMovement{ + to: "@Bob4", + amount: 100_000_000, + type: {:token, "@CharlieToken", 2} + } + ], + unspent_outputs: [ + %UnspentOutput{ + from: "@Alice2", + amount: 160_000_000, + type: :UCO, + timestamp: ~U[2022-10-10 10:44:38.983Z] + }, + %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", 3}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] + } = + %LedgerOperations{ + transaction_movements: [ + %TransactionMovement{ + to: "@Bob4", + amount: 100_000_000, + type: {:token, "@CharlieToken", 2} + } + ], + fee: 40_000_000 + } + |> LedgerOperations.consume_inputs( + "@Alice2", + [ + %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] + } + ], + ~U[2022-10-10 10:44:38.983Z] + ) + |> elem(1) + end + + test "should return insufficient funds when not enough uco" do + ops = %LedgerOperations{fee: 1_000} + assert {false, _} = LedgerOperations.consume_inputs(ops, "@Alice", [], DateTime.utc_now()) + end + + test "should return insufficient funds when not enough tokens" do + ops = %LedgerOperations{ + fee: 1_000, + transaction_movements: [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@CharlieToken", 0} + } + ] + } + + assert {false, _} = + LedgerOperations.consume_inputs( + ops, + "@Alice", + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + DateTime.utc_now() + ) + end + + test "should be able to pay with the minted fungible tokens" do + now = DateTime.utc_now() + + ops = %LedgerOperations{ + fee: 1_000, + tokens_to_mint: [ + %UnspentOutput{ + from: "@Bob", + amount: 100_000_000, + type: {:token, "@Token", 0}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + transaction_movements: [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 50_000_000, + type: {:token, "@Token", 0} + } + ] + } + + assert {true, ops_result} = + LedgerOperations.consume_inputs( + ops, + "@Alice", + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + now + ) + + assert [ + # I don't like utxo of amount=0 + %UnspentOutput{ + from: "@Alice", + amount: 0, + type: :UCO, + timestamp: ^now + }, + %UnspentOutput{ + from: "@Alice", + amount: 50_000_000, + type: {:token, "@Token", 0}, + timestamp: ^now + } + ] = ops_result.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, + tokens_to_mint: [ + %UnspentOutput{ + from: "@Bob", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + transaction_movements: [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@Token", 1} + } + ] + } + + assert {true, ops_result} = + LedgerOperations.consume_inputs( + ops, + "@Alice", + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + now + ) + + assert [ + # I don't like utxo of amount=0 + %UnspentOutput{ + from: "@Alice", + amount: 0, + type: :UCO, + timestamp: ^now + } + ] = ops_result.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, + tokens_to_mint: [ + %UnspentOutput{ + from: "@Bob", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + }, + %UnspentOutput{ + from: "@Bob", + amount: 100_000_000, + type: {:token, "@Token", 2}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + transaction_movements: [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@Token", 2} + } + ] + } + + assert {true, ops_result} = + LedgerOperations.consume_inputs( + ops, + "@Alice", + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + now + ) + + assert [ + # I don't like utxo of amount=0 + %UnspentOutput{ + from: "@Alice", + amount: 0, + type: :UCO, + timestamp: ^now + }, + %UnspentOutput{ + from: "@Bob", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ] = ops_result.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, + tokens_to_mint: [ + %UnspentOutput{ + from: "@Bob", + amount: 100_000_000, + type: {:token, "@Token", 1}, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + transaction_movements: [ + %TransactionMovement{ + to: "@JeanClaude", + amount: 100_000_000, + type: {:token, "@Token", 1} + }, + %TransactionMovement{ + to: "@JeanBob", + amount: 100_000_000, + type: {:token, "@Token", 1} + } + ] + } + + assert {false, _} = + LedgerOperations.consume_inputs( + ops, + "@Alice", + [ + %UnspentOutput{ + from: "@Charlie1", + amount: 1_000, + type: :UCO, + timestamp: ~U[2022-10-09 08:39:10.463Z] + } + ], + now + ) + end + end end diff --git a/test/archethic/transaction_chain/transaction_test.exs b/test/archethic/transaction_chain/transaction_test.exs index 10b230234..74b71fc75 100644 --- a/test/archethic/transaction_chain/transaction_test.exs +++ b/test/archethic/transaction_chain/transaction_test.exs @@ -2,7 +2,7 @@ defmodule Archethic.TransactionChain.TransactionTest do @moduledoc false use ArchethicCase, async: false - import ArchethicCase, only: [current_transaction_version: 0, current_protocol_version: 0] + import ArchethicCase alias Archethic.Crypto alias Archethic.TransactionChain.Transaction @@ -82,8 +82,8 @@ defmodule Archethic.TransactionChain.TransactionTest do tx = TransactionFactory.create_valid_transaction() keys = [ - [create_random_key(), Crypto.first_node_public_key()], - [create_random_key(), create_random_key()] + [random_public_key(), Crypto.first_node_public_key()], + [random_public_key(), random_public_key()] ] assert Transaction.valid_stamps_signature?(tx, keys) @@ -117,5 +117,298 @@ defmodule Archethic.TransactionChain.TransactionTest do end end - defp create_random_key(), do: <<0::8, 0::8, :crypto.strong_rand_bytes(32)::binary>> + describe "get_movements/1 ledgers" do + test "should return the ledgers" do + assert [ + %TransactionMovement{ + to: "@Alice1", + amount: 10, + type: :UCO + }, + %TransactionMovement{ + to: "@Alice1", + amount: 3, + type: {:token, "@BobToken", 0} + } + ] = + Transaction.get_movements(%Transaction{ + data: %TransactionData{ + ledger: %Ledger{ + uco: %UCOLedger{ + transfers: [ + %UCOLedger.Transfer{to: "@Alice1", amount: 10} + ] + }, + token: %TokenLedger{ + transfers: [ + %TokenLedger.Transfer{ + to: "@Alice1", + amount: 3, + token_address: "@BobToken", + token_id: 0 + } + ] + } + } + } + }) + end + end + + describe "get_movements/1 token resupply transaction" do + test "should return the movements for a fungible token" do + recipient1 = random_address() + recipient1_hex = recipient1 |> Base.encode16() + recipient2 = random_address() + recipient2_hex = recipient2 |> Base.encode16() + token = random_address() + token_hex = token |> Base.encode16() + + assert [ + %TransactionMovement{ + to: ^recipient1, + amount: 1_000, + type: {:token, ^token, 0} + }, + %TransactionMovement{ + to: ^recipient2, + amount: 2_000, + type: {:token, ^token, 0} + } + ] = + Transaction.get_movements( + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": "#{token_hex}", + "supply": 1000000, + "recipients": [{ + "to": "#{recipient1_hex}", + "amount": 1000 + }, + { + "to": "#{recipient2_hex}", + "amount": 2000 + }] + } + """ + ) + ) + end + + test "should return an empty list if no recipients" do + token = random_address() + token_hex = token |> Base.encode16() + + assert [] = + Transaction.get_movements( + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": "#{token_hex}", + "supply": 1000000 + } + """ + ) + ) + end + + test "should return an empty list if invalid transaction" do + token = random_address() + token_hex = token |> Base.encode16() + recipient1 = random_address() + recipient1_hex = recipient1 |> Base.encode16() + + assert [] = + Transaction.get_movements( + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": "#{token_hex}" + } + """ + ) + ) + + assert [] = + Transaction.get_movements( + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "token_reference": "not an hexadecimal", + "supply": 100000000, + "recipients": [{ + "to": "#{recipient1_hex}", + "amount": 1000 + }] + } + """ + ) + ) + + assert [] = + Transaction.get_movements( + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 1000000 + } + """ + ) + ) + end + end + + describe "get_movements/1 token creation transaction" do + test "should return the movements for a fungible token" do + recipient1 = random_address() + recipient1_hex = recipient1 |> Base.encode16() + recipient2 = random_address() + recipient2_hex = recipient2 |> Base.encode16() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "aeip": [2, 8, 19], + "supply": 300000000, + "type": "fungible", + "name": "My token", + "symbol": "MTK", + "properties": {}, + "recipients": [{ + "to": "#{recipient1_hex}", + "amount": 1000 + }, + { + "to": "#{recipient2_hex}", + "amount": 2000 + }] + } + """ + ) + + tx_address = tx.address + + assert [ + %TransactionMovement{ + to: ^recipient1, + amount: 1_000, + type: {:token, ^tx_address, 0} + }, + %TransactionMovement{ + to: ^recipient2, + amount: 2_000, + type: {:token, ^tx_address, 0} + } + ] = Transaction.get_movements(tx) + end + + test "should return the movements for a non-fungible token" do + recipient1 = random_address() + recipient1_hex = recipient1 |> Base.encode16() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 100000000, + "type": "non-fungible", + "name": "My NFT", + "symbol": "MNFT", + "recipients": [{ + "to": "#{recipient1_hex}", + "amount": 100000000, + "token_id": 1 + }] + } + """ + ) + + tx_address = tx.address + + assert [ + %TransactionMovement{ + to: ^recipient1, + amount: 100_000_000, + type: {:token, ^tx_address, 1} + } + ] = Transaction.get_movements(tx) + end + + test "should return the movements for a non-fungible token (collection)" do + recipient1 = random_address() + recipient1_hex = recipient1 |> Base.encode16() + + 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" + } + ], + "recipients": [{ + "to": "#{recipient1_hex}", + "amount": 100000000, + "token_id": 3 + }] + } + """ + ) + + tx_address = tx.address + + assert [ + %TransactionMovement{ + to: ^recipient1, + amount: 100_000_000, + type: {:token, ^tx_address, 3} + } + ] = Transaction.get_movements(tx) + end + + test "should return an empty list when trying to send a fraction of a non-fungible" do + recipient1 = random_address() + recipient1_hex = recipient1 |> Base.encode16() + + tx = + TransactionFactory.create_valid_transaction([], + type: :token, + content: """ + { + "supply": 100000000, + "type": "non-fungible", + "name": "My NFT", + "symbol": "MNFT", + "recipients": [{ + "to": "#{recipient1_hex}", + "amount": 1 + }] + } + """ + ) + + assert [] = Transaction.get_movements(tx) + end + end end diff --git a/test/support/transaction_factory.ex b/test/support/transaction_factory.ex index ecf5ffbdb..7b3a332fe 100644 --- a/test/support/transaction_factory.ex +++ b/test/support/transaction_factory.ex @@ -64,6 +64,7 @@ defmodule Archethic.TransactionFactory do transaction_movements: Transaction.get_movements(tx) } |> LedgerOperations.consume_inputs(tx.address, inputs, timestamp) + |> elem(1) validation_stamp = %ValidationStamp{ @@ -103,6 +104,7 @@ defmodule Archethic.TransactionFactory do fee: Fee.calculate(tx, 0.07, timestamp) } |> LedgerOperations.consume_inputs(tx.address, inputs, timestamp) + |> elem(1) validation_stamp = %ValidationStamp{ @@ -137,6 +139,7 @@ defmodule Archethic.TransactionFactory do fee: Fee.calculate(tx, 0.07, timestamp) } |> LedgerOperations.consume_inputs(tx.address, inputs, timestamp) + |> elem(1) validation_stamp = %ValidationStamp{ timestamp: timestamp, @@ -172,6 +175,7 @@ defmodule Archethic.TransactionFactory do fee: Fee.calculate(tx, 0.07, timestamp) } |> LedgerOperations.consume_inputs(tx.address, inputs, timestamp) + |> elem(1) validation_stamp = %ValidationStamp{ timestamp: timestamp, @@ -201,6 +205,7 @@ defmodule Archethic.TransactionFactory do fee: 1_000_000_000 } |> LedgerOperations.consume_inputs(tx.address, inputs, timestamp) + |> elem(1) validation_stamp = %ValidationStamp{ @@ -235,6 +240,7 @@ defmodule Archethic.TransactionFactory do ] } |> LedgerOperations.consume_inputs(tx.address, inputs, timestamp) + |> elem(1) validation_stamp = %ValidationStamp{ @@ -284,6 +290,7 @@ defmodule Archethic.TransactionFactory do transaction_movements: Transaction.get_movements(tx) } |> LedgerOperations.consume_inputs(tx.address, inputs, timestamp) + |> elem(1) validation_stamp = %ValidationStamp{