STAS3 Bundle Factory — automatic merge/split/transfer transaction planning.
Given callback functions for UTXO retrieval, transaction lookup, and script construction, the factory automatically plans sequences of merge/split/transfer transactions to fulfill multi-recipient payouts.
How It Works
- UTXO Selection — selects the minimum set of STAS UTXOs that cover the required amount (exact match preferred, else smallest-first accumulation)
- Merge Tree — if multiple UTXOs selected, builds pairwise merge transactions to consolidate into a single UTXO. Inserts "transfer refresh" levels every 3 merge levels to prevent excessive script depth.
- Transfer Planning — splits the consolidated UTXO across recipients, up to 3 per intermediate tx (with STAS change), up to 4 in the final tx.
- Fee Chaining — a single funding UTXO is chained through all transactions; each tx's fee change output feeds the next tx's fee input.
Callback Functions
The struct holds five callback functions:
get_stas_utxo_set—(min_satoshis) -> [utxo]returns available STAS UTXOsget_funding_utxo—(request) -> utxoreturns a fee-paying UTXOget_transactions—([txid_hex]) -> %{txid_hex => Transaction.t()}lookupsbuild_locking_params—(args) -> Stas3OutputParams.t()constructs output paramsbuild_unlocking_script—(args) -> {:ok, Script.t()}signs/unlocks inputs
UTXO Map Structure
Each UTXO in callbacks is a map with keys:
:txid— 32-byte binary txid (internal byte order):txid_hex— hex string txid (display order, for transaction lookups):vout— output index:satoshis— satoshi amount:locking_script—Script.t()locking script
Summary
Functions
Generic bundle creation for spend types that bypass transfer/2 validation
(swap, confiscation). These use get_stas_utxo_set directly.
Create a confiscation bundle.
Create a freeze bundle (sets spend type to :freeze).
Create a swap bundle.
Create a single-recipient transfer bundle.
Create an unfreeze bundle (sets spend type to :unfreeze).
Plan and build a multi-recipient transfer bundle.
Types
@type bundle_result() :: {:ok, %{transactions: [String.t()], fee_satoshis: non_neg_integer()}} | {:ok, %{message: String.t(), fee_satoshis: 0}}
@type funding_request() :: %{ utxo_ids_to_spend: [String.t()], estimated_fee_satoshis: non_neg_integer(), transactions_count: non_neg_integer() }
@type locking_params_args() :: %{ from_utxo: utxo(), recipient: recipient(), spend_type: spend_type(), is_freeze_like: boolean(), output_index: non_neg_integer(), output_count: non_neg_integer(), is_change: boolean() }
@type recipient() :: %{m: pos_integer(), addresses: [String.t()]}
@type spend_type() :: :transfer | :freeze | :unfreeze | :swap | :confiscation
@type t() :: %BSV.Tokens.Bundle.Stas3Bundle{ build_locking_params: (locking_params_args() -> BSV.Tokens.Stas3OutputParams.t()), build_unlocking_script: (unlocking_args() -> {:ok, BSV.Script.t()}), fee_rate: non_neg_integer(), fee_wallet: map(), get_funding_utxo: (funding_request() -> utxo()), get_stas_utxo_set: (non_neg_integer() -> [utxo()]), get_transactions: ([String.t()] -> %{required(String.t()) => BSV.Transaction.t()}), stas_wallet: map() }
@type transfer_output() :: %{recipient: recipient(), satoshis: non_neg_integer()}
@type unlocking_args() :: %{ tx: BSV.Transaction.t(), input_index: non_neg_integer(), utxo: utxo(), spend_type: spend_type(), is_freeze_like: boolean(), is_merge: boolean() }
@type utxo() :: %{ txid: binary(), txid_hex: String.t(), vout: non_neg_integer(), satoshis: non_neg_integer(), locking_script: BSV.Script.t() }
Functions
@spec create_bundle( t(), non_neg_integer(), recipient(), spend_type(), [binary()] | nil ) :: bundle_result()
Generic bundle creation for spend types that bypass transfer/2 validation
(swap, confiscation). These use get_stas_utxo_set directly.
@spec create_confiscation_bundle( t(), non_neg_integer(), recipient(), [binary()] | nil ) :: bundle_result()
Create a confiscation bundle.
@spec create_freeze_bundle(t(), non_neg_integer(), recipient(), [binary()] | nil) :: bundle_result()
Create a freeze bundle (sets spend type to :freeze).
@spec create_swap_bundle(t(), non_neg_integer(), recipient(), [binary()] | nil) :: bundle_result()
Create a swap bundle.
@spec create_transfer_bundle(t(), non_neg_integer(), recipient(), [binary()] | nil) :: bundle_result()
Create a single-recipient transfer bundle.
@spec create_unfreeze_bundle(t(), non_neg_integer(), recipient(), [binary()] | nil) :: bundle_result()
Create an unfreeze bundle (sets spend type to :unfreeze).
@spec transfer(t(), %{ outputs: [transfer_output()], spend_type: spend_type(), note: [binary()] | nil }) :: bundle_result()
Plan and build a multi-recipient transfer bundle.
Takes a list of outputs (each with :recipient and :satoshis), an optional
:spend_type (default :transfer), and optional :note (list of binaries for
OP_RETURN, attached only to the final transaction).
Returns {:ok, %{transactions: [hex], fee_satoshis: int}} on success, or
{:ok, %{message: "Insufficient ...", fee_satoshis: 0}} if balance is too low.
Raises on invalid inputs (empty outputs, zero satoshis).