Merkle Proof Verification

For: Application developers and compliance teams who need to independently verify that execution records have not been tampered with after the fact.

Axemere Console — Proofs

Developer Integration | Delegation Tokens | Merkle Proof Verification

Every action executed through Axemere Gateway produces an execution record. Records are hashed, appended to an ordered ledger, and periodically batched into a SHA-256 Merkle tree. The Merkle root is signed by the control plane and anchored to an external timestamp authority (RFC 3161 TSA). You can use the inclusion proof to independently verify that a record existed at a specific point in time and has not been altered.

Table of Contents


How It Works

RFC 3161 TSAControl PlaneGatewayApplicationRFC 3161 TSAControl PlaneGatewayApplicationBatch job runs periodicallyAction requestExecute actionBuild ExecutionRecord + signHash: SHA-256(JCS(record))SubmitRecordHash(record_hash)Append to ledger (ledger_seq assigned)Receipt (ledger_seq)Response includes record_id + record_hashCollect N records into batchBuild SHA-256 Merkle treeSign root metadata (Ed25519)Anchor root_hashRFC 3161 timestamp tokenGET /v1/verify/{record_hash}InclusionProof (path + root + anchor)Verify locally

Execution Record Hash

Each execution record is hashed using:

  1. JCS canonicalization (RFC 8785) of the full execution record JSON document: deterministic key order, no insignificant whitespace.
  2. SHA-256 of the canonical bytes.

The result is the record_hash, a 64-character hex string returned in every action response as X-MVGC-Record-Hash and in the response body.

You can independently compute the hash from a stored record copy to verify it matches what is on the ledger.

The record hash covers the signed execution record body, not the HTTP response envelope. Contact your Axemere representative for the full record schema.


Ledger and Batching

record_hash_1
ledger_seq=1000

record_hash_2
ledger_seq=1001

record_hash_3
ledger_seq=1002

record_hash_N
ledger_seq=1999

Merkle Batch
batch_id: UUIDv7
leaf_count: 1000
seq 1000-1999

  • Records are appended to the ledger in submission order with an auto-incrementing ledger_seq.
  • The batch job collects records within a sequence range and builds one Merkle tree per batch.
  • Batch size and frequency are configured on the control plane. Default: up to 10,000 records per batch, run every 5 minutes.
  • A record that arrives after a batch closes is included in the next batch.

Merkle Tree Construction

The tree uses SHA-256 throughout with domain-separated prefixes to prevent second-preimage attacks:

leaf_hash_1
= H(0x00 || record_hash_1)

leaf_hash_2
= H(0x00 || record_hash_2)

leaf_hash_3
= H(0x00 || record_hash_3)

leaf_hash_4
= H(0x00 || record_hash_4)

internal_1
= H(0x01 || L1 || L2)

internal_2
= H(0x01 || L3 || L4)

root_hash
= H(0x01 || I1 || I2)

Leaf hash:

leaf_hash = SHA-256( 0x00 || record_hash_bytes )

Internal node hash:

node_hash = SHA-256( 0x01 || left_hash_bytes || right_hash_bytes )

Odd leaf count: When the number of leaves is odd, the last leaf is duplicated for pairing. Leaves are ordered by ascending ledger_seq.


Inclusion Proof Format

The inclusion proof is returned by GET /v1/verify/{record_hash} when the record has been batched:

{
  "schema": "mvgc.inclusion_proof.v1",
  "record_hash": "a1b2c3d4e5f6...",
  "batch_id": "01955f3e-0000-7abc-8def-000000000001",
  "leaf_index": 42,
  "leaf_count": 1000,
  "path": [
    { "side": "right", "hash": "deadbeef..." },
    { "side": "left",  "hash": "cafebabe..." },
    { "side": "right", "hash": "12345678..." }
  ],
  "root": {
    "schema": "mvgc.merkle_root.v1",
    "batch_id": "01955f3e-0000-7abc-8def-000000000001",
    "org_id": "01955f3e-0000-7abc-8def-000000000000",
    "created_at": "2026-03-12T10:05:00Z",
    "hash_alg": "sha256",
    "leaf_count": 1000,
    "ledger_seq_min": 5000,
    "ledger_seq_max": 5999,
    "root_hash": "abcdef01...",
    "anchor": {
      "type": "rfc3161",
      "ref": "<base64-encoded TSA token>",
      "published_at": "2026-03-12T10:05:03Z"
    },
    "sig": { "alg": "ed25519", "kid": "kid_cp_1", "sig": "<base64url>" }
  }
}
FieldDescription
record_hashThe leaf value being proved
batch_idIdentifies the Merkle batch
leaf_index0-based position of this leaf in the ordered batch
leaf_countTotal number of leaves in the batch
pathSibling hashes from leaf to root; side is the sibling's position
rootSigned root metadata (see Root Metadata Schema)

