From d12af7ea35323423b3f5e44bfc7a711ab946bfa2 Mon Sep 17 00:00:00 2001 From: victor felder Date: Thu, 21 May 2026 16:28:12 +0200 Subject: [PATCH 1/8] feat(forge_operation): return {:missing_keys, paths} errors Replace string-based validation errors with structured {:error, {:missing_keys, [paths]}} tuples. Add error_reason() type alias and update all @specs. --- lib/forge_operation.ex | 37 ++++++++++++++++++----------------- test/forge_operation_test.exs | 12 +++++++++--- test/forge_test.exs | 2 +- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/lib/forge_operation.ex b/lib/forge_operation.ex index efee084..e72711d 100644 --- a/lib/forge_operation.ex +++ b/lib/forge_operation.ex @@ -8,6 +8,8 @@ defmodule Tezex.ForgeOperation do alias Tezex.Forge + @type error_reason() :: {:missing_keys, [String.t()]} + @operation_tags %{ "endorsement" => 0, "endorsement_with_slot" => 10, @@ -57,7 +59,7 @@ defmodule Tezex.ForgeOperation do <> end - @spec operation(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec operation(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def operation(content) do case content["kind"] do "failing_noop" -> failing_noop(content) @@ -77,7 +79,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(branch contents) - @spec operation_group(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec operation_group(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def operation_group(operation_group) do with :ok <- validate_required_keys(operation_group, @keys), operations = Enum.map(operation_group["contents"], &operation/1), @@ -97,7 +99,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind pkh secret) - @spec activate_account(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec activate_account(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def activate_account(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -113,7 +115,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind source fee counter gas_limit storage_limit public_key) - @spec reveal(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec reveal(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def reveal(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -141,7 +143,7 @@ defmodule Tezex.ForgeOperation do @keys ~w(kind source fee counter gas_limit storage_limit amount destination) @params ~w(parameters parameters.entrypoint parameters.value) - @spec transaction(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec transaction(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def transaction(content) do with :ok <- validate_required_keys(content, @keys), :ok <- @@ -178,7 +180,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind source fee counter gas_limit storage_limit balance) - @spec origination(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec origination(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def origination(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -206,7 +208,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind source fee counter gas_limit storage_limit) - @spec delegation(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec delegation(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def delegation(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -232,7 +234,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind level) - @spec endorsement(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec endorsement(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def endorsement(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -247,7 +249,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(branch operations operations.kind operations.level signature) - @spec inline_endorsement(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec inline_endorsement(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def inline_endorsement(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -264,7 +266,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind endorsement slot) - @spec endorsement_with_slot(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec endorsement_with_slot(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def endorsement_with_slot(content) do with :ok <- validate_required_keys(content, @keys), {:ok, endorsement} <- inline_endorsement(content["endorsement"]) do @@ -281,7 +283,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind arbitrary) - @spec failing_noop(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec failing_noop(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def failing_noop(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -296,7 +298,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind source fee counter gas_limit storage_limit value) - @spec register_global_constant(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec register_global_constant(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def register_global_constant(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -316,7 +318,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind source fee counter gas_limit storage_limit ticket_contents ticket_ty ticket_ticketer ticket_amount destination entrypoint) - @spec transfer_ticket(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec transfer_ticket(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def transfer_ticket(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -341,7 +343,7 @@ defmodule Tezex.ForgeOperation do end @keys ~w(kind source fee counter gas_limit storage_limit message) - @spec smart_rollup_add_messages(map()) :: {:ok, nonempty_binary()} | {:error, nonempty_binary()} + @spec smart_rollup_add_messages(map()) :: {:ok, nonempty_binary()} | {:error, error_reason()} def smart_rollup_add_messages(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -367,7 +369,7 @@ defmodule Tezex.ForgeOperation do @keys ~w(kind source fee counter gas_limit storage_limit rollup cemented_commitment output_proof) @spec smart_rollup_execute_outbox_message(map()) :: - {:ok, nonempty_binary()} | {:error, nonempty_binary()} + {:ok, nonempty_binary()} | {:error, error_reason()} def smart_rollup_execute_outbox_message(content) do with :ok <- validate_required_keys(content, @keys) do content = @@ -388,7 +390,7 @@ defmodule Tezex.ForgeOperation do end end - @spec validate_required_keys(map(), list()) :: :ok | {:error, nonempty_binary()} + @spec validate_required_keys(map(), list()) :: :ok | {:error, error_reason()} def validate_required_keys(map, required_keys, acc \\ "") when is_map(map) and is_list(required_keys) do required_keys = Enum.group_by(required_keys, &String.contains?(&1, ".")) @@ -421,8 +423,7 @@ defmodule Tezex.ForgeOperation do end) end else - {:error, - "Operation content is missing required keys: #{acc}#{Enum.join(missing_keys, ", ")}"} + {:error, {:missing_keys, Enum.map(missing_keys, &(acc <> &1))}} end end end diff --git a/test/forge_operation_test.exs b/test/forge_operation_test.exs index f3dd151..09e0d5a 100644 --- a/test/forge_operation_test.exs +++ b/test/forge_operation_test.exs @@ -145,7 +145,7 @@ defmodule Tezex.ForgeOperationTest do } assert ForgeOperation.operation(Map.drop(origination, ["storage_limit"])) == - {:error, "Operation content is missing required keys: storage_limit"} + {:error, {:missing_keys, ["storage_limit"]}} {:ok, result} = ForgeOperation.operation(origination) @@ -272,7 +272,10 @@ defmodule Tezex.ForgeOperationTest do assert ForgeOperation.validate_required_keys(%{"a" => 1, "b" => 2}, ~w(a b)) == :ok assert ForgeOperation.validate_required_keys(%{"a" => 1, "b" => 2}, ~w(a b c)) == - {:error, "Operation content is missing required keys: c"} + {:error, {:missing_keys, ["c"]}} + + assert ForgeOperation.validate_required_keys(%{"a" => 1}, ~w(a b c)) == + {:error, {:missing_keys, ["b", "c"]}} assert ForgeOperation.validate_required_keys( %{"a" => %{"b" => 2, "c" => %{"d" => 3}}}, @@ -282,6 +285,9 @@ defmodule Tezex.ForgeOperationTest do assert ForgeOperation.validate_required_keys( %{"a" => %{"b" => 2, "c" => %{}}}, ~w(a a.b a.c a.c.d) - ) == {:error, "Operation content is missing required keys: a.c.d"} + ) == {:error, {:missing_keys, ["a.c.d"]}} + + assert ForgeOperation.validate_required_keys(%{"a" => %{}}, ~w(a a.b a.c)) == + {:error, {:missing_keys, ["a.b", "a.c"]}} end end diff --git a/test/forge_test.exs b/test/forge_test.exs index 174fba0..1911bec 100644 --- a/test/forge_test.exs +++ b/test/forge_test.exs @@ -193,7 +193,7 @@ defmodule Tezex.ForgeTest do "signature" => nil } - assert {:error, "Operation content is missing required keys: parameters.entrypoint"} = + assert {:error, {:missing_keys, ["parameters.entrypoint"]}} = ForgeOperation.operation_group(opg) end From 445e00a84259347124d9bc7bf29853ec28917177 Mon Sep 17 00:00:00 2001 From: victor felder Date: Thu, 21 May 2026 16:31:08 +0200 Subject: [PATCH 2/8] feat(fee): propagate forge_operation error type in calculate_fee/3 --- lib/fee.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fee.ex b/lib/fee.ex index 17facac..37bf9e7 100644 --- a/lib/fee.ex +++ b/lib/fee.ex @@ -24,7 +24,7 @@ defmodule Tezex.Fee do @spec calculate_fee(map(), pos_integer(), extra_size: pos_integer(), minimal_nanotez_per_gas_unit: pos_integer() - ) :: {:ok, pos_integer()} | {:error, nonempty_binary()} + ) :: {:ok, pos_integer()} | {:error, ForgeOperation.error_reason()} def calculate_fee(content, consumed_gas, opts \\ []) do extra_size = Keyword.get(opts, :extra_size, @extra_size) From c61aefb864b4fd434a270c23deff26c3e74f3670 Mon Sep 17 00:00:00 2001 From: victor felder Date: Thu, 21 May 2026 16:33:25 +0200 Subject: [PATCH 3/8] feat(rpc): add error types, wrap transport errors in get/post --- lib/rpc.ex | 78 ++++++++++++++++++++++++++++++----------------- test/rpc_test.exs | 5 +++ 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/lib/rpc.ex b/lib/rpc.ex index 82b2040..fcaa3ad 100644 --- a/lib/rpc.ex +++ b/lib/rpc.ex @@ -21,6 +21,19 @@ defmodule Tezex.Rpc do @type operation() :: map() @type preapplied_operations() :: map() + @type transport_error() :: + {:transport, Exception.t()} + | {:http_status, Finch.Response.t()} + | {:decode, Jason.DecodeError.t()} + + @type error_reason() :: + transport_error() + | {:missing_keys, [String.t()]} + | {:preapply_failed, list()} + | {:unexpected_response, term()} + | {:invalid_counter, term()} + | {:invalid_balance, term()} + defstruct [:endpoint, chain_id: "main", headers: [], opts: []] @spec prepare_operation( @@ -305,44 +318,40 @@ defmodule Tezex.Rpc do end end - @spec get_block(t()) :: - {:ok, map()} | {:error, Finch.Error.t()} | {:error, Jason.DecodeError.t()} - @spec get_block(t(), nonempty_binary()) :: - {:ok, map()} | {:error, Finch.Error.t()} | {:error, Jason.DecodeError.t()} + @spec get_block(t()) :: {:ok, map()} | {:error, transport_error()} + @spec get_block(t(), nonempty_binary()) :: {:ok, map()} | {:error, transport_error()} def get_block(%Rpc{} = rpc, hash \\ "head") do get(rpc, "/blocks/#{hash}") end - @spec get_block_at_offset(t(), integer()) :: - {:ok, map()} | {:error, Finch.Error.t()} | {:error, Jason.DecodeError.t()} + @spec get_block_at_offset(t(), integer()) :: {:ok, map()} | {:error, transport_error()} def get_block_at_offset(%Rpc{} = rpc, offset) do if offset <= 0 do get_block(rpc) + else + with {:ok, head} <- get_block(rpc) do + get(rpc, "/blocks/#{head["header"]["level"] - offset}") + end end - - {:ok, head} = get_block(rpc) - get(rpc, "/blocks/#{head["header"]["level"] - offset}") end - @spec inject_operation(t(), any()) :: - {:ok, any()} | {:error, Finch.Error.t()} | {:error, Jason.DecodeError.t()} + @spec inject_operation(t(), any()) :: {:ok, any()} | {:error, transport_error()} def inject_operation(%Rpc{} = rpc, payload) do post(rpc, "/injection/operation", payload) end - @spec get_balance(t(), nonempty_binary()) :: - {:ok, pos_integer()} | {:error, Finch.Error.t()} | {:error, Jason.DecodeError.t()} + @spec get_balance(t(), nonempty_binary()) :: {:ok, pos_integer()} | {:error, error_reason()} def get_balance(%Rpc{} = rpc, address) do with {:ok, balance} <- get(rpc, "/blocks/head/context/contracts/#{address}/balance") do - {:ok, String.to_integer(balance)} + case Integer.parse(balance) do + {parsed, ""} -> {:ok, parsed} + _ -> {:error, {:invalid_balance, balance}} + end end end @spec get(Tezex.Rpc.t(), nonempty_binary()) :: - {:ok, any()} - | {:error, Finch.Error.t()} - | {:error, Finch.Response.t()} - | {:error, Jason.DecodeError.t()} + {:ok, any()} | {:error, transport_error()} defp get(%Rpc{} = rpc, path) do url = URI.parse(rpc.endpoint) @@ -353,17 +362,22 @@ defmodule Tezex.Rpc do Finch.build(:get, url, rpc.headers) |> Finch.request(Tezex.Finch, rpc.opts) |> case do - {:ok, %Finch.Response{status: 200, body: body}} -> Jason.decode(body) - {:ok, resp} -> {:error, resp} - {:error, _} = err -> err + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, _} = ok -> ok + {:error, e} -> {:error, {:decode, e}} + end + + {:ok, resp} -> + {:error, {:http_status, resp}} + + {:error, e} -> + {:error, {:transport, e}} end end @spec post(Tezex.Rpc.t(), nonempty_binary(), any()) :: - {:ok, any()} - | {:error, Finch.Error.t()} - | {:error, Finch.Response.t()} - | {:error, Jason.DecodeError.t()} + {:ok, any()} | {:error, transport_error()} defp post(%Rpc{} = rpc, path, body) do url = URI.parse(rpc.endpoint) @@ -385,9 +399,17 @@ defmodule Tezex.Rpc do Finch.build(:post, url, rpc.headers, body) |> Finch.request(Tezex.Finch, rpc.opts) |> case do - {:ok, %Finch.Response{status: 200, body: body}} -> Jason.decode(body) - {:ok, resp} -> {:error, resp} - {:error, _} = err -> err + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, _} = ok -> ok + {:error, e} -> {:error, {:decode, e}} + end + + {:ok, resp} -> + {:error, {:http_status, resp}} + + {:error, e} -> + {:error, {:transport, e}} end end diff --git a/test/rpc_test.exs b/test/rpc_test.exs index 3cc4527..4542ed5 100644 --- a/test/rpc_test.exs +++ b/test/rpc_test.exs @@ -78,6 +78,11 @@ defmodule Tezex.RpcTest do assert {:ok, result} == Rpc.forge_and_sign_operation(operation, @ghostnet_1_pkey) end + test "forge_and_sign_operation/2 propagates a missing-keys error" do + assert {:error, {:missing_keys, ["branch"]}} = + Rpc.forge_and_sign_operation(%{"contents" => []}, @ghostnet_1_pkey) + end + describe "fill_operation_fee/3" do test "a contract operation" do operation_result = Tezex.OperationResultFixture.offer() From d5db0035f967a4ecc7cf9a9959a89bcf07109e9a Mon Sep 17 00:00:00 2001 From: victor felder Date: Thu, 21 May 2026 16:37:42 +0200 Subject: [PATCH 4/8] feat(rpc): tag preapply errors as {:preapply_failed,_}/{:unexpected_response,_} --- lib/rpc.ex | 13 +++++-------- test/rpc_test.exs | 33 +++++++++++++++++---------------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/lib/rpc.ex b/lib/rpc.ex index fcaa3ad..f4ff67f 100644 --- a/lib/rpc.ex +++ b/lib/rpc.ex @@ -218,7 +218,7 @@ defmodule Tezex.Rpc do Sign the forged operation and returns the forged operation+signature payload to be injected. """ @spec forge_and_sign_operation(operation(), encoded_private_key()) :: - {:ok, nonempty_binary()} | {:error, nonempty_binary()} + {:ok, nonempty_binary()} | {:error, ForgeOperation.error_reason()} def forge_and_sign_operation(operation, encoded_private_key) do with {:ok, forged_operation} <- ForgeOperation.operation_group(operation) do signature = Crypto.sign_operation(encoded_private_key, forged_operation) @@ -237,10 +237,7 @@ defmodule Tezex.Rpc do Simulate the application of the operations with the context of the given block and return the result of each operation application. """ @spec preapply_operation(t(), map(), encoded_private_key(), any()) :: - {:ok, any()} - | {:error, Finch.Error.t()} - | {:error, Jason.DecodeError.t()} - | {:error, term()} + {:ok, list()} | {:error, error_reason()} def preapply_operation(%Rpc{} = rpc, operation, encoded_private_key, protocol) do with {:ok, forged_operation} <- ForgeOperation.operation_group(operation), signature = Crypto.sign_operation(encoded_private_key, forged_operation), @@ -285,11 +282,11 @@ defmodule Tezex.Rpc do errors end - {:error, errors} + {:error, {:preapply_failed, errors}} end - {:ok, _result} -> - {:error, :preapply_failed} + {:ok, result} -> + {:error, {:unexpected_response, result}} err -> err diff --git a/test/rpc_test.exs b/test/rpc_test.exs index 4542ed5..4827aac 100644 --- a/test/rpc_test.exs +++ b/test/rpc_test.exs @@ -191,22 +191,23 @@ defmodule Tezex.RpcTest do ] assert {:error, - [ - [ - %{ - "amount" => "1000000000", - "balance" => _, - "contract" => "tz1ZW1ZSN4ruXYc3nCon8EaTXp1t3tKWb9Ew", - "id" => error_a, - "kind" => "temporary" - }, - %{ - "amounts" => [_, "1000000000"], - "id" => error_b, - "kind" => "temporary" - } - ] - ]} = + {:preapply_failed, + [ + [ + %{ + "amount" => "1000000000", + "balance" => _, + "contract" => "tz1ZW1ZSN4ruXYc3nCon8EaTXp1t3tKWb9Ew", + "id" => error_a, + "kind" => "temporary" + }, + %{ + "amounts" => [_, "1000000000"], + "id" => error_b, + "kind" => "temporary" + } + ] + ]}} = Rpc.send_operation(rpc, contents, @ghostnet_1_address, @ghostnet_1_pkey) assert String.ends_with?(error_a, "balance_too_low"), error_a From c860ecb9c8bf33c67c32fec3a49f9a450363aa90 Mon Sep 17 00:00:00 2001 From: victor felder Date: Thu, 21 May 2026 16:41:43 +0200 Subject: [PATCH 5/8] feat(rpc): normalize counter functions to {:ok, integer()} | {:error,_} --- lib/rpc.ex | 21 +++++++++------------ test/rpc_test.exs | 10 +++++----- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/lib/rpc.ex b/lib/rpc.ex index f4ff67f..519b664 100644 --- a/lib/rpc.ex +++ b/lib/rpc.ex @@ -294,24 +294,21 @@ defmodule Tezex.Rpc do end @spec get_counter_for_account(t(), nonempty_binary()) :: - integer() | {:error, :not_integer} | {:error, Finch.Error.t()} + {:ok, integer()} | {:error, error_reason()} def get_counter_for_account(%Rpc{} = rpc, address) do - with {:ok, n} <- get(rpc, "/blocks/head/context/contracts/#{address}/counter"), - {n, ""} <- Integer.parse(n) do - n - else - {:error, _} = err -> err - :error -> {:error, :not_integer} - {_, rest} when is_binary(rest) -> {:error, :not_integer} + with {:ok, n} <- get(rpc, "/blocks/head/context/contracts/#{address}/counter") do + case Integer.parse(n) do + {parsed, ""} -> {:ok, parsed} + _ -> {:error, {:invalid_counter, n}} + end end end @spec get_next_counter_for_account(t(), nonempty_binary()) :: - {:ok, integer()} | {:error, :not_integer} | {:error, Finch.Error.t()} + {:ok, integer()} | {:error, error_reason()} def get_next_counter_for_account(%Rpc{} = rpc, address) do - case get_counter_for_account(rpc, address) do - {:error, _} = err -> err - n -> {:ok, n + 1} + with {:ok, n} <- get_counter_for_account(rpc, address) do + {:ok, n + 1} end end diff --git a/test/rpc_test.exs b/test/rpc_test.exs index 4827aac..c66e8b4 100644 --- a/test/rpc_test.exs +++ b/test/rpc_test.exs @@ -11,11 +11,11 @@ defmodule Tezex.RpcTest do @tag :tezos test "get_counter_for_account" do - counter = - Rpc.get_counter_for_account( - %Rpc{endpoint: @endpoint}, - "tz1LKpeN8ZSSFNyTWiBNaE4u4sjaq7J1Vz2z" - ) + assert {:ok, counter} = + Rpc.get_counter_for_account( + %Rpc{endpoint: @endpoint}, + "tz1LKpeN8ZSSFNyTWiBNaE4u4sjaq7J1Vz2z" + ) assert is_integer(counter) end From fc38fa9127f25031b2d1ed5981ff2e781a3998cd Mon Sep 17 00:00:00 2001 From: victor felder Date: Thu, 21 May 2026 16:45:42 +0200 Subject: [PATCH 6/8] fix(rpc): fill_operation_fee/3 returns {:ok,_}|{:error,_}, propagate in send_operation --- lib/rpc.ex | 8 ++--- test/rpc_test.exs | 77 +++++++++++++++++++++++++++-------------------- 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/lib/rpc.ex b/lib/rpc.ex index 519b664..3f1e367 100644 --- a/lib/rpc.ex +++ b/lib/rpc.ex @@ -76,7 +76,7 @@ defmodule Tezex.Rpc do storage_limit: non_neg_integer(), gas_reserve: non_neg_integer(), burn_reserve: non_neg_integer() - ) :: operation() + ) :: {:ok, operation()} | {:error, ForgeOperation.error_reason()} def fill_operation_fee(operation, preapplied_operations, opts \\ []) do gas_limit = Keyword.get(opts, :gas_limit) storage_limit = Keyword.get(opts, :storage_limit) @@ -166,7 +166,7 @@ defmodule Tezex.Rpc do first_error = Enum.find(contents, &(elem(&1, 0) == :error)) if is_nil(first_error) do - %{operation | "contents" => Enum.map(contents, &elem(&1, 1))} + {:ok, %{operation | "contents" => Enum.map(contents, &elem(&1, 1))}} else first_error end @@ -196,7 +196,7 @@ defmodule Tezex.Rpc do offset: non_neg_integer(), storage_limit: non_neg_integer() ) :: - {:ok, any()} | {:error, Finch.Error.t()} | {:error, Jason.DecodeError.t()} + {:ok, any()} | {:error, error_reason()} def send_operation(%Rpc{} = rpc, transactions, wallet_address, encoded_private_key, opts \\ []) do transactions = if is_map(transactions), do: [transactions], else: transactions offset = Keyword.get(opts, :offset, 0) @@ -208,7 +208,7 @@ defmodule Tezex.Rpc do operation = prepare_operation(transactions, wallet_address, counter, branch), {:ok, preapplied_operations} <- preapply_operation(rpc, operation, encoded_private_key, protocol), - operation = fill_operation_fee(operation, preapplied_operations, opts), + {:ok, operation} <- fill_operation_fee(operation, preapplied_operations, opts), {:ok, payload} <- forge_and_sign_operation(operation, encoded_private_key) do inject_operation(rpc, payload) end diff --git a/test/rpc_test.exs b/test/rpc_test.exs index c66e8b4..4b8b8db 100644 --- a/test/rpc_test.exs +++ b/test/rpc_test.exs @@ -87,20 +87,21 @@ defmodule Tezex.RpcTest do test "a contract operation" do operation_result = Tezex.OperationResultFixture.offer() - assert %{ - "contents" => [ - %{ - "amount" => "1000000", - "counter" => "26949360", - "destination" => "KT1MFWsAXGUZ4gFkQnjByWjrrVtuQi4Tya8G", - "fee" => "651", - "gas_limit" => "2419", - "kind" => "transaction", - "source" => "tz1ZW1ZSN4ruXYc3nCon8EaTXp1t3tKWb9Ew", - "storage_limit" => "289" - } - ] - } = + assert {:ok, + %{ + "contents" => [ + %{ + "amount" => "1000000", + "counter" => "26949360", + "destination" => "KT1MFWsAXGUZ4gFkQnjByWjrrVtuQi4Tya8G", + "fee" => "651", + "gas_limit" => "2419", + "kind" => "transaction", + "source" => "tz1ZW1ZSN4ruXYc3nCon8EaTXp1t3tKWb9Ew", + "storage_limit" => "289" + } + ] + }} = Rpc.fill_operation_fee( %{ "contents" => [] @@ -112,16 +113,17 @@ defmodule Tezex.RpcTest do test "a transfer" do operation_result = Tezex.OperationResultFixture.transfer() - assert %{ - "contents" => [ - %{ - "amount" => "100", - "fee" => "285", - "gas_limit" => "269", - "storage_limit" => "100" - } - ] - } = + assert {:ok, + %{ + "contents" => [ + %{ + "amount" => "100", + "fee" => "285", + "gas_limit" => "269", + "storage_limit" => "100" + } + ] + }} = Rpc.fill_operation_fee( %{ "contents" => [] @@ -133,15 +135,24 @@ defmodule Tezex.RpcTest do test "a settle_auction" do operation_result = Tezex.OperationResultFixture.settle_auction() - assert %{ - "contents" => [ - %{ - "fee" => "2372", - "gas_limit" => "20847", - "storage_limit" => "103" - } - ] - } = Rpc.fill_operation_fee(%{"contents" => []}, operation_result) + assert {:ok, + %{ + "contents" => [ + %{ + "fee" => "2372", + "gas_limit" => "20847", + "storage_limit" => "103" + } + ] + }} = Rpc.fill_operation_fee(%{"contents" => []}, operation_result) + end + + test "propagates a missing-keys error" do + [content] = Tezex.OperationResultFixture.transfer() + invalid = [Map.delete(content, "amount")] + + assert {:error, {:missing_keys, ["amount"]}} = + Rpc.fill_operation_fee(%{"contents" => []}, invalid) end end From 4b30603287016bdbe6e39d677814c57bf2061085 Mon Sep 17 00:00:00 2001 From: victor felder Date: Thu, 21 May 2026 16:48:39 +0200 Subject: [PATCH 7/8] docs: changelog for error handling redesign --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3500b6..4c24534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## vNext +- [BREAKING][forge_operation]: error returns changed from `{:error, String.t()}` to `{:error, {:missing_keys, [String.t()]}}` (affects `validate_required_keys/3`, `operation/1`, `operation_group/1` and all per-operation builders) +- [BREAKING][fee]: `calculate_fee/3` error shape changed accordingly (propagated from `forge_operation`) +- [BREAKING][rpc]: unified all error returns to `{:error, {reason, detail}}` — transport errors are wrapped (`:transport`, `:http_status`, `:decode`); preapply returns `{:preapply_failed, errors}` / `{:unexpected_response, body}`; `fill_operation_fee/3` now returns `{:ok, operation} | {:error, _}` (previously crashed on fee errors); `get_counter_for_account/2` now returns `{:ok, integer()} | {:error, _}`; `get_balance/2` returns `{:error, {:invalid_balance, _}}` instead of raising on a non-integer body; `get_block_at_offset/2` returns `{:error, _}` instead of raising on a transport error, and now short-circuits to the head block for `offset <= 0` - [BREAKING][crypto/bls]: drop `is_` prefix from predicates (`is_zero?` → `zero?`, `is_one?` → `one?`, `is_infinity?` → `infinity?`) across `Fq`, `Fr`, `Fq2`, `Fq12`, `FqP`, `G1`, `G2` - [BREAKING][crypto/bls]: `Fq12.inv/1` and `FqP.inv/1` now return `{:ok, t} | {:error, :not_invertible}` - [BREAKING][crypto/bls]: `Fr.from_bytes/1` rejects out-of-range scalars with `:out_of_range` instead of silently reducing From d21a38eb021f9962e28aa2c8ba3d621d18d4669e Mon Sep 17 00:00:00 2001 From: victor felder Date: Thu, 21 May 2026 17:03:11 +0200 Subject: [PATCH 8/8] fix(rpc): fill_operation_fee/3 crashed on non-origination/transaction kinds --- lib/rpc.ex | 2 +- test/rpc_test.exs | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/rpc.ex b/lib/rpc.ex index 3f1e367..c64a51c 100644 --- a/lib/rpc.ex +++ b/lib/rpc.ex @@ -140,7 +140,7 @@ defmodule Tezex.Rpc do if content["kind"] in ~w(origination transaction) do {gas_limit_new + gas_reserve, storage_limit_new + burn_reserve} else - gas_limit_new + {gas_limit_new, storage_limit_new} end extra_size = 1 + div(Fee.extra_size(), number_contents) diff --git a/test/rpc_test.exs b/test/rpc_test.exs index 4b8b8db..09f8d93 100644 --- a/test/rpc_test.exs +++ b/test/rpc_test.exs @@ -154,6 +154,31 @@ defmodule Tezex.RpcTest do assert {:error, {:missing_keys, ["amount"]}} = Rpc.fill_operation_fee(%{"contents" => []}, invalid) end + + test "handles a non-origination/transaction operation kind" do + preapplied = [ + %{ + "kind" => "delegation", + "source" => "tz1ZW1ZSN4ruXYc3nCon8EaTXp1t3tKWb9Ew", + "fee" => "0", + "counter" => "1", + "gas_limit" => "1000", + "storage_limit" => "0", + "delegate" => "tz1ZW1ZSN4ruXYc3nCon8EaTXp1t3tKWb9Ew", + "metadata" => %{ + "operation_result" => %{ + "consumed_milligas" => "1000000", + "status" => "applied" + } + } + } + ] + + assert {:ok, %{"contents" => [%{"kind" => "delegation", "fee" => fee}]}} = + Rpc.fill_operation_fee(%{"contents" => []}, preapplied) + + assert is_binary(fee) + end end describe "send_operation/4" do