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.
import (
"context"
mintlayer "github.com/mintlayer/go-sdk/wasm"
)
ctx := context.Background()
c, err := mintlayer.New(ctx)
if err != nil {
log.Fatal(err)
}
defer c.Close()
The shape of a transaction
tx, err := c.EncodeTransaction(encodedInputs, encodedOutputs, 0 /* flags */)
encodedInputs: concatenation of input blobs. Most inputs are UTXO spends (EncodeInputForUtxo), but protocol inputs exist too: token operations (EncodeInputForMintTokens,EncodeInputForUnmintTokens,EncodeInputForFreezeToken, …), delegation withdrawals, and order operations. All concatenate the same way.encodedOutputs: concatenation of output blobs. Any mix of transfers, burns, issuances, data deposits, HTLCs, order creations, and pool operations.flags:0for 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, err := c.EncodeOutputTransfer(
mintlayer.NewAmount("500000000000"), "mxtc1qrecipient...", mintlayer.Mainnet)
// 2. Arbitrary bytes on-chain
memo, err := c.EncodeOutputDataDeposit([]byte("order-ref:2026-09-16-042"))
// 3. Coins the recipient cannot spend for 1000 blocks
lock, err := c.EncodeLockForBlockCount(1000)
vesting, err := c.EncodeOutputLockThenTransfer(
mintlayer.NewAmount("100000000000"), "mxtc1qemployee...", lock, mintlayer.Mainnet)
// 4. Token payment alongside the coins
tokenOut, err := c.EncodeOutputTokenTransfer(
mintlayer.NewAmount("25"), "mxtc1qrecipient...", "ttml1tokenid...", mintlayer.Mainnet)
allOutputs := append(payment, memo...)
allOutputs = append(allOutputs, vesting...)
allOutputs = append(allOutputs, tokenOut...)
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:
tokenInfo, err := idxClient.GetToken(ctx, tokenID) // NextNonce
mintInput, err := c.EncodeInputForMintTokens(
tokenID, mintlayer.NewAmount("100000"), tokenInfo.NextNonce, mintlayer.Mainnet)
feeInput, err := c.EncodeInputForUtxo(srcID, uint32(0)) // coin UTXO covering the fee
encodedInputs := append(mintInput, feeInput...)
The mint's EncodeInputForMintTokens requires the token's current NextNonce 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, err := idxClient.GetTip(ctx)
issuanceFee, err := c.FungibleTokenIssuanceFee(tip.BlockHeight, mintlayer.Mainnet)
mintFee, err := c.TokenSupplyChangeFee(tip.BlockHeight, mintlayer.Mainnet)
freezeFee, err := c.TokenFreezeFee(tip.BlockHeight, mintlayer.Mainnet)
authorityFee, err := c.TokenChangeAuthorityFee(tip.BlockHeight, mintlayer.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, err := idxClient.GetFeeRate(ctx, 1) // atoms per KB, top 1 MB of mempool
size, err := c.EstimateTransactionSize(encodedInputs, destAddresses, allOutputs, mintlayer.Mainnet)
fee := new(big.Int).Mul(new(big.Int).SetUint64(uint64(size)), feeRateAtoms)
fee.Div(fee, big.NewInt(1000))
destAddresses 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:
poolID, err := c.GetPoolId(encodedInputs, mintlayer.Mainnet)
tokenID, err := c.GetTokenId(encodedInputs, tip.BlockHeight, mintlayer.Mainnet)
delegationID, err := c.GetDelegationId(encodedInputs, mintlayer.Mainnet)
orderID, err := c.GetOrderId(encodedInputs, mintlayer.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)
EncodeSignedTransaction expects witnesses for every input. For flows where different parties sign different inputs, build partial signatures and merge:
partiallySigned, err := c.EncodePartiallySignedTransaction(
tx, witnessBytes, inputUtxos, inputDestinations,
htlcSecrets, // empty when unused
mintlayer.TxAdditionalInfo{},
mintlayer.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, err := c.MakeTransactionIntentMessageToSign(intent, transactionID)
sig, err := c.SignMessageForSpending(privKey, msg)
signedIntent, err := c.EncodeSignedTransactionIntent(msg, [][]byte{sig})
// Verifier side, before signing:
err = c.VerifyTransactionIntent(expectedSignedMessage, signedIntent, inputDestinations, mintlayer.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).
TxAdditionalInfopopulated for any pool or order input, signing fails or produces an invalid sighash otherwise (see the reference).- Protocol fees included as outputs; nonce values fresh from the indexer.
- Dry-run the result:
GetTransactionIDandDecodeSignedTransactionToJSbeforeSubmitTransaction.