Building An Onchain Twap Oracle From Trade Logs Using Merkle Proofs
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:
- Takes raw swap events from a decentralized exchange,
- Builds a Merkle tree over those trade logs (so you get a tamper-evident commitment),
- Lets an onchain contract verify the set of logs using a Merkle proof,
- 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:
-
Offchain script:
- Loads a list of swap “logs” (each log includes
timestamp,price, andvolume). - Builds a Merkle tree over the logs.
- Computes a TWAP from the same logs (for demonstration).
- Generates a Merkle proof for each trade included.
- Loads a list of swap “logs” (each log includes
-
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:
setRoot(windowId, merkleRoot)submitTradeAndAccumulate(windowId, timestamp, priceX96, volume, dt, proof)getTwap(windowId)
Example dt values for the demo logs
If the trade logs are:
t0=1000t1=1020t2=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:
leafHashon 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.