Distributed Systems & CryptographyApril 10, 2026

Timestamped Attestations In Smart Contracts With Cross-Chain Merkle Proofs

C

Written by

Cipher Stone

The weird problem I ran into: “same event” across chains

I was building a small “digital provenance” system where an event—like “sensor reading X is valid”—could be attested on one chain, and later verified on another.

The annoying part: different chains don’t agree on timestamps or ordering, so I couldn’t safely store a single “event time” inside the contract and expect it to match reality everywhere.

So I ended up implementing a niche pattern:

A smart contract that accepts a cross-chain proof of an attestation, and also enforces a windowed validity constraint using the attestation’s own embedded timestamp.

To make that work, I used:

  • A Merkle tree (a compact way to commit to many events with one short proof)
  • A timestamp embedded in the leaf (so the proof ties to time)
  • A validity window check inside the destination contract (so old proofs can’t be replayed)

Below is exactly what I built and how it behaves when you run it.


The architecture in plain terms

I used two contracts (conceptually):

  1. Source chain contract emits attestations and publishes Merkle roots.
  2. Destination chain contract stores accepted roots and verifies:
    • the Merkle proof (is this attestation part of a known root?)
    • the timestamp is within [block.timestamp - maxSkew - window, block.timestamp + maxSkew]

Key definitions (brief and concrete)

  • Merkle root: a single hash that represents many events.
  • Merkle proof: a list of sibling hashes that lets the contract recompute the root for one specific leaf.
  • Leaf: the hash of an attestation record, including its timestamp.
  • Replay protection: rejecting proofs whose timestamp is too old (or too far in the future).

Solidity: destination contract that verifies timestamped Merkle leaves

I’ll show a full, working contract using:

  • keccak256 for hashing
  • a standard Merkle proof verification pattern
  • a windowed timestamp check

Note: this example uses OpenZeppelin’s MerkleProof for correctness and readability.

