Distributed Systems & CryptographyApril 5, 2026

Building An Onchain Twap Oracle From Trade Logs Using Merkle Proofs

C

Written by

Cipher Stone

I got curious about something that’s annoyingly under-explained in DeFi: how to build a time-weighted average price (TWAP) oracle without trusting a centralized indexer. My weekend project was a tiny “oracle pipeline” that:

  1. Takes raw swap events from a decentralized exchange,
  2. Builds a Merkle tree over those trade logs (so you get a tamper-evident commitment),
  3. Lets an onchain contract verify the set of logs using a Merkle proof,
  4. Computes a TWAP using only the verified trades.

Below is what I built, why each piece exists, and a working end-to-end example you can run locally.


What problem I set out to solve

Many DeFi TWAP oracles depend on an “offchain reader” (indexer) that pulls events and reports prices. The oracle contract then trusts the reported data implicitly.

I wanted a model where:

  • The offchain part can gather trades freely,
  • But the onchain part only accepts trades if they’re included in an authenticated set (Merkle root),
  • And the TWAP math uses only those authenticated trades.

Definitions (first time terms):

  • TWAP (Time-Weighted Average Price): an average price computed across a time window, weighting each trade by how much time it represents.
  • Merkle tree: a binary tree of hashes; its root hash commits to a whole set of data.
  • Merkle proof: a small list of hashes that lets you verify “this leaf was part of the tree” using only the root.

System design (the moving parts)

Here’s the pipeline I used:

  1. Offchain script:

    • Loads a list of swap “logs” (each log includes timestamp, price, and volume).
    • Builds a Merkle tree over the logs.
    • Computes a TWAP from the same logs (for demonstration).
    • Generates a Merkle proof for each trade included.
  2. Onchain contract (Solidity):

    • Stores the Merkle root for a specific TWAP window.
    • Verifies Merkle proofs for submitted trade logs.
    • Recomputes the TWAP deterministically from verified logs.

This approach separates:

  • “Data availability and indexing” (offchain),
  • “Authenticity and computation enforcement” (onchain).

Offchain: build Merkle root and generate proofs (Node.js)

I used merkletreejs to keep the code short but correct.

1) Project setup

npm init -y npm i merkletreejs keccak256 npm i -D typescript ts-node @types/node

2) twap-merkle.ts