path[0] is the sibling of the leaf; path[last] is the sibling of the child that feeds the root.


Retrieving a Proof

GET /v1/verify/{record_hash}
Authorization: Bearer <api_key>

Response — verified (200 OK):

{
  "status": "verified",
  "record_hash": "a1b2c3d4e5f6...",
  "inclusion_proof": { "...": "InclusionProof object" }
}

Response — pending inclusion (200 OK):

{
  "status": "pending_inclusion",
  "record_hash": "a1b2c3d4e5f6..."
}
StatusMeaning
verifiedRecord has been included in a Merkle batch; full proof is available
pending_inclusionRecord is in the ledger but not yet batched; retry after the next batch window

404 is returned when the record_hash is not known to the gateway at all.


Verification Algorithm

Step-by-step

Given an inclusion proof, verify it as follows:

  1. Verify the root.sig Ed25519 signature against the CP's public key (kid from the signature envelope). This confirms the root was produced by the control plane.
  2. Verify the root.anchor.ref if you require external time-binding (see External Anchoring).
  3. Compute the leaf hash: h = SHA-256( 0x00 || unhex(record_hash) )
  4. Walk path from index 0 to last:
    • If step.side == "right": h = SHA-256( 0x01 || h || unhex(step.hash) )
    • If step.side == "left": h = SHA-256( 0x01 || unhex(step.hash) || h )
  5. Assert h == unhex(root.root_hash)

If step 5 holds and step 1 verified, the record was included in the signed batch and has not been altered.

Go example

import (
    "crypto/sha256"
    "encoding/hex"
    "fmt"
)

func VerifyInclusion(recordHash string, proof InclusionProof) error {
    recordBytes, err := hex.DecodeString(recordHash)
    if err != nil {
        return fmt.Errorf("decode record_hash: %w", err)
    }

    // Step 1: compute leaf hash
    h := leafHash(recordBytes)

    // Step 2: walk proof path
    for _, step := range proof.Path {
        sibling, err := hex.DecodeString(step.Hash)
        if err != nil {
            return fmt.Errorf("decode path hash: %w", err)
        }
        if step.Side == "right" {
            h = nodeHash(h, sibling)
        } else {
            h = nodeHash(sibling, h)
        }
    }

    // Step 3: compare to root
    rootBytes, err := hex.DecodeString(proof.Root.RootHash)
    if err != nil {
        return fmt.Errorf("decode root_hash: %w", err)
    }
    if !bytes.Equal(h, rootBytes) {
        return fmt.Errorf("proof verification failed: computed %x, want %x", h, rootBytes)
    }
    return nil
}

func leafHash(b []byte) []byte {
    h := sha256.New()
    h.Write([]byte{0x00})
    h.Write(b)
    return h.Sum(nil)
}

func nodeHash(left, right []byte) []byte {
    h := sha256.New()
    h.Write([]byte{0x01})
    h.Write(left)
    h.Write(right)
    return h.Sum(nil)
}

Python example

import hashlib

def leaf_hash(record_hash_hex: str) -> bytes:
    record_bytes = bytes.fromhex(record_hash_hex)
    return hashlib.sha256(b"\x00" + record_bytes).digest()

def node_hash(left: bytes, right: bytes) -> bytes:
    return hashlib.sha256(b"\x01" + left + right).digest()

