Timestamped Attestations In Smart Contracts With Cross-Chain Merkle Proofs
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):
- Source chain contract emits attestations and publishes Merkle roots.
- 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:
keccak256for hashing- a standard Merkle proof verification pattern
- a windowed timestamp check
Note: this example uses OpenZeppelin’s
MerkleProoffor 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:
- Create leaves
- Build a Merkle tree
- Provide a proof for one leaf
- 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 >= nowTsts + 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.