-
Notifications
You must be signed in to change notification settings - Fork 35
Mintlayer block and transaction serialization
Byte-level reference for the consensus encoding of blocks and transactions.
Source of truth. Everything here is derived from the type definitions in common/src/chain/
and crypto/src/, and the worked examples are real encoder output, not hand-computed. If this
document and the code disagree, the code wins — in particular the #[codec(index = N)]
attributes, which are the enum discriminants.
The encoding is SCALE
(parity-scale-codec, re-exported through the serialization crate). SCALE is not
self-describing: there are no field names, no type tags, and no terminators. Decoding requires
knowing the type in advance.
Little-endian, no prefix.
| Type | Bytes | Example (value 3) |
|---|---|---|
u8 |
1 | 03 |
u32 |
4 | 03000000 |
u64 |
8 | 0300000000000000 |
u128 |
16 |
03000000...00 (16 bytes) |
A variable-length encoding used for lengths, amounts, heights and timestamps. The low two bits of the first byte select the mode:
mode 00 : single byte value = byte >> 2 0 ..= 63
mode 01 : two bytes (LE) value = u16 >> 2 64 ..= 16_383
mode 10 : four bytes (LE) value = u32 >> 2 16_384 ..= 2^30 - 1
mode 11 : big integer first byte >> 2 = (length - 4),
then `length` bytes, little-endian 2^30 ..= 2^536
Examples:
| Value | Encoding | Mode |
|---|---|---|
0 |
00 |
single byte, 0 << 2
|
1 |
04 |
single byte, 1 << 2
|
63 |
fc |
single byte, largest |
1_000_000 |
02093d00 |
four-byte, 0xF4240 << 2 | 0b10
|
1_600_000_000 |
0300105e5f |
big-integer, 4 payload bytes LE |
| Construct | Encoding |
|---|---|
struct |
fields concatenated in declaration order, no padding, no header |
enum |
1 byte discriminant, then the variant's payload |
Vec<T> |
compact length (item count), then the items back to back |
Option<T> |
00 = None; 01 followed by the payload = Some
|
[u8; N] |
N raw bytes, no length prefix |
Box<T> |
encoded exactly as T — the box is invisible on the wire |
Two repo-specific wrappers matter:
-
VersionTag<N>encodes as the single byteN. It is a real field, occupying one byte. -
DirectEncode/DirectDecode(fromserialization/tagged) generate an enum encoding with no discriminant byte — the inner variant is written directly.BlockandTransactionboth use this, which is why aBlockis byte-identical to itsBlockV1, and versioning is carried by the inner struct's ownVersionTaginstead.
| Type | Encoding | Bytes |
|---|---|---|
H256, Id<T>
|
raw hash | 32 |
PoolId, DelegationId, TokenId, OrderId
|
Id<T> newtypes |
32 |
Amount |
compact u128 (atoms) |
1–17 |
BlockHeight |
compact u64
|
1–9 |
BlockTimestamp |
compact u64 (seconds since Unix epoch) |
1–9 |
AccountNonce |
compact u64
|
1–9 |
Compact (target) |
u32 little-endian — not a SCALE compact |
4 |
PublicKeyHash |
raw | 20 |
PublicKey |
00 (Secp256k1Schnorr) + 33-byte compressed key |
34 |
Signature |
00 (Secp256k1Schnorr) + 64-byte Schnorr sig |
65 |
HtlcSecretHash |
raw | 20 |
Note the trap in Compact: the difficulty-target type is named Compact but is a plain
little-endian u32, unrelated to SCALE compact integers.
Block ──(DirectEncode, no tag)──> BlockV1
BlockV1
├── header : SignedBlockHeader
└── body : BlockBody
offset size field
┌───────────────────────────────────────────────────────────────────────┐
│ 0 var block_header : BlockHeader (see below) │
│ var 1 signature tag : 00 = None, 01 = HeaderSignature │
│ var 65 signature : present only when tag = 01 │
└───────────────────────────────────────────────────────────────────────┘
The signature comes after the header, because SignedBlockHeader declares
block_header before signature_data. Only PoS blocks carry a header signature.
offset size field
┌───────────────────────────────────────────────────────────────────────┐
│ 0 1 version VersionTag<1>, always 0x01 │
│ 1 32 prev_block_id Id<GenBlock> │
│ 33 32 tx_merkle_root H256 │
│ 65 32 witness_merkle_root H256 │
│ 97 1-9 timestamp compact u64, seconds since epoch │
│ var var consensus_data ConsensusData (see below) │
└───────────────────────────────────────────────────────────────────────┘
Everything up to and including the merkle roots is at a fixed offset; from the timestamp on, offsets depend on the compact encoding.
tag variant payload
┌──────────────────────────────────────────────────────────────────────┐
│ 00 None (empty) │
│ 01 PoW PoWData │
│ 02 PoS PoSData │
└──────────────────────────────────────────────────────────────────────┘
PoWData — fixed 20 bytes:
offset size field
┌──────────────────────────────────────────────────────────────────────┐
│ 0 4 bits Compact target, u32 LE │
│ 4 16 nonce u128 LE │
└──────────────────────────────────────────────────────────────────────┘
PoSData — variable:
size field
┌──────────────────────────────────────────────────────────────────────┐
│ var kernel_inputs Vec<TxInput> │
│ var kernel_witness Vec<InputWitness> │
│ 32 stake_pool_id PoolId │
│ var vrf_data VRFReturn (enum: 00 = Schnorrkel, + payload) │
│ 4 compact_target Compact, u32 LE │
└──────────────────────────────────────────────────────────────────────┘
Field order is worth noting: compact_target is written last, after the VRF data, even
though PoWData puts its target first.
BlockBody
├── reward : BlockReward = Vec<TxOutput> (compact count, then outputs)
└── transactions : Vec<SignedTransaction>
BlockReward is a newtype over Vec<TxOutput>, so an empty reward is the single byte 00.
A PoW block with no transactions and an empty reward, prev_block_id = 0x2222…22,
timestamp = 1_600_000_000, bits = 0x1d00ffff, nonce = 42 — 126 bytes:
01 version = 1
2222222222222222222222222222222222222222222222222222222222222222 prev_block_id
2fa3f686df876995167e7c2e5d74c4c7b6e48f8068fe0e44208344d480f7904c tx_merkle_root
2fa3f686df876995167e7c2e5d74c4c7b6e48f8068fe0e44208344d480f7904c witness_merkle_root
0300105e5f timestamp, compact 1600000000
01 ConsensusData::PoW
ffff001d bits, u32 LE = 0x1d00ffff
2a000000000000000000000000000000 nonce, u128 LE = 42
00 header signature: None
00 reward: 0 outputs
00 transactions: 0 items
Bytes 0–123 are the SignedBlockHeader; the last two bytes are the whole BlockBody.
The block id is the hash of the header, not of the full block.
SignedTransaction
├── transaction : Transaction ──(DirectEncode, no tag)──> TransactionV1
└── signatures : Vec<InputWitness>
The witnesses live in SignedTransaction, outside the signed body — one witness per input,
positionally matched. The transaction id is computed over Transaction only, so it does not
commit to the signatures.
offset size field
┌──────────────────────────────────────────────────────────────────────┐
│ 0 1 version VersionTag<1>, always 0x01 │
│ 1 1-17 flags compact u128 (currently always 0) │
│ var var inputs Vec<TxInput> │
│ var var outputs Vec<TxOutput> │
└──────────────────────────────────────────────────────────────────────┘
The smallest possible transaction is therefore 4 bytes: 01 00 00 00.
tag variant payload
┌──────────────────────────────────────────────────────────────────────┐
│ 00 Utxo UtxoOutPoint │
│ 01 Account AccountOutPoint │
│ 02 AccountCommand AccountNonce + AccountCommand │
│ 03 OrderAccountCommand OrderAccountCommand │
└──────────────────────────────────────────────────────────────────────┘
UtxoOutPoint — fixed 37 bytes after the TxInput tag:
offset size field
┌──────────────────────────────────────────────────────────────────────┐
│ 0 1 source tag 00 = Transaction, 01 = BlockReward │
│ 1 32 id Id<Transaction> or Id<GenBlock> │
│ 33 4 index u32 LE, output index │
└──────────────────────────────────────────────────────────────────────┘
The output index is a plain little-endian u32, not a compact — so it always costs 4 bytes.
AccountOutPoint = AccountNonce (compact u64) + AccountSpending:
| Tag | AccountSpending |
Payload |
|---|---|---|
00 |
DelegationBalance |
DelegationId + Amount
|
AccountCommand (preceded by an AccountNonce):
| Tag | Variant | Payload |
|---|---|---|
00 |
MintTokens |
TokenId + Amount
|
01 |
UnmintTokens |
TokenId |
02 |
LockTokenSupply |
TokenId |
03 |
FreezeToken |
TokenId + IsTokenUnfreezable
|
04 |
UnfreezeToken |
TokenId |
05 |
ChangeTokenAuthority |
TokenId + Destination
|
06 |
ConcludeOrder |
OrderId |
07 |
FillOrder |
OrderId + Amount + Destination
|
08 |
ChangeTokenMetadataUri |
TokenId + Vec<u8>
|
OrderAccountCommand (no nonce — these are the V1 order commands):
| Tag | Variant | Payload |
|---|---|---|
00 |
FillOrder |
OrderId + Amount
|
01 |
FreezeOrder |
OrderId |
02 |
ConcludeOrder |
OrderId |
tag variant payload
┌──────────────────────────────────────────────────────────────────────┐
│ 00 Transfer OutputValue + Destination │
│ 01 LockThenTransfer OutputValue + Destination + OutputTimeLock│
│ 02 Burn OutputValue │
│ 03 CreateStakePool PoolId + StakePoolData │
│ 04 ProduceBlockFromStake Destination + PoolId │
│ 05 CreateDelegationId Destination + PoolId │
│ 06 DelegateStaking Amount + DelegationId │
│ 07 IssueFungibleToken TokenIssuance │
│ 08 IssueNft TokenId + NftIssuance + Destination │
│ 09 DataDeposit Vec<u8> │
│ 0a Htlc OutputValue + HashedTimelockContract │
│ 0b CreateOrder OrderData │
└──────────────────────────────────────────────────────────────────────┘
Watch the argument order: ProduceBlockFromStake and CreateDelegationId are
(Destination, PoolId), while CreateStakePool is (PoolId, StakePoolData) and
DelegateStaking is (Amount, DelegationId).
| Tag | Variant | Payload |
|---|---|---|
00 |
Coin |
Amount |
01 |
TokenV0 |
TokenData (legacy) |
02 |
TokenV1 |
TokenId + Amount
|
| Tag | Variant | Payload | Bytes |
|---|---|---|---|
00 |
AnyoneCanSpend |
— | 1 |
01 |
PublicKeyHash |
PublicKeyHash |
21 |
02 |
PublicKey |
PublicKey |
35 |
03 |
ScriptHash |
Id<Script> |
33 |
04 |
ClassicMultisig |
PublicKeyHash |
21 |
ScriptHash is encodable but is not spendable: no validation path handles it.
| Tag | Variant | Payload |
|---|---|---|
00 |
UntilHeight |
compact u64
|
01 |
UntilTime |
compact u64 (s) |
02 |
ForBlockCount |
compact u64
|
03 |
ForSeconds |
compact u64
|
tag variant payload
┌──────────────────────────────────────────────────────────────────────┐
│ 00 NoSignature Option<Vec<u8>> │
│ 01 Standard StandardInputSignature │
└──────────────────────────────────────────────────────────────────────┘
StandardInputSignature:
offset size field
┌──────────────────────────────────────────────────────────────────────┐
│ 0 1 sighash_type u8 bitfield │
│ 1 var raw_signature Vec<u8> (compact length, then bytes) │
└──────────────────────────────────────────────────────────────────────┘
Sighash flags: 0x01 ALL, 0x02 NONE, 0x03 SINGLE, 0x80 ANYONECANPAY (OR-ed in).
raw_signature is an opaque byte string whose interpretation depends on the Destination
being spent — a bare signature for PublicKey/PublicKeyHash, an
AuthorizedClassicalMultisigSpend for ClassicMultisig, an
AuthorizedHashedTimelockContractSpend for HTLC outputs.
StakePoolData HashedTimelockContract
├── pledge : Amount ├── secret_hash : 20 bytes
├── staker : Dest ├── spend_key : Destination
├── vrf_public_key : VRFPub ├── refund_timelock : OutputTimeLock
├── decommission_key : Dest └── refund_key : Destination
├── margin_ratio_per_thousand
└── cost_per_block : Amount OrderData
├── conclude_key : Destination
├── ask : OutputValue
└── give : OutputValue
TokenIssuance and NftIssuance are themselves versioned enums; see
common/src/chain/tokens/.
Empty transaction (no inputs, no outputs) — 4 bytes as Transaction, 5 as SignedTransaction:
01 version = 1
00 flags = 0 (compact)
00 inputs: 0 items
00 outputs: 0 items
00 signatures: 0 items <- SignedTransaction only
One UTXO input, one coin transfer to a P2PKH destination, unsigned witness — 72 bytes:
01 version = 1
00 flags = 0
04 inputs: 1 item
00 TxInput::Utxo
00 source: Transaction
1111111111111111111111111111111111111111111111111111111111111111 tx id
03000000 output index = 3 (u32 LE)
04 outputs: 1 item
00 TxOutput::Transfer
00 OutputValue::Coin
02093d00 amount = 1_000_000 atoms
01 Destination::PublicKeyHash
75eeddc44a29ef682f407bce482eeb878ab863f9 20-byte hash
04 signatures: 1 item
00 InputWitness::NoSignature
00 Option::None
The Transaction alone is the first 69 bytes; the trailing three are the witness vector.
| Enum | Discriminants |
|---|---|
ConsensusData |
0 None, 1 PoW, 2 PoS |
BlockHeaderSignature |
0 None, 1 HeaderSignature |
TxInput |
0 Utxo, 1 Account, 2 AccountCommand, 3 OrderAccountCommand |
OutPointSourceId |
0 Transaction, 1 BlockReward |
TxOutput |
0 Transfer, 1 LockThenTransfer, 2 Burn, 3 CreateStakePool, 4 ProduceBlockFromStake, 5 CreateDelegationId, 6 DelegateStaking, 7 IssueFungibleToken, 8 IssueNft, 9 DataDeposit, 10 Htlc, 11 CreateOrder |
OutputValue |
0 Coin, 1 TokenV0, 2 TokenV1 |
Destination |
0 AnyoneCanSpend, 1 PublicKeyHash, 2 PublicKey, 3 ScriptHash, 4 ClassicMultisig |
OutputTimeLock |
0 UntilHeight, 1 UntilTime, 2 ForBlockCount, 3 ForSeconds |
InputWitness |
0 NoSignature, 1 Standard |
AccountSpending |
0 DelegationBalance |
AccountCommand |
0 MintTokens, 1 UnmintTokens, 2 LockTokenSupply, 3 FreezeToken, 4 UnfreezeToken, 5 ChangeTokenAuthority, 6 ConcludeOrder, 7 FillOrder, 8 ChangeTokenMetadataUri |
OrderAccountCommand |
0 FillOrder, 1 FreezeOrder, 2 ConcludeOrder |
PublicKeyHolder |
0 Secp256k1Schnorr |
These values are consensus-critical: they are the wire format. Reordering an enum or inserting a
variant without an explicit #[codec(index = N)] is a hard fork.
-
TokenIssuance,NftIssuance,TokenData(V0) payload layouts — seecommon/src/chain/tokens/. -
VRFReturn/VRFPublicKeyinternals (schnorrkel types). - The sighash pre-image: what a signature actually commits to is a separate encoding, built in
common/src/chain/transaction/signature/sighash/, and is not the transaction encoding above. - Merkle tree construction for
tx_merkle_root/witness_merkle_root. - P2P wire framing, which wraps these structures in its own message envelope — see
p2p/README.md.
- Home
- Installing Mintlayer
- Upgrade Mintlayer
- Node
- Addresses
- Wallet CLI
- Wallet RPC
- API
- Advanced Tools
- Guides
- CHANGELOG