Current section
Files
Jump to
Current section
Files
lib/sutra/cardano/transaction/tx_builder/internal.ex
defmodule Sutra.Cardano.Transaction.TxBuilder.Internal do
@moduledoc """
Internal function for Tx Builder
"""
require Sutra.Cardano.Script
alias Sutra.Blake2b
alias Sutra.Cardano.Address
alias Sutra.Cardano.Asset
alias Sutra.Cardano.Gov.CostModels
alias Sutra.Cardano.Script
alias Sutra.Cardano.Transaction
alias Sutra.Cardano.Transaction.Input
alias Sutra.Cardano.Transaction.Output
alias Sutra.Cardano.Transaction.TxBody
alias Sutra.Cardano.Transaction.TxBuilder
alias Sutra.Cardano.Transaction.TxBuilder.Collateral
alias Sutra.Cardano.Transaction.TxBuilder.Error.ScriptEvaluationFailed
alias Sutra.Cardano.Transaction.TxBuilder.TxConfig
alias Sutra.Cardano.Transaction.Witness
alias Sutra.Cardano.Transaction.Witness.PlutusData
alias Sutra.Cardano.Transaction.Witness.Redeemer
alias Sutra.Cardano.Transaction.Witness.VkeyWitness
alias Sutra.CoinSelection
alias Sutra.CoinSelection.LargestFirst
alias Sutra.Common.ExecutionUnitPrice
alias Sutra.Data.Plutus.PList
alias Sutra.ProtocolParams
alias Sutra.SlotConfig
alias Sutra.Uplc
alias Sutra.Utils
import Sutra.Utils, only: [maybe: 3]
@ref_script_size_increment 25_600
@ref_script_multiplier 1.2
def process_build_tx(_, [], _), do: {:error, :missing_wallet_utxos}
def process_build_tx(
%TxBuilder{} = builder_info,
[%Input{} | _] = wallet_inputs,
collateral_inputs
) do
wallet_inputs =
wallet_inputs -- (collateral_inputs ++ builder_info.ref_inputs)
sorted_txbuilder = %TxBuilder{
builder_info
| inputs: Input.sort_inputs(builder_info.inputs),
ref_inputs: Input.sort_inputs(builder_info.ref_inputs),
outputs: alter_outputs_with_min_ada(builder_info)
}
sorted_txbuilder
|> init_txbody(collateral_inputs)
|> create_tx(builder_info, wallet_inputs, init_witness(sorted_txbuilder))
end
defp init_witness(%TxBuilder{} = builder) do
%Witness{
redeemer:
with_mint_redeemers([], builder)
|> with_cert_redeemers(builder)
|> with_reward_redeemers(builder),
script_witness: Map.values(builder.script_lookup) |> Enum.filter(&Script.is_script/1),
plutus_data: Enum.map(builder.plutus_data, fn {_, v} -> %PlutusData{value: v} end),
vkey_witness: []
}
end
defp with_mint_redeemers(initial_witness, %TxBuilder{} = tx_builder)
when map_size(tx_builder.mints) == 0,
do: initial_witness
defp with_mint_redeemers(initial_witness, %TxBuilder{
mints: mint_info,
redeemer_lookup: redeemer_lookup
}) do
sorted_mints = Sutra.Utils.with_sorted_indexed_map(mint_info)
Enum.reduce(sorted_mints, initial_witness, fn {k, indexed_mint_info}, acc ->
case Map.get(redeemer_lookup, {:mint, k}) do
# Witness is from NativeScript Redeemer is not needed
nil ->
acc
redeemer_data ->
[Witness.init_redeemer(indexed_mint_info[:index], redeemer_data) | acc]
end
end)
end
defp with_cert_redeemers(initial_witness, %TxBuilder{certificates: certs}) do
Enum.reduce(Enum.with_index(certs), initial_witness, fn {{_cert, redeemer}, indx}, acc ->
if is_nil(redeemer), do: acc, else: [Witness.init_redeemer(indx, redeemer, :cert) | acc]
end)
end
defp with_reward_redeemers(initial_witness, %TxBuilder{
withdrawals: withdrawals,
redeemer_lookup: redeemer_lookup
})
when map_size(redeemer_lookup) > 0 and map_size(withdrawals) > 0 do
sorted_withdrawls = Sutra.Utils.with_sorted_indexed_map(withdrawals)
Enum.reduce(sorted_withdrawls, initial_witness, fn {k, indexed_info}, acc ->
case Map.get(redeemer_lookup, {:reward, k}) do
nil ->
acc
redeemer_data ->
[Witness.init_redeemer(indexed_info[:index], redeemer_data, :reward) | acc]
end
end)
end
defp with_reward_redeemers(initial_witness, _), do: initial_witness
defp with_spend_redeemers(%TxBuilder{} = builder, inputs) do
Enum.reduce(Enum.with_index(inputs), [], fn {%Input{} = input, indx}, acc ->
if Address.script_address?(input.output.address) do
redeemer = %Redeemer{
index: indx,
data: Map.get(builder.redeemer_lookup, {:spend, Input.extract_ref(input)}),
## placeholder for initial Exunits will be updated later after script Evaluation
exunits: {0, 0},
tag: :spend
}
[redeemer | acc]
else
acc
end
end)
end
defp total_ref_bytes(inputs, initial \\ 0) when is_list(inputs) do
Enum.reduce(inputs, initial, fn %Input{output: %Output{} = output}, acc ->
size =
if is_binary(output.reference_script), do: byte_size(output.reference_script), else: 0
acc + size
end)
end
defp calculate_refscript_fee(protocol_params, total_bytes, fee \\ 0)
defp calculate_refscript_fee(_, 0, fee), do: fee
defp calculate_refscript_fee(ref_script_cost_per_byte, total_bytes, fee)
when total_bytes > 0 do
if total_bytes < @ref_script_size_increment do
fee + ref_script_cost_per_byte * total_bytes
else
new_ref_script_cost = @ref_script_multiplier * ref_script_cost_per_byte
calculate_refscript_fee(
new_ref_script_cost,
total_bytes - @ref_script_size_increment,
fee + new_ref_script_cost
)
end
end
defp init_txbody(
%TxBuilder{
valid_from: valid_from,
valid_to: valid_to,
config: %TxConfig{slot_config: slot_config}
} = builder,
collateral_inputs
) do
initial_fee =
calculate_refscript_fee(
builder.config.protocol_params.min_fee_ref_script_cost_per_byte,
total_ref_bytes(builder.ref_inputs)
) + 100_000
ttl =
maybe(valid_to, nil, &SlotConfig.unix_time_to_slot(&1, slot_config))
validaty_interval_start =
maybe(valid_from, nil, &SlotConfig.unix_time_to_slot(&1, slot_config))
%TxBody{
inputs: builder.inputs,
ttl: ttl,
validaty_interval_start: validaty_interval_start,
outputs: builder.outputs,
# Initial fee
fee: Asset.from_lovelace(floor(initial_fee)),
mint: builder.mints,
reference_inputs: builder.ref_inputs,
# TODO: Handle collateral as list
collateral: maybe(collateral_inputs, nil, fn i -> [i.output_reference] end),
total_collateral: maybe(collateral_inputs, nil, fn i -> i.output.value end),
required_signers: MapSet.to_list(builder.required_signers),
auxiliary_data_hash:
maybe(builder.metadata, nil, &(CBOR.encode(&1) |> Blake2b.blake2b_256())),
script_data_hash: Blake2b.blake2b_256(""),
certificates: Enum.map(builder.certificates, &Utils.fst/1),
withdrawals: prepare_withdrawals(builder)
}
end
defp prepare_withdrawals(%TxBuilder{
config: %TxConfig{change_address: %Address{} = change_address},
withdrawals: withdrawals,
script_lookup: script_lookup
}) do
network_tag = if change_address.network == :mainnet, do: "1", else: "0"
Enum.reduce(withdrawals, %{}, fn {k, v}, acc ->
prefix = if Map.has_key?(script_lookup, k), do: "F", else: "E"
Map.put(acc, prefix <> network_tag <> k, v)
end)
end
defp create_tx(
%TxBody{} = tx_body,
%TxBuilder{config: %TxConfig{protocol_params: protocol_params}} = builder,
wallet_inputs,
%Witness{} = witnesses
) do
initial_required_asset = Asset.merge(builder.total_deposit, tx_body.fee)
with {:ok, %CoinSelection{selected_inputs: selected_inputs} = c_selection} <-
balance_tx(initial_required_asset, tx_body, wallet_inputs, builder),
{:ok, %Transaction{tx_body: %TxBody{}, witnesses: %Witness{}} = tx} <-
derive_tx(tx_body, c_selection, builder, witnesses),
{:ok, {collateral_refs, collateral_return, collateral_used}} <-
Collateral.set_collateral(tx, wallet_inputs -- selected_inputs, builder) do
collateral_retun_output =
maybe(collateral_return, nil, fn _ ->
Output.new(builder.config.change_address, collateral_return)
|> calculate_min_ada_for_output(builder.config.protocol_params)
end)
final_tx =
%Transaction{
tx
| tx_body: %TxBody{
tx.tx_body
| collateral: collateral_refs,
collateral_return: collateral_retun_output,
total_collateral: collateral_used
},
witnesses: %Witness{tx.witnesses | vkey_witness: []}
}
# Calculate new Tx Fee
tx_fee = calc_fee(final_tx, protocol_params)
if Asset.lovelace_of(tx_body.fee) >= tx_fee do
{:ok, final_tx}
else
%TxBody{
tx_body
| fee: Asset.from_lovelace(ceil(tx_fee * 1.06)),
required_signers: final_tx.tx_body.required_signers
}
|> create_tx(
builder,
wallet_inputs,
witnesses
)
end
end
end
defp derive_tx(
%TxBody{} = tx_body,
%CoinSelection{} = selected_coin,
%TxBuilder{config: %TxConfig{} = cfg} = builder,
%Witness{} = witness_set
) do
new_inputs =
(tx_body.inputs ++ selected_coin.selected_inputs)
|> Input.sort_inputs()
change_output =
Output.new(cfg.change_address, Asset.only_positive(selected_coin.change),
datum: cfg.change_datum
)
|> calculate_min_ada_for_output(cfg.protocol_params)
vkey_witnesses =
calc_total_signers(builder.required_signers, new_inputs) |> derive_vkey_witness()
tx = %Transaction{
tx_body: %TxBody{
tx_body
| inputs: new_inputs,
outputs: tx_body.outputs ++ [change_output]
},
witnesses: %Witness{
witness_set
| vkey_witness: vkey_witnesses,
redeemer: witness_set.redeemer ++ with_spend_redeemers(builder, new_inputs)
},
is_valid: true,
metadata: builder.metadata
}
with {:ok, redeemers} <- evaluate_uplc(builder, tx) do
script_data_hash =
calculate_script_data_hash(
cfg.protocol_params.cost_models,
builder.used_scripts,
Witness.to_cbor(witness_set.plutus_data) |> Map.get(4),
Witness.to_cbor(redeemers) |> Map.get(5)
)
{:ok,
%Transaction{
tx
| witnesses: %Witness{tx.witnesses | redeemer: redeemers},
tx_body: %TxBody{tx.tx_body | script_data_hash: script_data_hash}
}}
end
end
defp has_plutus_script?([]), do: false
defp has_plutus_script?([script_type | rest]) do
if script_type in [:plutus_v1, :plutus_v2, :plutus_v3],
do: true,
else: has_plutus_script?(rest)
end
defp evaluate_uplc(
%TxBuilder{
config: %TxConfig{
protocol_params: %ProtocolParams{} = p_params,
slot_config: %SlotConfig{} = slot_config
}
} = builder,
%Transaction{} = tx
) do
if has_plutus_script?(builder.used_scripts) do
case Uplc.evaluate(
tx,
p_params.cost_models,
{slot_config.zero_time, slot_config.zero_slot, slot_config.slot_length}
) do
{:ok, redeemers} ->
{:ok, redeemers}
{:error, msg} ->
{:error, ScriptEvaluationFailed.new(msg)}
end
else
{:ok, []}
end
end
defp alter_outputs_with_min_ada(
%TxBuilder{config: %TxConfig{protocol_params: protocol_params}} = builder
) do
Enum.map(builder.outputs, &calculate_min_ada_for_output(&1, protocol_params))
end
defp calculate_min_ada_for_output(%Output{} = output, %ProtocolParams{} = params) do
byte_length = output |> Output.to_cbor() |> CBOR.encode() |> byte_size()
min_fee = ceil(params.ada_per_utxo_byte * (byte_length + 160))
if Asset.lovelace_of(output.value) >= min_fee do
output
else
%Output{
output
| value: Asset.without_lovelace(output.value) |> Asset.add("lovelace", min_fee)
}
|> calculate_min_ada_for_output(params)
end
end
defp calc_total_signers(%MapSet{} = required_signers, used_inputs) when is_list(used_inputs) do
Enum.reduce(used_inputs, required_signers, fn %Input{output: output}, acc ->
if Address.vkey_address?(output.address),
do: MapSet.put(acc, output.address.payment_credential.hash),
else: acc
end)
end
defp derive_vkey_witness(%MapSet{} = signers) do
signers
|> MapSet.to_list()
|> Enum.map(fn hash ->
%VkeyWitness{vkey: hash, signature: Base.encode16(<<0::size(512)>>)}
end)
end
defp calc_fee(
%Transaction{} = tx,
%ProtocolParams{
execution_costs: %ExecutionUnitPrice{
step_price: {step_price_n, step_price_d},
mem_price: {mem_price_n, mem_price_d}
}
} = protocol_params
) do
cost_per_step = step_price_n / step_price_d
cost_per_mem = mem_price_n / mem_price_d
tx_cbor = Transaction.to_cbor(tx) |> CBOR.encode()
fee = protocol_params.min_fee_A * byte_size(tx_cbor) + protocol_params.min_fee_B
Enum.reduce(tx.witnesses.redeemer, fee, fn %Redeemer{exunits: {mem, cpu}}, acc ->
acc + cost_per_mem * mem + cost_per_step * cpu
end)
end
defp balance_tx(
initial_asset_to_cover,
%TxBody{} = tx_body,
[%Input{} | _] = wallet_inputs,
%TxBuilder{} = builder
) do
{to_fill_asset, leftover_asset} = calculate_assets_flow(tx_body, initial_asset_to_cover)
available_inputs = wallet_inputs -- tx_body.inputs
with {:ok, selection} <-
initial_selection(available_inputs, to_fill_asset, leftover_asset) do
ensure_min_ada_for_change(selection, available_inputs, builder)
end
end
defp calculate_assets_flow(tx_body, initial_asset_to_cover) do
total_with_mint =
Enum.reduce(tx_body.inputs, tx_body.mint, fn i, acc ->
Asset.merge(i.output.value, acc)
end)
total_output_assets =
Enum.reduce(tx_body.outputs, initial_asset_to_cover, fn o, acc ->
Asset.merge(o.value, acc)
end)
to_fill = Asset.diff(total_with_mint, total_output_assets) |> Asset.only_positive()
leftover = Asset.diff(total_output_assets, total_with_mint) |> Asset.only_positive()
{to_fill, leftover}
end
defp initial_selection(inputs, to_fill, leftover) do
if Asset.zero() != to_fill do
LargestFirst.select_utxos(inputs, to_fill, leftover)
else
{:ok, %CoinSelection{selected_inputs: [], change: leftover}}
end
end
defp ensure_min_ada_for_change(selection, available_inputs, builder) do
change_output_template = Output.new(builder.config.change_address, selection.change)
min_ada_output =
calculate_min_ada_for_output(change_output_template, builder.config.protocol_params)
min_ada_required = Asset.lovelace_of(min_ada_output.value)
current_change_lovelace = Asset.lovelace_of(selection.change)
if current_change_lovelace >= min_ada_required do
{:ok, selection}
else
needed = min_ada_required - current_change_lovelace
remaining_inputs = available_inputs -- selection.selected_inputs
CoinSelection.select_utxos_for_lovelace(remaining_inputs, needed, selection.change)
|> Utils.when_ok(fn extra ->
{:ok,
%CoinSelection{
selected_inputs: selection.selected_inputs ++ extra.selected_inputs,
change: extra.change
}}
end)
end
end
# If transaction includes datums but does not
# include the redeemers field, the script data format becomes (in hex):
# [ A0 | datums | A0 ]
defp calculate_script_data_hash(_cost_models, _script_lookup, datums, nil)
when not is_nil(datums) do
("\xA0" <> CBOR.encode(datums) <> "\xA0")
|> Blake2b.blake2b_256()
end
# Calculate Script Data Hash
defp calculate_script_data_hash(%CostModels{} = cost_model, used_scripts, datums, redeemer)
when is_map(redeemer) and redeemer != %{} do
lang_views =
Enum.reduce(used_scripts, %{}, fn script_type, acc ->
cond do
script_type == :plutus_v1 ->
# The language ID tag for Plutus V1 is encoded twice. first as a uint then as
# a bytestring.
# Concretely, this means that the language version for V1 is encoded as
# 4100 in hex.
#
# The value of cost_models map for v1
# is encoded as an indefinite length list and the result is encoded as a bytestring.
# Therefore using %PList{} instead of normal list
Map.put_new(acc, 0x4100, %PList{value: cost_model.plutus_v1})
script_type == :plutus_v2 ->
Map.put_new(acc, 1, cost_model.plutus_v2)
script_type == :plutus_v3 ->
Map.put_new(acc, 2, cost_model.plutus_v3)
true ->
acc
end
end)
(CBOR.encode(redeemer) <>
Utils.maybe(datums, "", &CBOR.encode/1) <> CBOR.encode(lang_views))
|> Blake2b.blake2b_256()
end
defp calculate_script_data_hash(_, _, _, _), do: nil
end