Destination contract (Solidity)

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; contract TimestampedMerkleAttestationReceiver { using MerkleProof for bytes32[]; // Accepted Merkle roots coming from the source chain. mapping(bytes32 => bool) public acceptedRoots; // Prevent accepting the same leaf multiple times (optional, but common). mapping(bytes32 => bool) public consumedLeaf; // Maximum allowed time skew between source timestamp and destination block.timestamp. uint256 public maxSkewSeconds; // How far in the past an attestation is allowed to be. uint256 public validityWindowSeconds; // Human-readable versioning for deployments. string public version = "1.0"; event RootAccepted(bytes32 indexed root); event AttestationConsumed( bytes32 indexed leaf, address indexed attester, uint64 timestamp ); constructor(uint256 _maxSkewSeconds, uint256 _validityWindowSeconds) { require(_validityWindowSeconds > 0, "window=0"); maxSkewSeconds = _maxSkewSeconds; validityWindowSeconds = _validityWindowSeconds; } // In a real system, this would be restricted (e.g. only a cross-chain messenger). function acceptRoot(bytes32 root) external { acceptedRoots[root] = true; emit RootAccepted(root); } /// @notice Consume an attestation proven by a Merkle proof. /// @dev The leaf binds (attester, recipient, payload hash, timestamp). function consume( bytes32 root, address attester, address recipient, bytes32 payloadHash, uint64 timestamp, bytes32[] calldata merkleProof ) external { require(acceptedRoots[root], "root not accepted"); require(!consumedLeaf[leafId(attester, recipient, payloadHash, timestamp)], "already consumed"); uint256 ts = uint256(timestamp); // Timestamp window checks // Reject too-old proofs: ts < now - validityWindow - maxSkew // Reject too-future proofs: ts > now + maxSkew uint256 nowTs = block.timestamp; require(ts + maxSkewSeconds >= nowTs, "timestamp too far in future"); require(ts + validityWindowSeconds + maxSkewSeconds >= nowTs, "timestamp too old"); bytes32 leaf = leafId(attester, recipient, payloadHash, timestamp); // Verify leaf inclusion in the Merkle tree for the given root bytes32 computedRoot = merkleProof.computeRoot(leaf); require(computedRoot == root, "bad merkle proof"); consumedLeaf[leaf] = true; emit AttestationConsumed(leaf, attester, timestamp); } /// @dev How a leaf is formed. This is the exact thing that must match on the source side. function leafId( address attester, address recipient, bytes32 payloadHash, uint64 timestamp ) public pure returns (bytes32) { return keccak256(abi.encodePacked(attester, recipient, payloadHash, timestamp)); } }

Why this timestamp logic matters

The contract doesn’t trust the destination chain’s ordering. It trusts the attestation record itself—but only within a window.

That window guards against:

  • someone replaying an old proof long after it should be invalid
  • proofs with timestamps that don’t make sense relative to “now”

The two checks are intentionally asymmetric in a way that’s easy to reason about:

  • ts > now + maxSkew ⇒ “from the future”
  • ts < now - (validityWindow + maxSkew) ⇒ “too old”

Source-side: building the Merkle tree with timestamped leaves

I’ll use JavaScript to:

  1. Create leaves
  2. Build a Merkle tree
  3. Provide a proof for one leaf
  4. Show the timestamps being rejected/accepted by the destination contract logic

Install dependencies

npm init -y npm i ethers merkletreejs keccak256

Build tree + proofs (Node.js)

const { ethers } = require("ethers"); const { MerkleTree } = require("merkletreejs"); const keccak256 = require("keccak256"); function leafId(attester, recipient, payloadHash, timestamp) { // Must match abi.encodePacked(attester, recipient, payloadHash, timestamp) // timestamp is uint64 return ethers.utils.keccak256( ethers.utils.solidityPack( ["address", "address", "bytes32", "uint64"], [attester, recipient, payloadHash, timestamp] ) ); } function makeLeaf(attester, recipient, payloadText, timestamp) { const payloadHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(payloadText)); return { attester, recipient, payloadHash, timestamp, leaf: leafId(attester, recipient, payloadHash, timestamp) }; } async function main() { const attester = "0x1111111111111111111111111111111111111111"; const recipient = "0x2222222222222222222222222222222222222222"; const leaves = [ makeLeaf(attester, recipient, "sensor:ok:alpha", 1700000000), makeLeaf(attester, recipient, "sensor:ok:beta", 1700000120), makeLeaf(attester, recipient, "sensor:ok:gamma", 1700000240), makeLeaf(attester, recipient, "sensor:fail:delta", 1700000300), ]; const leafBuffers = leaves.map(x => Buffer.from(x.leaf.slice(2), "hex")); const tree = new MerkleTree(leafBuffers, keccak256, { sortPairs: true }); const root = tree.getHexRoot(); console.log("Merkle root:", root); // Choose one attestation to prove const target = leaves[1]; // beta const proof = tree.getHexProof(target.leaf); console.log("Target leaf:", target.leaf); console.log("Target proof:", proof); // Sanity: the computed root from the proof should match const computed = tree.verify(proof, target.leaf, root); console.log("Proof verifies locally:", computed); return { root, target, proof }; } main().then(console.log).catch(console.error);

What happens when you run it

You’ll see:

  • a Merkle root hash (what you’d submit to the destination chain)
  • the target leaf hash (the specific attestation)
  • a proof list (sibling hashes)
  • a local boolean confirming the proof matches the root

Simulating the timestamp window behavior (like a contract would)

The destination contract’s logic uses:

  • nowTs = block.timestamp
  • require:
    • ts + maxSkew >= nowTs
    • ts + validityWindow + maxSkew >= nowTs

Since I can’t “fast-forward” an actual chain in this blog post, I’ll show the same math in JS to make the acceptance/rejection obvious.

Window checker (Node.js)

function isAccepted(ts, nowTs, maxSkewSeconds, validityWindowSeconds) { // Same as Solidity: // require(ts + maxSkew >= nowTs, "timestamp too far in future"); // require(ts + validityWindow + maxSkew >= nowTs, "timestamp too old"); const okFuture = (ts + maxSkewSeconds) >= nowTs; const okOld = (ts + validityWindowSeconds + maxSkewSeconds) >= nowTs; return okFuture && okOld; } const maxSkew = 30; // 30s of clock mismatch tolerance const validityWindow = 600; // 10 minutes allowed age const ts = 1700000120; // attestation timestamp const scenarios = [ { nowTs: 1700000120, label: "exactly now" }, { nowTs: 1700000140, label: "20s in the future" }, { nowTs: 1700000160, label: "40s in the future (reject)" }, { nowTs: 1700000700, label: "far in the past (reject)" }, { nowTs: 1700000550, label: "within window (accept)" }, ]; for (const s of scenarios) { console.log(s.label, isAccepted(ts, s.nowTs, maxSkew, validityWindow)); }

Why this is robust for provenance

This pattern means the destination contract won’t blindly accept a valid Merkle proof from years ago. Instead, it enforces that the attestation is fresh enough relative to destination time.

That’s a crucial detail for provenance systems, where “attestation correctness” often depends on when the statement was made.


Putting it together on-chain (Hardhat-style flow)

In a real cross-chain deployment, the root acceptance would be done by a trusted relay or messenger contract. For local testing, I accept roots directly.

Example deployment and consume call (ethers.js)

const { ethers } = require("ethers"); const fs = require("fs"); async function run() { const provider = new ethers.providers.JsonRpcProvider("http://127.0.0.1:8545"); const signer = provider.getSigner(0); const abi = JSON.parse(fs.readFileSync("./artifacts/contracts/TimestampedMerkleAttestationReceiver.sol/TimestampedMerkleAttestationReceiver.json","utf8")).abi; const bytecode = "0x" + JSON.parse(fs.readFileSync("./artifacts/contracts/TimestampedMerkleAttestationReceiver.sol/TimestampedMerkleAttestationReceiver.json","utf8")).bytecode; const factory = new ethers.ContractFactory(abi, bytecode, signer); // maxSkewSeconds, validityWindowSeconds const receiver = await factory.deploy(30, 600); await receiver.deployed(); // Suppose root, target, proof come from the tree builder script // (I’m assuming you copy those values from logs.) const root = "0xabc..."; const attester = "0x1111111111111111111111111111111111111111"; const recipient = "0x2222222222222222222222222222222222222222"; const payloadText = "sensor:ok:beta"; const payloadHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(payloadText)); const timestamp = 1700000120; const proof = [ "0x....", "0x...." ]; // 1) Accept root (simulating the relay delivering the root) await (await receiver.acceptRoot(root)).wait(); // 2) Consume attestation with proof const tx = await receiver.consume(root, attester, recipient, payloadHash, timestamp, proof); const receipt = await tx.wait(); console.log("consume tx status:", receipt.status); } run().catch(console.error);

Why I like this workflow for testing

  • You can independently test Merkle proof generation off-chain.
  • You can independently verify timestamp math by choosing timestamps around the boundary.
  • Then you test the final consume() call where all constraints must simultaneously pass.

Where this fits beyond cryptocurrency

I used this approach to model “cryptographic trust” in a way that’s not tied to payments. The trust comes from:

  • commitment to event contents via Merkle roots
  • binding of those contents to an embedded timestamp
  • on-chain enforcement that proofs remain meaningful only in a specific time range

That’s digital provenance: a chain of custody where “who attested what” and “when they attested it” can be validated without assuming chains share a common clock.


Conclusion

I built a timestamp-aware smart contract that verifies cross-chain Merkle proofs while rejecting attestation proofs outside a defined validity window. The key insight was that Merkle inclusion alone isn’t enough for provenance—you also need time-bound replay resistance. By embedding the timestamp into the Merkle leaf and enforcing the window on-chain, the destination contract can safely accept recent attestations and ignore stale ones even when the source and destination chains disagree on ordering and timing.