Metadata Merkleization
What is Metadata Merkleization?
Metadata merkleization is a security feature defined in RFC-78 that allows offline wallets and hardware devices to verify they're using the correct metadata when signing transactions.
The problem it solves: When signing transactions offline, how do you know the metadata you're using matches what's on-chain? Malicious metadata could trick you into signing something different than what you intended.
The solution: Generate a cryptographic hash (merkle root) of the metadata and include it in the transaction. The chain validates this hash, ensuring the metadata matches.
Why It Matters
For secure offline signing:
- Hardware wallets can verify metadata authenticity
- Air-gapped devices can trust the metadata
- Users can be confident they're signing what they think they're signing
- Prevents metadata-based attacks
Quick Start
import 'package:substrate_metadata/substrate_metadata.dart';
// Create merkleizer from metadata
final merkleizer = MetadataMerkleizer.fromMetadata(
metadata,
decimals: 10, // Token decimals
tokenSymbol: 'DOT', // Token symbol
);
// Get the metadata hash
final metadataHash = hex.encode(merkleizer.digest());
// Use this hash when creating transactions
Creating a Merkleizer
Basic Creation
import 'package:substrate_metadata/substrate_metadata.dart';
// Decode metadata first
final input = Input.fromBytes(metadataBytes);
final prefixed = RuntimeMetadataPrefixed.codec.decode(input);
final metadata = prefixed.metadata;
// Create merkleizer
final merkleizer = MetadataMerkleizer.fromMetadata(
metadata,
decimals: 10,
tokenSymbol: 'DOT',
);
With Optional Parameters
You can provide additional verification parameters:
final merkleizer = MetadataMerkleizer.fromMetadata(
metadata,
decimals: 10,
tokenSymbol: 'DOT',
specVersion: 1003003, // Optional: verify spec version
specName: 'polkadot', // Optional: verify spec name
base58Prefix: 0, // Optional: verify SS58 prefix
);
If provided, these values will be validated against the metadata's System.Version constant.
Generating the Metadata Hash
The metadata hash (digest) is a 32-byte Blake3 hash:
// Generate the hash
final hashBytes = merkleizer.digest();
// Convert to hex for use in transactions
final metadataHash = hex.encode(hashBytes);
print('Metadata hash: 0x$metadataHash');
The digest can be called multiple times - it's cached after the first call.
Using in Transactions
With SigningPayload
When creating a signing payload, include the metadata hash:
import 'package:polkadart/polkadart.dart';
final payload = SigningPayload(
method: encodedCall,
specVersion: runtimeVersion.specVersion,
transactionVersion: runtimeVersion.transactionVersion,
genesisHash: encodeHex(genesisHash),
blockHash: encodeHex(currentBlockHash),
blockNumber: currentBlockNumber,
eraPeriod: 64,
nonce: nonce,
tip: 0,
metadataHash: metadataHash, // ← Include the hash
).encode(registry);
With ExtrinsicPayload
Also include it when building the final extrinsic:
final extrinsic = ExtrinsicPayload(
signer: wallet.bytes(),
method: encodedCall,
signature: signature,
eraPeriod: 64,
blockNumber: currentBlockNumber,
nonce: nonce,
tip: 0,
metadataHash: metadataHash, // ← Include the hash
).encode(registry, SignatureType.sr25519);
Generating Proofs
The merkleizer can generate proofs for specific transactions:
Proof for Complete Extrinsic
// For a fully encoded extrinsic
final proof = merkleizer.getProofForExtrinsic(
extrinsicBytes,
additionalSignedBytes, // Can be null for unsigned
);
// The proof contains the merkle path
Proof for Extrinsic Payload
// For just the signing payload
final proof = merkleizer.getProofForExtrinsicPayload(payloadBytes);
Proof for Extrinsic Parts
For more control:
final proof = merkleizer.getProofForExtrinsicParts(
callData, // The encoded call
includedInExtrinsic, // Encoded signed extensions
includedInSignedData, // Encoded additional signed data
);
Complete Example
Here's a full example of creating a transaction with metadata hash:
import 'package:substrate_metadata/substrate_metadata.dart';
import 'package:polkadart/polkadart.dart';
Future<void> transferWithMetadataHash() async {
final provider = Provider.fromUri(Uri.parse('wss://rpc.polkadot.io'));
final polkadot = Polkadot(provider);
final wallet = await KeyPair.sr25519.fromUri("//Alice");
// Get chain information
final runtimeVersion = await polkadot.rpc.state.getRuntimeVersion();
final currentBlockNumber = (await polkadot.query.system.number()) - 1;
final currentBlockHash = await polkadot.query.system.blockHash(currentBlockNumber);
final genesisHash = await polkadot.query.system.blockHash(0);
final nonce = await polkadot.rpc.system.accountNextIndex(wallet.address);
// Get metadata and create merkleizer
final metadataBytes = await polkadot.rpc.state.getMetadata();
final input = Input.fromBytes(metadataBytes);
final prefixed = RuntimeMetadataPrefixed.codec.decode(input);
final merkleizer = MetadataMerkleizer.fromMetadata(
prefixed.metadata,
decimals: 10,
tokenSymbol: 'DOT',
);
// Generate metadata hash
final metadataHash = hex.encode(merkleizer.digest());
print('Metadata hash: 0x$metadataHash');
// Create the call
final multiAddress = $MultiAddress().id(wallet.publicKey.bytes);
final transferCall = polkadot.tx.balances.transferKeepAlive(
dest: multiAddress,
value: BigInt.from(1000000000000), // 1 DOT
);
final encodedCall = transferCall.encode();
// Create signing payload WITH metadata hash
final payload = SigningPayload(
method: encodedCall,
specVersion: runtimeVersion.specVersion,
transactionVersion: runtimeVersion.transactionVersion,
genesisHash: encodeHex(genesisHash),
blockHash: encodeHex(currentBlockHash),
blockNumber: currentBlockNumber,
eraPeriod: 64,
nonce: nonce,
tip: 0,
metadataHash: metadataHash, // ← Metadata hash
).encode(polkadot.registry);
// Sign the payload
final signature = wallet.sign(payload);
// Create extrinsic WITH metadata hash
final extrinsic = ExtrinsicPayload(
signer: wallet.bytes(),
method: encodedCall,
signature: signature,
eraPeriod: 64,
blockNumber: currentBlockNumber,
nonce: nonce,
tip: 0,
metadataHash: metadataHash, // ← Metadata hash
).encode(polkadot.registry, SignatureType.sr25519);
// Submit the transaction
final author = AuthorApi(provider);
await author.submitAndWatchExtrinsic(extrinsic, (data) {
print('Status: ${data.type} - ${data.value}');
});
}
Understanding the Digest Structure
The metadata digest (V1 format) contains:
class MetadataDigest {
final Uint8List typeInformationTreeRoot; // Merkle root of type tree
final Uint8List extrinsicMetadataHash; // Hash of extrinsic metadata
final int specVersion; // Runtime spec version
final String specName; // Chain name
final int base58Prefix; // SS58 address prefix
final int decimals; // Token decimals
final String tokenSymbol; // Token symbol
}
The final hash is Blake3(digest).
The Type Information Tree
The merkleizer builds a binary tree from all types used in:
- Extrinsic format (address, signature, call types)
- Signed extensions (types in extra and additional signed data)
Only types actually used are included, making the tree minimal.
Proof Structure
A proof contains:
- The leaf nodes (actual type definitions used)
- The leaf indices in the tree
- The proof nodes (merkle path)
- Extrinsic metadata
- Extra information (spec version, name, etc.)
This allows verification without the full metadata.
Verification Flow
- Client generates metadata hash from local metadata
- Client includes hash in transaction
- Chain receives transaction
- Chain computes its own metadata hash
- Chain compares hashes
- If match: transaction proceeds
- If mismatch: transaction rejected
Metadata V15 Requirement
// Check metadata version
if (prefixed.metadata.version != 15) {
throw Exception('Metadata merkleization requires V15 metadata');
}
Performance Considerations
Creating the merkleizer is expensive:
- It processes all types in the metadata
- Builds a complete type tree
- Computes merkle hashes
Cache the Merkleizer
Create it once per metadata version and reuse it for all transactions.class MetadataHashCache {
final Map<int, String> _cache = {};
MetadataMerkleizer? _merkleizer;
String getHash(RuntimeMetadata metadata) {
final version = metadata.extrinsic.version;
if (_cache.containsKey(version)) {
return _cache[version]!;
}
// Create new merkleizer
_merkleizer = MetadataMerkleizer.fromMetadata(
metadata,
decimals: 10,
tokenSymbol: 'DOT',
);
final hash = hex.encode(_merkleizer!.digest());
_cache[version] = hash;
return hash;
}
}
Best Practices
Always Use for Offline Signing
If you're signing transactions offline (hardware wallets, air-gapped devices), always include the metadata hash.Verify Chain Support
Not all chains support metadata hash yet. Check the signed extensions to see ifCheckMetadataHash is present.Match Metadata Versions
The metadata used to generate the hash MUST match the on-chain metadata version.Cache the Hash
Generate the hash once per metadata version and cache it. The hash doesn't change unless the metadata changes.Checking Chain Support
To check if a chain supports metadata hash:
bool supportsMetadataHash(MetadataTypeRegistry registry) {
final extensions = registry.signedExtensions;
return extensions.any((ext) => ext.identifier == 'CheckMetadataHash');
}
// Usage
if (supportsMetadataHash(registry)) {
print('Chain supports metadata hash');
// Use metadata hash in transactions
} else {
print('Chain does not support metadata hash');
// Omit metadata hash (set to null)
}
Error Handling
try {
final merkleizer = MetadataMerkleizer.fromMetadata(
metadata,
decimals: 10,
tokenSymbol: 'DOT',
specVersion: expectedVersion,
);
final hash = hex.encode(merkleizer.digest());
} on Exception catch (e) {
if (e.toString().contains('mismatch')) {
print('Metadata verification failed: $e');
// Metadata doesn't match expected values
} else {
print('Error generating metadata hash: $e');
}
}
Next Steps
- Complete Example Guide - Full transaction example
- ChainInfo API - High-level metadata operations
- Type Registry - Understanding type resolution