Lean Oracle: a committee-signed pull price oracle for CKB, live on testnet
Lean Oracle gives any CKB contract a price it can verify on chain. It covers BTC, ETH, SOL, USDT and CKB. A committee of publishers signs one price update every second. A public mirror serves the signed updates. Any project moves its own price cell forward with one of them, and the type script checks the committee’s signatures before it accepts the price.
The publishers are permissioned: a fixed, published set of keys. Our aim is for those keys to belong to teams the Nervos community already knows and trusts. This post explains the whole system: what is signed, how the price is computed, what the contracts check, who can change what, and what is still missing. At the end we ask teams to run a publisher.
Everything is open source and running on testnet today:
- Code: on GitHub, repository
calledAdo/lean-oracle-v2(contracts, publisher, mirror, SDK, deploy tool) - Docs and feed pages: GitHub Pages,
calledado.github.io/lean-oracle-v2 - Public testnet mirror:
64-227-40-35.sslip.io(try the path/v1/updates/latest?ids=Crypto.CKB/USDT) - SDK:
lean-oracle-sdkon npm (2.1.0),npm install lean-oracle-sdk
(New accounts here cannot post links, so names and paths are written out in full. Every file mentioned below is in the repository.)
Why CKB needs this, and why it has this shape
Lending, perpetuals, options, stablecoins, liquidations, up/down rounds and settlement at a fixed time all need a price the contract can trust. On CKB this is harder than on an account chain, for three reasons:
- A script cannot read the clock or call out. It sees only the transaction. A price must arrive inside the transaction, and the script must be able to prove it is authentic.
- A shared cell can be spent once per block. One public “oracle cell” that everyone reads and someone keeps updating is a point of contention. It also needs a keeper, and someone has to pay for it.
- Pushing prices costs capacity and fees. Updating a cell every second for 26 feeds, whether or not anyone needs those prices, wastes money.
So Lean Oracle is a pull oracle, in the family of Pyth and its Hermes service, adapted to cells:
- Prices are signed off chain, all the time, and cost nothing until someone uses one.
- A consumer pulls the signed update it needs, latest or at an exact time, and puts it in its own transaction.
- The on-chain script verifies the signatures and moves the consumer’s own feed cell forward.
- There are no public oracle cells. Each project owns its cells, so nobody else can contend for them, and each project pays only for the prices it uses.
The system in one picture
Exchanges (Binance, OKX, Bybit, Coinbase, Kraken, Bitstamp, Gate, Bitget, KuCoin, MEXC)
│ websocket + REST: books and trades
▼
┌────────────── Committee "majors" (n publishers, quorum ⌊2n/3⌋+1) ──────────────┐
│ each publisher: observe ──► leader proposes ──► each signer re-derives & signs │
│ one Merkle root per tick, one quorum signature over the header │
└───────────────────────────────┬───────────────────────────────────────────────┘
│ finalized, self-verifying updates
▼
Mirror(s): verify, archive, serve (anyone can run one)
│ GET /v1/updates/latest | /at?t= | /range, WS stream
▼
Your transaction: witness = update blob ──► price_feed_type checks signatures + proof
│
▼
Your feed cell (Type ID-unique, your lock) now holds the signed price
│
▼
Your contract reads it as a cell dep and applies YOUR freshness rule
Three on-chain pieces, all in contracts/:
| Contract | What it is |
|---|---|
publisher_set_type |
The committee cell. It holds the publisher keys, the derived quorum, the pause flag and the rotation state. |
price_feed_type |
Feed cells. A feed cell is Type ID-unique, starts zeroed, and only accepts committee-signed prices that are strictly newer than the one it holds. |
lean-oracle-common |
The shared Rust crate: codecs, Merkle proofs, verify_price_update, and consumer helpers you can use in your own scripts. |
Off chain there are the publisher (a Docker image each committee member runs), the mirror (the public read API, which needs no trust), and the SDK.
1. The committee: permissioned, and why
A committee is one publisher_set_type cell plus a signed configuration (section 4). It holds n publisher keys (1 to 9) and the quorum q = ⌊2n/3⌋ + 1. Every price needs signatures from q distinct keys in the committee.
Why permissioned rather than open? A price oracle is only as good as the identities behind its keys. In an open, stake-weighted set, an attacker can buy influence, and the stake needed to secure a price is hard to size on a young chain. A small committee of known teams, each with a public reputation in the Nervos ecosystem, gives a simpler guarantee that is easy to reason about:
A wrong price needs q of n named teams to sign it. With 7 publishers that is 5 teams, and every signature is attributable to a key the community knows.
Our proposal is that the launch committee’s keys belong to teams already trusted across Nervos: infrastructure and wallet teams, DeFi teams who consume prices themselves, node operators, and ecosystem organizations. We aim for 7 publishers (the hard cap is 9). Each operator:
- runs the publisher image on its own machine, with its own exchange connections;
- holds its own key, in a file or in AWS KMS (secp256k1, so the key never leaves the HSM);
- computes every price independently, signs only what it computed itself (section 3), and can be identified by the index of its signature in every update.
Testnet today runs one publisher (quorum 1 of 1), operated by us. That is enough to exercise every path end to end, but it is not the trust model we are asking you to rely on. Moving to a multi-team committee is the next milestone, and the reason for this post.
What the protocol guarantees with q = ⌊2n/3⌋+1
With f = n − q faulty or offline publishers tolerated:
- Uniqueness. At most one valid update exists per (committee, tick). Two different signed headers for the same tick would need
2q − n ≥ f + 1common signers, so at least one publisher would have to sign twice. Each publisher persists what it signed before releasing the signature, and refuses to sign a different header for that tick. This matters for settlement: “the first price at or after time T” cannot be cherry-picked between two versions. - Median safety. Each published price is the median of at least q publishers’ observations, so it is bounded by honest observations while fewer than n/3 publishers are faulty.
- Liveness. A crashed or silent leader is replaced within the same tick by the next one in order (section 3).
- Accountability. If two signed headers for one tick ever appear, the mirror keeps both as public evidence (
GET /v1/equivocations), with the signatures that prove who signed what.
2. How a price is computed
The full rules are in docs/oracle-design.md, section 4. In short:
Native pairs only, no conversion
Every feed is priced only from markets that trade exactly that pair. Crypto.BTC/USDT comes from BTC-USDT books, never from BTC/USD times a USDT rate. Publishers never convert. If you need another pair, combine feeds yourself in the same tick, for example CKB/USD = CKB/USDT × USDT/USD. Both come from the same committee and the same signed tick.
Feeds (committee majors, 1 s ticks)
| Feed | Venues (allowlist) | Minimum venues |
|---|---|---|
| BTC, ETH, SOL /USD | Coinbase, Kraken, Bitstamp | 2 of 3 |
| BTC, ETH, SOL /USDT | Binance, OKX, Bybit, Gate, Bitget, KuCoin, MEXC | 3 of 7 |
| BTC, ETH, SOL /USDC | Binance, OKX, Bybit, Kraken, Bitget, KuCoin, MEXC, Gate | 3 of 8 |
| USDT/USD | Coinbase, Kraken, Bitstamp | 2 of 3 |
| CKB/USDT | Binance, Gate, Bitget, KuCoin, MEXC | 3 of 5 |
| CKB/USDC | Binance, Gate, MEXC | 2 of 3 |
….TWAP60 of each of the 12 above |
the source feed’s own signed prices | see below |
feed_id = ckb_hash("LEAN/FEED/V1" || symbol). Feed IDs are permanent. The exponent is fixed per feed: −8 for the majors and stablecoins, −10 for CKB.
The config rules are enforced by the signing tool: every feed needs at least 2 venues (no single exchange can set a price) and at least one spare market (one exchange going down does not stop the feed).
Per tick, per publisher
- Venue price.
- A venue counts only if its connection is live, its book is not crossed, its spread is within limits, and its top of book is above a dust size.
- Majors use the median of top-of-book midpoints, sampled every 100 ms.
- CKB pairs use a 60 s trade VWAP clamped to the venue’s book: never outside the median bid and ask over the window. On websocket venues, trades that print outside the book at that moment are dropped. A few dollars of wash trades on a thin venue therefore cannot drag its price outside its own spread.
- Cross-venue price. Take the median across venues, drop venues further than
maxDeviationBpsfrom it, and take the median again.conf = max(median half-spread, median absolute deviation). - EMA. An EMA price and EMA conf (1 h half-life) come from the committee’s finalized history, in integer arithmetic, so every publisher computes the same value.
Methodology changes are tested on recorded market data first
We did not tune the CKB rules by guessing. We recorded every book and trade on the CKB markets for three days on the testnet publisher, built a replay that reproduces the live price exactly (333,857 CKB feed-ticks replayed, 0 off by more than 1 bp), and then measured each candidate rule against that history:
| CKB rule (live since 1 Oct) | Feed | Within the old price’s conf | Ticks omitted |
|---|---|---|---|
| VWAP clamped to the book + websocket trade-vs-book filter | CKB/USDT | 99.12% | 0% |
| same | CKB/USDC | 99.36% | 0.01% |
From the same recordings we estimated what it costs to move the median: about $10.9k of one-sided buying to push CKB/USDT 1% for a tick, and about $2.1k for CKB/USDC. That second number is the reason we tell CKB/USDC users to prefer CKB/USDT or the TWAP. The full study, including the rule we rejected (a volume floor, which dropped thin venues and omitted ticks), is in docs/designs/manipulation-resistant-pricing.md.
TWAP60: for anything that settles at an instant
Any price at one instant can be pushed for one second. A settlement, liquidation or round that reads one tick gives the attacker the cheapest possible target. Since config v3 (2 Oct), every spot feed also has a .TWAP60 feed, for example Crypto.CKB/USDT.TWAP60:
- It is published once a minute, at the boundary tick (
t % 60 s == 0). - Its price is the mean of the source feed’s finalized, committee-signed prices from t−61 s to t−2 s. Publishers average signed history, not raw exchange data, so every honest publisher computes the same number.
conf = max(mean conf, MAD of the window), so a volatile minute shows up as a wider conf.- A publisher contributes a TWAP only if it holds at least 45 of the 60 ticks and is synced to the end of the window. Otherwise the boundary has no TWAP. We call that VOID, and your contract decides what VOID means for it.
- On recorded data the majors’ TWAPs are VOID in under 0.6% of minutes. CKB/USDT’s is VOID in about 9%, because the spot feed itself drops out in stretches when too few CKB venues are live. Improving that is open work, listed below.
To move a TWAP, an attacker has to hold the price against arbitrage for a whole minute, not one second. Design and review: docs/designs/twap60.md.
3. How publishers agree on one update per second
The publishers talk over authenticated websockets. Each connection opens with a challenge signed by the publisher’s committee key. Per tick t:
- Observe. Each publisher signs its observation for every feed,
(price, conf, source_time), and sends it to every other publisher. - Propose. A leader order for the tick is derived from
ckb_hash(t || hash of the update finalized two ticks earlier || index). Nobody can predict it more than about two seconds ahead, and every synced publisher computes the same order. Rank 0 proposes once it holds all observations, or a quorum after 100 ms. If rank 0 is silent, ranks 1…f take over in turn, and each re-proposes the best proposal it has already seen, so signatures already given still count. - Sign. Every publisher re-derives the update from the chosen observations itself: medians, EMA, Merkle tree and header. It signs only if every feed is within
tolerance_bpsof its own observation and it has never signed a different header for this tick. The leader cannot slip in a price the signers did not compute themselves. - Finalize. Once a quorum has signed the header, the update is final. Publishers share finalized updates with any peer that is behind.
Measured with 4 local publishers on live exchanges, every publisher finalized 100% of ticks, about 21 ms (p50) and 27 ms (p90) after the tick. That excludes network delay between machines.
The signed update
- Header (119 bytes): magic
LOPU, version, committee type hash,set_index,publish_time_ms(the tick),tick_period_ms,config_hash(the methodology that produced it),leaf_count,merkle_root. - Leaf (86 bytes, one per feed):
feed_id,price,conf,expo,prev_publish_time_ms,ema_price,ema_conf,source_time_ms,num_publishers. - Merkle tree: blake2b (
ckb_hash) with sorted pairs, so proofs need no direction bits. Every hash is domain-separated (LEAN/…/V1). - Signatures: recoverable low-S secp256k1, one bundle signing
ckb_hash("LEAN/PRICE_UPDATE/V1" || header).
One quorum signature covers all 24 feeds in the tick. A blob you put on chain carries only the feeds you need, each with its Merkle proof.
4. Governance: who can change what
Everything a committee does is either an on-chain operation on its committee cell or a signed config. Both need a quorum of the current keys.
Committee cell operations (on chain)
| Operation | Effect | Guard |
|---|---|---|
ROTATE |
Replace the key set. The outgoing set stays valid only for ticks before the switch, so settlement at an exact past time survives a rotation. | Proof of possession from every new key. At most once per min_rotation_interval_s (24 h by default). The switch time is bounded by a header dep. |
ROTATE_REVOKE |
Emergency rotation: the old set is dropped at once. | Proof of possession. No waiting interval. |
PAUSE / UNPAUSE |
While paused, feed cells reject every update, and consumers treat stored prices as unusable. | Quorum. |
REVOKE_PREVIOUS |
Drop the previous set early, for example if retired keys leak. | Quorum. |
Every approval signs ckb_hash("LEAN/PUBLISHER_SET_UPDATE/V2" || committee type hash || op || old state || new state), so it cannot be replayed on another committee. The committee cell sits under an always-success lock with the type script as its only guard, so any party can submit a quorum-approved operation, and no single key can block governance.
Methodology changes (off chain, signed)
Venues, windows, tolerances and the feed list live in a versioned committee config. A new version is signed by a quorum over its canonical JSON and names an activation_tick. All publishers switch at that tick, and every update carries the active config_hash, so anyone can tell which rules produced any price. Adding a feed is just a new Merkle leaf: no contract change and no transaction. That is how the 12 TWAP feeds went live on 2 Oct.
Upgrades
The contracts are deployed with hash_type = data2, in plain code cells, with no Type ID and no upgrade key. Nobody can change the code you integrate against. A fix means a new deployment with a new code hash, and you choose when to move to it. The contracts build reproducibly in a pinned Docker image, so you can rebuild them and compare against the hashes on chain (contracts/checksums.txt).
The limit, stated plainly
If a quorum of the current keys is stolen or colludes, the thief is the committee: no rule inside the system can override a quorum. This is true of every committee oracle. The defences are who holds the keys (named teams, HSM-backed keys), the pause, and evidence that cannot be erased (equivocations are public). Recovery is a new committee cell that integrators re-pin. For anything below a quorum (one leaked key, retired keys leaking later) the committee rotates or revokes.
5. On chain: what price_feed_type checks
A feed cell’s type.args = feed_id || type_id, and the Type ID rule makes it unique. A consumer pins exactly one cell by its type hash, and a burned cell can never be recreated to replay older prices.
Create. All price fields start at zero. Exactly one committee cell must be in the cell deps, and its type hash is written into the feed cell, which ties the cell to that committee for life.
Update. The type script checks that:
new.publish_time_ms > old.publish_time_ms: the cell only moves forward;- the update blob in the witness contains this
feed_id, with a Merkle proof against the header’s root; - the header names this cell’s committee, the committee is not paused, and a quorum of the current set signed the header (or of the previous set, for ticks before the rotation switch);
- the new cell data equals the signed leaf exactly.
When you move several feed cells of one committee in one transaction, one cell (the leader) checks the signatures once, and the others check only their own Merkle proof.
Burn. Allowed; your lock decides.
Lock. Any lock you like. Only you can update your cell (secp256k1), or a multisig, or a permissionless lock of your own design. The type script alone guarantees authenticity and forward-only updates, whatever the lock.
Costs (measured with ckb-testtool)
| Cycles | |
|---|---|
| Update one feed cell, quorum 3 of 4 | 22.6 M |
| Update one feed cell, quorum 7 of 9 | 52.5 M |
| Each extra feed cell of the same committee in the same tx | ~0.14 M |
| Reading and checking a stored price in your own script (reference lock) | ~38 k |
Each signature costs about 7.5 M cycles. Reading a price that is already in a cell is cheap. You pay for signatures only when you move the cell.
What the oracle does not decide for you
By design, freshness and settlement policy belong to the consumer. A script cannot read the clock, so the feed cell stores the committee-signed publish_time_ms, and your contract decides how old is too old. You can compare it with a header dep’s timestamp, require an exact tick (publish_time_ms == boundary), or rely on your own cell only moving forward. The common crate has helpers for all three. It also checks the committee cell for a pause, and checks that the stored price was signed by a set that is still trusted.
6. The mirror: an untrusted transport
The mirror is a public read API, much like Pyth’s Hermes. It follows several publishers, verifies every update before storing it (committee, key set, quorum, every Merkle proof), and serves:
| Endpoint | Returns |
|---|---|
GET /v1/updates/latest?ids= |
Newest signed update for each feed |
GET /v1/updates/at?t=&ids= |
First update at or after t (for settlement at an exact tick; cached as immutable once final) |
GET /v1/updates/range?id=&from=&to= |
One feed’s history |
WS /v1/stream?ids= |
Every new update, pushed |
GET /v1/equivocations |
Conflicting signed updates, kept as evidence |
You do not have to trust a mirror. Every value is inside a signed blob, and MirrorClient in the SDK decodes from the blob and verifies it against the committee cell on chain before returning anything. A lying mirror can only withhold data, never forge it. Anyone can run a mirror with nothing but the publisher URLs and the committee type hash. Heavy users should run their own.
7. Using it today (testnet)
import { LeanOracleTestnetClient } from "lean-oracle-sdk/client";
const oracle = new LeanOracleTestnetClient();
// Read: verified against the live committee cell, no chain write
const [ckb] = await oracle.latestPrices(["Crypto.CKB/USDT.TWAP60"], "majors");
console.log(ckb.price, ckb.expo, ckb.publishTimeMs);
// Once: create a feed cell you own
const { tx, typeScript } = await oracle.createFeedCell({ signer, feed: "Crypto.CKB/USDT", committee: "majors" });
// Any time: move it to the latest signed price, or to an exact settlement tick
const update = await oracle.pullAndUpdate(typeScript); // or { atMs, exact: true }
Or with plain HTTP:
curl "https://64-227-40-35.sslip.io/v1/updates/latest?ids=Crypto.CKB/USDT,Crypto.CKB/USDT.TWAP60"
A reference consumer is in the repo: examples/price_trigger_lock. It is a lock that releases funds when a Lean Oracle price crosses a strike, with a TypeScript script that runs the full flow on testnet.
Testnet deployment (verify these yourself)
| Value | |
|---|---|
price_feed_type code hash |
0x0cfb92a77c98b8b90731aedcb19ee0b12827e73c133e2448d50b3fa9ba8da876 (data2) |
publisher_set_type code hash |
0xe8eaf1bd1db480480b1a48513d8a4508901ca392dd7acd1fc9c8e2bb62f3d636 (data2) |
Committee majors type hash |
0xf5ed9644d2100a1d588222b8f80236be3579a7b735ae83c5a82063c94ef6faff |
| Committee created in tx | 0x979346fc072d91bd9e1f91f9a4fb56751a3b0527e883bc2d61460c902b483c79 |
| Publishers / quorum | 1 / 1 (testnet only) |
| Feeds | 24: 12 spot at 1 s, 12 TWAP60 at 60 s |
All deployments are recorded in deployments/testnet.json and ship in lean-oracle-sdk/presets.
8. What is not done yet
Being explicit about this matters more than the feature list:
- One publisher on testnet. The signing protocol is tested with 4 publishers in process and on a devnet, and the contracts with up to 9 keys, but the live testnet committee has one key: ours. The trust model in section 1 starts when other teams join.
- No external audit yet. The contracts are frozen (v1 contract freeze, 26 Sep), reproducibly built, and covered by Rust and TypeScript tests with shared test vectors. An audit is planned before mainnet.
- Not on mainnet. Mainnet comes after a multi-team committee has run on testnet and after the audit.
- CKB liquidity is thin. CKB/USDT spot drops out when fewer than 3 of its 5 venues are live, which causes the ~9% TWAP VOID rate. CKB/USDC trades on 3 venues and costs about $2.1k to move 1% for a tick. We will add venues as they qualify, and we publish these numbers rather than hide them.
- Exchange terms. Each operator must check each venue’s market-data terms before mainnet.
- One public mirror, on a single droplet. More mirrors, ideally run by other teams, are welcome. Running one needs no trust.
9. What we are asking for
- Teams to run a publisher. We are looking for about 7 teams already trusted in the Nervos ecosystem to hold the
majorscommittee keys. A publisher runs as one Docker container next to a CKB RPC endpoint, needs outbound connections to the exchanges, and can keep its key in AWS KMS. We will help with setup and run a multi-publisher testnet committee before any mainnet launch. If your team is interested, reply here or message us. - Integrators. If you are building lending, derivatives, a stablecoin, prediction rounds or anything that settles on a price, tell us which feeds, cadence and settlement pattern you need. TWAP60 exists because settlement at an instant is fragile.
- Review. Read the design (
docs/oracle-design.md) and the contracts, and point out where they are wrong. We especially want review of governance (rotation, previous-set rules, pause) and of the per-tick signing protocol. - Mirror operators. A second mirror anywhere makes reads more resilient, and it needs no permission.
I will answer questions in this thread.