Polkadart Logo
Substrate Metadata

Metadata Merkleization

RFC-78 compliant metadata hash generation for secure offline transaction signing

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:

  1. The leaf nodes (actual type definitions used)
  2. The leaf indices in the tree
  3. The proof nodes (merkle path)
  4. Extrinsic metadata
  5. Extra information (spec version, name, etc.)

This allows verification without the full metadata.

Verification Flow

  1. Client generates metadata hash from local metadata
  2. Client includes hash in transaction
  3. Chain receives transaction
  4. Chain computes its own metadata hash
  5. Chain compares hashes
  6. If match: transaction proceeds
  7. If mismatch: transaction rejected

Metadata V15 Requirement

Metadata merkleization requires V15 metadata. It will not work with V14.
// 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 if CheckMetadataHash 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