Building transactions manually
The wallet daemon handles transaction building automatically for most use
cases. Use the mintlayer.wasm module directly when you need:
- Full custody (no wallet daemon)
- Custom output types or complex spending conditions
- Integration testing or tooling
The examples/send_coins.py program demonstrates this flow end to end.
Overview
Building a transaction manually requires these steps:
- Derive the spending key and address from a mnemonic
- Fetch spendable UTXOs from the indexer
- Encode each input as binary
- Encode each output as binary
- Build the unsigned transaction
- Sign each input to produce witness bytes
- Assemble the signed transaction
- Submit to the network
Step 1: Key derivation
from mintlayer.wasm import Client, Network
c = Client()
try:
mnemonic = "word1 word2 ... word12"
account_key = c.make_default_account_privkey(mnemonic, Network.MAINNET)
# key index 0 = first receiving address
spend_key = c.make_receiving_address(account_key, 0)
pub_key = c.public_key_from_private_key(spend_key)
from_addr = c.pubkey_to_pubkeyhash_address(pub_key, Network.MAINNET)
finally:
c.close()
Step 2: Fetch spendable UTXOs
from mintlayer.indexer import Client as IndexerClient
idx = IndexerClient("http://127.0.0.1:3000")
utxos = idx.get_spendable_utxos(from_addr)
if not utxos:
raise RuntimeError("no spendable UTXOs")
Each UTXO carries outpoint (source_id hex string + index) and output
(raw JSON).
Step 3: Encode inputs
Each input requires:
- Hex-decode the source transaction ID
- Encode the outpoint source ID (
encode_outpoint_source_id) - Encode the input (
encode_input_for_utxo)
Concatenation semantics: inputs (and outputs, and witnesses) are plain
binary blobs — the concatenation of every per-item encoding is the
transaction field. There is no count prefix or separator; just append with
+=.
from mintlayer.wasm import SOURCE_TRANSACTION
encoded_inputs = b""
for u in utxos:
tx_id_bytes = bytes.fromhex(u.outpoint.source_id)
src_id = c.encode_outpoint_source_id(tx_id_bytes, SOURCE_TRANSACTION)
inp = c.encode_input_for_utxo(src_id, u.outpoint.index)
encoded_inputs += inp
Step 4: Encode outputs
from mintlayer.wasm import Amount
output = c.encode_output_transfer(
Amount(atoms="100000000000"), # 1 ML in atoms
"mtc1qrecipient...",
Network.MAINNET,
)
For multiple outputs, concatenate them:
change_output = c.encode_output_transfer(
Amount(atoms=str(change_atoms)),
from_addr, # send change back to sender
Network.MAINNET,
)
all_outputs = output + change_output
Step 5: Build the unsigned transaction
tx = c.encode_transaction(encoded_inputs, output, 0) # flags = 0
tx_id = c.get_transaction_id(tx, True)
print(f"unsigned tx id: {tx_id}")
Step 6: Prepare UTXO bytes for signing
The sighash computation requires access to the UTXO being spent. Build a per-input blob where each entry is prefixed with either:
0x01followed by the re-encoded output bytes (recommended for coin transfers)0x00alone (acceptable for some output types)
def encode_utxo_entry(c: Client, utxo_json: dict, network: Network) -> bytes:
try:
if utxo_json.get("type") == "Transfer":
value = utxo_json["value"]
if value.get("type") == "Coin":
encoded = c.encode_output_transfer(
Amount(atoms=value["amount"]["atoms"]),
value["destination"],
network,
)
return b"\x01" + encoded
except (KeyError, TypeError, ValueError):
pass
return b"\x00"
all_utxo_bytes = b""
for u in utxos:
all_utxo_bytes += encode_utxo_entry(c, u.output, Network.MAINNET)
The order of these entries must match the input order exactly — witness i is
validated against entry i.
Step 7: Sign each input
Call encode_witness once per input. Concatenate results.
from mintlayer.wasm import SignatureHashType, TxAdditionalInfo
witness_bytes = b""
for i in range(len(utxos)):
w = c.encode_witness(
SignatureHashType.SIGHASH_ALL,
spend_key,
from_addr,
tx,
all_utxo_bytes,
i, # input index
TxAdditionalInfo(), # empty for standard transfers
0, # block height (0 when no timelock constraint)
Network.MAINNET,
)
witness_bytes += w
Step 8: Assemble and submit
signed_tx = c.encode_signed_transaction(tx, witness_bytes)
signed_hex = signed_tx.hex()
# Submit via the indexer (requires --enable-post-routes)
submitted_tx_id = idx.submit_transaction(signed_hex)
print(f"submitted: {submitted_tx_id}")
# Alternative: broadcast via the node daemon
# node_client.p2p_submit_transaction(signed_hex, TrustPolicy.UNTRUSTED)
See indexer.md for the submit route requirements and node.md for the P2P alternative.
Fee estimation
Compute the fee before constructing outputs so you can deduct it from the change:
# Collect destination addresses (one per input, in input order)
dest_addresses = [from_addr] * len(utxos)
estimated_size = c.estimate_transaction_size(
encoded_inputs, dest_addresses, all_outputs, Network.MAINNET
)
# get_fee_rate returns atoms per KB for the top 1 MB of the mempool
fee_rate = int(idx.get_fee_rate(1))
fee = estimated_size * fee_rate // 1000
# Subtract fee from the amount going to the recipient or from the change output.
Lock-then-transfer outputs
To send coins that cannot be spent for a period of time:
# Unlock after 1000 blocks
lock = c.encode_lock_for_block_count(1000)
output = c.encode_output_lock_then_transfer(
Amount(atoms="100000000000"),
"mtc1qrecipient...",
lock,
Network.MAINNET,
)
Token transfers
Sending fungible tokens uses the same flow, with a different output encoder:
token_output = c.encode_output_token_transfer(
Amount(atoms="1000"), # token amount in smallest units
"mtc1qrecipient...",
"ttml1tokenid...",
Network.MAINNET,
)
Note that a token transfer transaction must also include a coin output (or coin inputs) to cover the network fee.
Related
- wasm.md — full WASM client reference
- staking.md — manual delegation/pool transactions
- tokens.md — manual token issuance/minting
- wallet.md — let the wallet daemon do all of this for you