Asset Price Oracle on Nervos Network

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-sdk on 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:

  1. 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.
  2. 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.
  3. 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 + 1 common 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

  1. 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.
  2. Cross-venue price. Take the median across venues, drop venues further than maxDeviationBps from it, and take the median again. conf = max(median half-spread, median absolute deviation).
  3. 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:

  1. Observe. Each publisher signs its observation for every feed, (price, conf, source_time), and sends it to every other publisher.
  2. 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.
  3. 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_bps of 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.
  4. 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:

  1. new.publish_time_ms > old.publish_time_ms: the cell only moves forward;
  2. the update blob in the witness contains this feed_id, with a Merkle proof against the header’s root;
  3. 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);
  4. 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

  1. Teams to run a publisher. We are looking for about 7 teams already trusted in the Nervos ecosystem to hold the majors committee 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.
  2. 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.
  3. 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.
  4. Mirror operators. A second mirror anywhere makes reads more resilient, and it needs no permission.

I will answer questions in this thread.

4 Likes

I think this is a genuinely useful direction for CKB.

The project-local feed cell + signed pull-update model feels much more natural for the Cell model than maintaining a globally shared mutable oracle cell. In particular, separating an untrusted transport/mirror layer from on-chain verification is a sensible design choice.

One point I would like to understand more clearly is the freshness model for permissionless consumers. If freshness is checked against a header dep, it would be useful to state the exact invariant that prevents an old but internally consistent oracle update from being paired with an old header. This seems particularly important for liquidation-style use cases.

There is also an important distinction between cryptographic oracle integrity and the economic integrity of the underlying markets. Given the relatively thin liquidity of some CKB markets, downstream protocols will probably need explicit exposure limits, confidence/deviation bounds, and perhaps spot/TWAP cross-checks rather than treating every correctly signed price as equally safe.

1 Like