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.

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: 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, 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).
  • TxAdditionalInfo populated 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: GetTransactionID and DecodeSignedTransactionToJS before SubmitTransaction.