def verify_inclusion(record_hash: str, path: list[dict], root_hash: str) -> bool:
    h = leaf_hash(record_hash)
    for step in path:
        sibling = bytes.fromhex(step["hash"])
        if step["side"] == "right":
            h = node_hash(h, sibling)
        else:
            h = node_hash(sibling, h)
    return h == bytes.fromhex(root_hash)

External Anchoring (RFC 3161 TSA)

When anchoring is enabled, the control plane submits the root_hash to an external RFC 3161 timestamp authority (TSA) after building the Merkle batch. The TSA issues a signed timestamp token that cryptographically binds the root hash to a wall-clock time.

RFC 3161 TSAControl PlaneRFC 3161 TSAControl PlaneTimeStampRequest(SHA-256(root_hash))TimeStampResponse (signed token)Store token in anchor.refSign root metadata (Ed25519)

The anchor object in root metadata:

"anchor": {
  "type": "rfc3161",
  "ref": "<base64-encoded DER TimeStampToken>",
  "published_at": "2026-03-12T10:05:03Z"
}

To verify the anchor independently:

  1. Base64-decode anchor.ref to get the DER-encoded TimeStampToken.
  2. Verify the TimeStampToken signature against the TSA's public certificate.
  3. Extract messageImprint from the token and verify it equals SHA-256(unhex(root_hash)).
  4. The genTime in the token is the TSA's assertion of when the root was submitted.

Every Merkle batch is anchored. If anchor is absent from a root metadata object, the batch was produced before anchoring was active and the proof is still valid for tamper detection.


Root Metadata Schema

The signed root object embedded in every inclusion proof:

FieldDescription
schema"mvgc.merkle_root.v1"
batch_idUUIDv7, unique batch identifier
org_idOrganisation that owns this batch
created_atWhen the batch was finalized
hash_algAlways "sha256" in v1
leaf_countNumber of records in the batch
ledger_seq_minFirst ledger_seq in this batch
ledger_seq_maxLast ledger_seq in this batch
root_hashHex-encoded SHA-256 Merkle root
anchorOptional external anchor (RFC 3161)
sigEd25519 signature over JCS-canonical root metadata

Proof Availability Timing

Inclusion proofs are not available immediately after a request; the record must be batched first.

record_hash returned in response

SubmitRecordHash accepted, ledger_seq assigned

GET /v1/verify returns pending_inclusion

Batch job runs, Merkle tree built and signed

RFC 3161 TSA anchor added

Submitted

Ledgered

PendingInclusion

Verified

Anchored

Typical timings (defaults, operator-configurable):

StateTypical delay
Request → ledger< 100ms
Ledger → batch (pending → verified)Up to 5 minutes
Batch → anchor (verified → anchored)Up to 30 seconds after batch

Poll GET /v1/verify/{record_hash} until status == "verified" to retrieve the proof. There is no webhook or callback for batch completion.


Security Properties

PropertyGuarantee
Tamper detectionAny modification to a record changes its hash, invalidating the inclusion proof
Orderingledger_seq is monotonically increasing; records cannot be silently reordered within a batch
Non-repudiationSigned root (sig) proves the CP produced this specific batch
External time-bindingRFC 3161 anchor proves the root existed at or before the TSA's genTime
Domain separation0x00 leaf prefix and 0x01 node prefix prevent second-preimage attacks between levels

The Merkle ledger protects against post-hoc record alteration. It does not protect against a record being omitted from the ledger entirely; omission detection requires comparing the ledger_seq range against expected record counts.


Troubleshooting

SymptomLikely causeFix
status: pending_inclusion persistsBatch job not running or batch interval is longCheck CP batch job health; wait for the next batch window
404 on GET /v1/verify/{hash}Record hash is wrong or record not yet submitted to ledgerVerify the hash from the original response; check network connectivity between gateway and CP
Verification step 5 fails (hash mismatch)Proof path or root hash is corruptedRe-fetch the proof; check for data corruption in storage
Root sig verification failsWrong CP public key or root was produced by a different CP instanceVerify kid matches the CP's current keyring; check key rotation state
Anchor verification failsTSA certificate not trusted or token corruptedVerify the TSA cert chain independently; check anchor.published_at against TSA logs

See Also