import { MerkleTree } from "merkletreejs"; import keccak256 from "keccak256"; type TradeLog = { timestamp: number; // seconds since epoch (or any consistent unit) priceX96: bigint; // example fixed-point price; here we use an integer for simplicity volume: bigint; // example volume }; // Hash each leaf deterministically. // The contract must reproduce the same hashing scheme. function leafHash(log: TradeLog): Buffer { const ts = Buffer.from(log.timestamp.toString(16).padStart(16, "0"), "hex"); const price = Buffer.from(log.priceX96.toString(16), "hex"); const vol = Buffer.from(log.volume.toString(16), "hex"); // keccak256( timestamp || price || volume ) return keccak256(Buffer.concat([ts, price, vol])); } function encodeTradesForDemo(): TradeLog[] { // I used fake-but-consistent trade logs with increasing timestamps. // In a real setup, you'd map DEX swap events into this structure. return [ { timestamp: 1000, priceX96: 10n * 2n ** 96n, volume: 5n }, { timestamp: 1020, priceX96: 12n * 2n ** 96n, volume: 2n }, { timestamp: 1080, priceX96: 11n * 2n ** 96n, volume: 7n }, ]; } function main() { const trades = encodeTradesForDemo(); const leaves = trades.map(leafHash); const tree = new MerkleTree(leaves, keccak256, { sortPairs: true }); const merkleRoot = tree.getHexRoot(); console.log("Merkle root:", merkleRoot); // Generate proofs per trade. // Each proof is a list of sibling hashes that lets the contract verify inclusion. trades.forEach((t, i) => { const leaf = leaves[i]; const proof = tree.getProof(leaf).map((x) => x.data.toString("hex")); console.log(`Trade ${i} proof:`, proof); }); // For demonstration: compute a simple TWAP using fixed intervals. // Here I do time-weighting based on delta to next trade timestamp. let weightedSum = 0n; let totalTime = 0n; for (let i = 0; i < trades.length; i++) { const cur = trades[i]; const nextTs = (i + 1 < trades.length) ? trades[i + 1].timestamp : trades[i].timestamp; const dt = BigInt(nextTs - cur.timestamp); if (dt > 0n) { weightedSum += BigInt(cur.priceX96) * dt; totalTime += dt; } } const twap = weightedSum / totalTime; console.log("Computed TWAP priceX96:", twap.toString()); } main();

How to run

npx ts-node twap-merkle.ts

You’ll see:

  • a Merkle root,
  • proofs for each trade log,
  • a TWAP value computed offchain (used only as a sanity check).

Important: the onchain contract must use the exact same hashing logic (keccak256(timestamp||price||volume) with the same packing). That’s why I spelled out the encoding in the function above.


Onchain: verify Merkle proofs and recompute the TWAP (Solidity)

Now I’ll implement:

  • setRoot(windowId, root) to commit a TWAP window’s trade set,
  • submitTradeAndAccumulate(...) to verify each submitted trade,
  • finalizeTwap(...) to compute the final TWAP.

1) TwapMerkleOracle.sol

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract TwapMerkleOracle { struct Window { bytes32 merkleRoot; bool initialized; // Accumulators for TWAP computation: // weightedSum = sum(price * dt) uint256 weightedSum; uint256 totalTime; // Track which trades were already accepted to avoid double counting. mapping(bytes32 => bool) seenLeaf; } uint256 public windowCount; mapping(uint256 => Window) private windows; function setRoot(uint256 windowId, bytes32 merkleRoot) external { Window storage w = windows[windowId]; require(!w.initialized, "root already set"); w.merkleRoot = merkleRoot; w.initialized = true; } // Mirror the leaf hashing used offchain. // leaf = keccak256( timestamp || priceX96 || volume ) function leafHash( uint256 timestamp, uint256 priceX96, uint256 volume ) public pure returns (bytes32) { // We pack exactly once: abi.encodePacked gives tightly packed bytes. return keccak256(abi.encodePacked(timestamp, priceX96, volume)); } // Verify a leaf is in the Merkle tree. function verifyProof( bytes32[] calldata proof, bytes32 root, bytes32 leaf ) public pure returns (bool) { bytes32 computed = leaf; for (uint256 i = 0; i < proof.length; i++) { bytes32 sibling = proof[i]; // sortPairs=true in JS means we sort at each step. if (computed <= sibling) { computed = keccak256(abi.encodePacked(computed, sibling)); } else { computed = keccak256(abi.encodePacked(sibling, computed)); } } return computed == root; } // Submit one verified trade. // The contract needs dt to compute a TWAP. In a real oracle, // you'd define dt rules (e.g., based on next trade timestamp). // For this demo, I pass dt explicitly. function submitTradeAndAccumulate( uint256 windowId, uint256 timestamp, uint256 priceX96, uint256 volume, uint256 dt, bytes32[] calldata merkleProof ) external { Window storage w = windows[windowId]; require(w.initialized, "window not initialized"); bytes32 leaf = leafHash(timestamp, priceX96, volume); require(!w.seenLeaf[leaf], "trade already counted"); bool ok = verifyProof(merkleProof, w.merkleRoot, leaf); require(ok, "invalid proof"); // Accumulate TWAP components. // weightedSum += price * dt w.weightedSum += priceX96 * dt; w.totalTime += dt; w.seenLeaf[leaf] = true; } function getTwap(uint256 windowId) external view returns (uint256 twapPriceX96) { Window storage w = windows[windowId]; require(w.totalTime > 0, "no trades"); return w.weightedSum / w.totalTime; } }

2) Why the dt parameter matters

TWAP math depends on how you choose the intervals.

For a straightforward “trade-driven” TWAP demo:

  • each trade contributes for the time until the next trade timestamp,
  • so dt = nextTimestamp - currentTimestamp.

In a real deployment, that dt could be derived from the ordered trade set. Doing it “fully onchain” is more expensive; using an explicit dt keeps this demo focused on the Merkle-verification part.


End-to-end wiring: from JS outputs to Solidity inputs

To keep this post fully runnable without dragging in a full Hardhat test suite, I’ll show the key idea: take the offchain outputs (root + proofs + trade fields) and call:

  1. setRoot(windowId, merkleRoot)
  2. submitTradeAndAccumulate(windowId, timestamp, priceX96, volume, dt, proof)
  3. getTwap(windowId)

Example dt values for the demo logs

If the trade logs are:

  • t0=1000
  • t1=1020
  • t2=1080

Then:

  • for trade 0: dt0 = 1020 - 1000 = 20
  • for trade 1: dt1 = 1080 - 1020 = 60
  • trade 2 has no “next trade” in this list; for the demo I treat its dt as 0 (it won’t affect TWAP).

That means you submit trade 0 and 1 with dt > 0, and you can submit trade 2 with dt=0 (optional).


Notes I learned the hard way

1) Hash encoding must match exactly

Even small packing mismatches (like fixed-size vs variable-size encoding) will break proofs.

That’s why I used:

  • leafHash on the JS side: keccak256(Buffer.concat([timestampBuf, priceBuf, volumeBuf]))
  • and on the Solidity side: keccak256(abi.encodePacked(timestamp, priceX96, volume))

In a production system, I’d formalize the ABI/packing and test it with cross-language vectors.

2) sortPairs must match

My JS Merkle tree used { sortPairs: true }, so the Solidity proof verifier sorts (computed, sibling) each step. If you remove sorting in JS, you must remove it in Solidity too.

3) Double-counting protection is essential

Without seenLeaf, a malicious submitter could replay the same verified trade multiple times and inflate the TWAP.


Conclusion

I built an onchain TWAP oracle pipeline where the offchain indexer gathers trade logs, commits them into a Merkle root, and the onchain contract verifies each trade via Merkle proofs before recomputing a time-weighted average. The big takeaway from tinkering is that “cryptographic trust” in DeFi is less about fancy math and more about getting deterministic hashing, proof verification, and double-count protections exactly right.