Skip to main content

Forging Custom Transactions (Advanced)

A Mintlayer transaction is just inputs + outputs + witnesses. The wallet daemon assembles standard shapes for you; the WASM runtime lets you forge any valid combination yourself. This is the advanced guide; the basic eight-step flow is documented in Building transactions. Here we focus on what you can combine.

from mintlayer.wasm import Amount, Network, TxAdditionalInfo
from mintlayer.wasm import Client

c = Client()

The shape of a transaction​

tx = c.encode_transaction(encoded_inputs, encoded_outputs, 0) # flags = 0
  • encoded_inputs: a concatenation of input blobs. Most inputs are UTXO spends (encode_input_for_utxo), but protocol inputs exist too: token operations (encode_input_for_mint_tokens, encode_input_for_unmint_tokens, encode_input_for_freeze_token, ...), delegation withdrawals, and order operations. All concatenate the same way.
  • encoded_outputs: concatenation of output blobs. Any mix of transfers, burns, issuances, data deposits, HTLCs, order creations, and pool operations.
  • flags: 0 for standard transactions.

Nothing stops you from mixing output types freely in one transaction.

Mixed outputs in one transaction​

Payments, an on-chain memo, and a time-locked vesting payout, all atomic:

# 1. Plain coin payment
payment = c.encode_output_transfer(
Amount(atoms="500000000000"), "mtc1qrecipient...", Network.MAINNET
)

# 2. Arbitrary bytes on-chain
memo = c.encode_output_data_deposit(b"order-ref:2026-09-16-042")

# 3. Coins the recipient cannot spend for 1000 blocks
lock = c.encode_lock_for_block_count(1000)
vesting = c.encode_output_lock_then_transfer(
Amount(atoms="100000000000"), "mtc1qemployee...", lock, Network.MAINNET
)

# 4. Token payment alongside the coins
token_out = c.encode_output_token_transfer(
Amount(atoms="25"), "mtc1qrecipient...", "ttml1tokenid...", Network.MAINNET
)

all_outputs = payment + memo + vesting + token_out

Mixing UTXO and protocol inputs​

Protocol operations are inputs like any other. Minting tokens, for example, consumes a nonce input keyed by the token ID, while the network fee still comes from a regular coin UTXO:

token_info = idx.get_token(token_id) # next_nonce

mint_input = c.encode_input_for_mint_tokens(
token_id, Amount(atoms="100000"), token_info.next_nonce, Network.MAINNET
)

fee_input = c.encode_input_for_utxo(src_id, 0) # coin UTXO covering the fee

encoded_inputs = mint_input + fee_input

The mint input requires the token's current next_nonce from the indexer: every protocol input (freezes, authority changes, delegation withdrawals, order ops) consumes its nonce the same way.

Protocol fees​

Token operations carry minimum protocol fees that must be added to the transaction outputs. Query them at the current height:

tip = idx.get_tip()

issuance_fee = c.fungible_token_issuance_fee(tip.block_height, Network.MAINNET)
mint_fee = c.token_supply_change_fee(tip.block_height, Network.MAINNET)
freeze_fee = c.token_freeze_fee(tip.block_height, Network.MAINNET)
authority_fee = c.token_change_authority_fee(tip.block_height, Network.MAINNET)

Add matching coin outputs (typically back to yourself or the new authority) covering the fee amounts.

Network fee estimation​

Size drives the network fee, and size depends on the signed transaction. Estimate before finalizing outputs, then deduct the fee from change:

rate = idx.get_fee_rate(1) # atoms per KB, top 1 MB of mempool

size = c.estimate_transaction_size(encoded_inputs, dest_addresses, all_outputs, Network.MAINNET)

fee = (size + 999) // 1000 * max(int(rate), 1)

dest_addresses is one owner address per input, in input order; for mixed inputs, that is the UTXO's address, or the relevant delegation/order owner for protocol inputs.

Predicting IDs​

Creation transactions get their IDs from their inputs, deterministically; compute them before broadcasting:

pool_id = c.get_pool_id(encoded_inputs, Network.MAINNET)
token_id = c.get_token_id(encoded_inputs, tip.block_height, Network.MAINNET)
delegation_id = c.get_delegation_id(encoded_inputs, Network.MAINNET)
order_id = c.get_order_id(encoded_inputs, Network.MAINNET)

This is how a transaction that creates a delegation can immediately delegate to it, or how you can show a user "your token will be ttml1..." before signing.

Multi-party signing (PSBT-style)​

encode_signed_transaction expects witnesses for every input. For flows where different parties sign different inputs, build partial signatures and merge:

partially_signed = c.encode_partially_signed_transaction(
transaction, witness_bytes, input_utxos, input_destinations,
htlc_secrets, # empty when unused
TxAdditionalInfo(),
Network.MAINNET,
)

Exchange the partially signed transaction out-of-band; the final assembler produces the complete signed transaction once every input has a witness.

Transaction intents​

An intent is a signed declaration of purpose (what a transaction is meant to do), bound to the transaction ID but independent of its bytes. Signers can verify what they are authorizing before producing a witness:

msg = c.make_transaction_intent_message_to_sign(intent, transaction_id)
sig = c.sign_message_for_spending(private_key, msg)

signed_intent = c.encode_signed_transaction_intent(msg, [sig])

# Verifier side, before signing:
c.verify_transaction_intent(msg, signed_intent, input_destinations, Network.MAINNET)

This is the building block for co-signers and policy engines that must approve transactions they can't fully parse.

Sanity checks before broadcasting​

  • Every input witness present, in input order (see Wallet & Signing).
  • TxAdditionalInfo populated for any pool or order input, or signing fails or produces an invalid sighash (see the reference).
  • Protocol fees included as outputs; nonce values fresh from the indexer.
  • Dry-run the result: get_transaction_id before submit_transaction.