Decentralized BCH/USD ticker.
Thirteen publishers, each pinned to one operator-diverse exchange, attest prices to a covenant on Bitcoin Cash. The covenant commits the per-cycle median on chain. No admin keys; the covenant is the only on-chain rule.
§01Overview
ticker.cash is a BCH/USD price feed that lives entirely on Bitcoin Cash chipnet. Every 60 s, ≥ 7 of 13 publishers refresh their slot commits with a fresh price observation; whichever publisher wins the race broadcasts Oracle.update, which commits the per-cycle median to chain.
The on-chain code is three CashScript covenants: Oracle, PublisherSlot, Ticker. No proxies, no admin paths, no off-chain governance signers. The Oracle covenant address is permanent for the life of the deploy; behavioural changes require a fresh genesis at a new address.
§02Trust model
The trust root is a 7-of-13 publisher quorum. Each publisher fetches the price from its pinned CEX over TLS, signs (sourceId, price, ts, publisherPkh, cycleSeq, hash160(serverName)) with its slot-pinned key, and the covenant commits the result. The Oracle.update median absorbs ≤ 6 misbehaving publishers per cycle without bias.
Sources are operator-diverse. 9 USD spot markets (Kraken, Coinbase, Gemini, Binance.US, Bitstamp, Crypto.com, Bitfinex, EXMO, Independent Reserve — 4 US, 5 non-US), 2 USDC (OKX, KuCoin), 2 USDT (Bybit, HTX). No operator family appears twice; no quote-currency dominates. Both stablecoin clusters could depeg the same direction simultaneously and the median still lands in the 9-USD cluster.
Publisher collusion model
What a colluding ≥ 7 of 13 publishers can do:
- Move the on-chain median to any value they all agree to sign.
What no collusion can do:
- Skip the ≥ 7 distinct-publisher quorum check — enforced in script.
- Replay a stale attestation —
PublisherSlot.attest()requiresnewCycleSeq > slot.cycleSeq(strict-greater). - Hide a cycle from observers — every state transition is an on-chain tx.
- Mint new slots after genesis — slot category closed forever by CashTokens consensus.
- Run the chain faster than the 60 s minimum stride enforced by
Oracle.cash.
No admin keys. No upgrade path. Changing the publisher set requires a fresh PublisherSlot covenant, new genesis, and a slot fleet migration. The new address coexists with the old.
§03Cycle flow
- Publisher polls the Oracle UTXO for
lastTsandseq(the next cycle attests atseq + 1). - Publisher fetches the price from its pinned CEX over TLS.
- Publisher broadcasts
PublisherSlot.attest. The slot covenant verifies the publisher ECDSA-DER sig, the CN/SAN hash, and the monotonic cycleSeq advance, then rewrites the slot commit in place. - Publisher waits ~25 s for peer slot refreshes to appear in mempool.
- Once ≥ 7 slots carry the current cycleSeq, any publisher builds
Oracle.updateconsuming the Oracle UTXO + ≥ 7 slot inputs, re-emits each slot unchanged at its matching output index, and mints 2 mutable Ticker NFTs. - First publisher to broadcast wins. Losers retry on the new tip.
Each cycle's full signature path is in §04.
§04Signatures
Five signatures appear on the path from "an exchange quoted a price" to "your dApp's tx settles." One is produced and verified per slot refresh — the heart of the trust model. The rest are standard BCH input sigs. Every signature ties to a specific publisher, source, and cycle.
Step 1 · Slot refresh (×13 publishers, ~60 s)
Exchange Publisher PublisherSlot
(1 of 13) (1 of 13) covenant
───────── ───────── ─────────────
│ │ │
│ ◄─── TLS ────────────┤ │
├── price ────────────► │ │
│ │ │
│ │ ECDSA-DER sig 1 │
│ │ signs: │
│ │ sourceId │
│ │ ‖ price │
│ │ ‖ ts │
│ │ ‖ publisherPkh │
│ │ ‖ cycleSeq │
│ │ ‖ hash160(serverName)
│ │ │
│ │ Tx sig 2 (P2PKH) on slot.attest
│ ├────────────────► │
│ │ │
│ │ verifies sig 1,
│ │ cycleSeq strictly advances,
│ │ hash160(SN) matches slot's pinned CN hash
Step 2 · Oracle.update (race winner)
≥ 7 slots Race-winner publisher Oracle covenant + N × slot.consume
───────── ───────────────────── ─────────────────────────────────
│ │ │
│ │ Tx sig 3 (P2PKH) │
│ │ (no off-chain sig needed) │
│ ├────────────────────────────► │
│ │ │
│ │ Oracle.update enforces:
│ │ position-checked median,
│ │ ≥ 7 distinct + sorted pkhs,
│ │ newTs - prevTs ≥ 60,
│ │ 2 mutable Ticker mints.
│ │ Each slot.consume enforces
│ │ identical slot re-emit at its
│ │ own input/output index.
Step 3 · Consumer
Consumer dApp Ticker covenant + consumer's own gate
───────────── ──────────────────────────────────────
│ │
│ Tx sig 4 │
│ ├─ in[0] = consumer covenant │
│ │ (auth via covenant unlock — │
│ │ e.g. checkSig over recipient │
│ │ key, pushed into unlock script)│
│ └─ in[1] = Ticker NFT (no sig — │
│ Ticker.cash is anyone- │
│ can-spend) │
│ out[0] = business output │
│ out[1] = Ticker re-emit │
├─────────────────────────────────────► │
│ │
│ Ticker.cash: re-emit at out[1] identical
│ Consumer: price gate, freshness gate
Signature inventory
| # | Signer | Algorithm | Signs | Verified by |
|---|---|---|---|---|
| 0 | Exchange TLS cert | TLS | The TLS session | Publisher's TLS client, CN/SAN pinned |
| 1 | Publisher (1 of 13) | ECDSA-DER | sourceId ‖ price ‖ ts ‖ publisherPkh ‖ cycleSeq ‖ hash160(serverName) | PublisherSlot.attest() slot-pinned key |
| 2 | Publisher wallet | P2PKH | The slot.attest tx | BCH consensus |
| 3 | Race-winner wallet | P2PKH | The Oracle.update tx | BCH consensus |
| 4 | Consumer | covenant unlock (or P2PKH if the consumer skips a covenant) | The consumer's own tx (auth shape depends on whether the spender is a covenant or a plain wallet) | BCH consensus + consumer covenant gate |
Why each binding matters
publisherPkhin the publisher sig. Locks the attestation to the slot's pinned identity — covenant rejects the sig if it doesn't match the on-chain pkh.hash160(serverName)in the publisher sig. A publisher can't fetch from Coinbase and relabel the price as Kraken's — the covenant pins eachsourceIdto a specific CN hash at genesis.cycleSeqin the sig. A signature produced for cycle N can't be replayed at N+1; the slot's stored cycleSeq is strict-monotonic.- No off-chain sig on
Oracle.update. Anyone can broadcast it. The covenant is the only judge; the race winner just pays the fee.
contracts/Oracle.cash + contracts/PublisherSlot.cash, deployed once, immutable forever.§05Verify
Anyone can independently verify that the on-chain median matches what publishers attested. The Oracle covenant doesn't need a trusted indexer — the chain has everything.
Step 1 · Find the latest Oracle.update tx
Query the Oracle covenant's address for its sole UTXO. The UTXO's birthing tx is the most recent Oracle.update. The tx outputs are: [0] Oracle re-emit, [1..N] the N (7–13) slot re-emits in input order, [N+1] and [N+2] the two new Ticker NFTs, optional [N+3] change. Decode tx.outputs[0].nftCommitment as the 19-byte Oracle commit:
0x60 | seq(4) | lastTs(4) | medianPrice(8) | activeCount(2) (LE throughout; medianPrice = USD × 1e8)
Step 2 · Read each slot input's pre-update commit
Walk tx.inputs[1..N]. Each input is a PublisherSlot UTXO — fetch its tx_hash:tx_pos, then look at THAT tx's output to get the slot's nftCommitment as it sat on chain when Oracle.update consumed it. Decode the 39-byte slot commit:
0x73 | sourceId(2) | publisherPkh(20) | price(8) | timestamp(4) | cycleSeq(4)
Each slot's cycleSeq must equal oracle.seq (the cycle's new sequence number, written by this update). If any slot's cycleSeq doesn't match, the covenant would have rejected the tx — but the check is also yours to confirm.
Step 3 · Recompute the median
Take the price field from each of the 7-13 slot inputs. Sort ascending. The covenant uses "lower-middle" median: index floor((N - 1) / 2). For N=13 that's index 6 — the 7th smallest. The Oracle commit's medianPrice must equal that value.
Step 4 · Verify each publisher signature
For deeper assurance: look up each slot input's birthing tx — the prior slot.attest that wrote the commit you just read. That tx's input[0] unlock script contains the publisher's ECDSA-DER signature (70–72 B) plus the raw serverName and price/ts/cycleSeq pushes. (The covenant's checkDataSig accepts a 64 B Schnorr signature too, but the deployed publisher fleet signs ECDSA.) Recompute the publisher digest:
sha256( u16LE(sourceId) ‖ u64LE(price) ‖ u32LE(ts) ‖ pkh20 ‖ u32LE(cycleSeq) ‖ hash160(serverName) )
Verify it against the publisher's pubkey (also pushed in the unlock script). And verify hash160(serverName) matches the slot covenant's pinned sourceCNHashes[sourceId] entry.
Tooling
A reference verifier is planned (ticker-verify — single-purpose Rust binary that pulls the chain via Electrum and runs the four steps above, exiting non-zero on any mismatch). Until then, the steps above can be scripted with any cashscript-aware tooling.
§06Integrate
One path: spend a Ticker NFT as an input in your transaction.
On-chain
Spend a Ticker NFT as an input in your transaction. The Ticker is mutable, anyone-can-spend, and self-replicates via the Ticker covenant. Your tx becomes a chain-descendant of the Oracle.update that minted the Ticker — BCH consensus refuses to confirm your tx in any chain history that excludes the update.
input[0] your consumer covenant
input[1] Ticker NFT (from the pinned Oracle category)
output[0] your business logic (payment, mint, settle…)
output[1] Ticker re-emit (Ticker.cash enforces structural equality)
output[2] optional change
Decode tx.inputs[1].nftCommitment as the 17-byte commit:
0x80 | seq(4) | lastTs(4) | medianPrice(8)
Little-endian throughout. medianPrice / 10⁸ = USD. Pin the Oracle category (LE-reversed) as a covenant arg so an attacker can't feed you a Ticker from a different oracle.
Reference covenant: contracts/examples/PriceGatedRelease.cash — locks BCH, releases on price ≥ strike. ~64 B script body.
minLastTs floor and rejects any Ticker older than that — but the floor is fixed at deploy time, not a rolling "current price" window. Once the gate has been historically reachable (any Ticker at or after minLastTs cleared the strike), it stays open until spent. For sliding-window "tx.locktime − lastTs ≤ N" freshness, the consumer covenant has to add that check itself; a generic freshness witness for that use case is planned.§07Operate
One stateless daemon, ticker-node. Each operator runs one process per slot.
Publisher
Polls the Oracle UTXO, fetches its pinned source over TLS, refreshes its slot via slot.attest(), waits ~25 s for peers, then races to broadcast Oracle.update. ~2.2 k sats per cycle just for slot.attest, plus a share of the ~12.8 k Oracle.update tx fee averaged across racers — works out to ~3.2 k sats per cycle per slot (see §09 for the full breakdown).
$ ticker-node --publisher --slot 0
Contract addresses, publisher pkhs, and Electrum endpoints come from the manifest at $TICKER_HOME/manifest.json; the source pin is positional (slot i → SOURCES[i], baked into the binary); the per-slot secp256k1 publisher key is loaded from $TICKER_HOME/publisher.key (mode 0600).
Resource cost (per publisher, measured live)
One ticker-node process. Numbers below are from the running chipnet deploy (slot 0, > 1 hour uptime, single-process measurement on Linux x86-64).
| Memory | ~4 MiB RSS | VmRSS = 4160 KiB, of which only 624 KiB is anonymous heap; the rest is shared file mappings (binary, glibc, CA bundle). 13 co-located publishers fit in ~60 MiB total. |
| CPU | ~0.03 % of one core | 1 s of CPU per 3300 s of wall time at 60 s stride. Each cycle = ~18 ms of CPU (slot.attest sign + Oracle.update build + Electrum I/O). Idle between cycles. |
| Threads | 3 | Main + publisher loop + stats server. |
| Disk (state) | ~20 KiB | $TICKER_HOME/ holds manifest (~1.8 KiB), publisher.key (64 B), advisory state file (~200 B). No DB. |
| Disk (binary) | 1.7 MiB | Static-linked Rust ELF, includes embedded cashc artifacts (~55 KiB) + Mozilla CA bundle. |
| Disk (logs) | ~30 MiB/month | Structured JSON via journald, ~1 MiB/day. Compressed at rest. |
Network bandwidth (per publisher)
Each cycle the daemon: fetches its pinned source over HTTPS (~3 KiB round-trip), broadcasts its slot.attest tx (~2.2 KiB wire), watches Fulcrum via Electrum WS subscription, and broadcasts an Oracle.update attempt (~12.8 KiB wire) which has a ~1/13 chance of being the canonical winner. Race-lost broadcasts still consume upload bandwidth but pay zero fee.
| Upload per cycle | ~18 KiB | slot.attest (2.2) + Oracle.update broadcast (12.8) + Electrum requests (~2) + CEX GET (~1). |
| Download per cycle | ~12 KiB | listunspent responses (slot category returns ~6 KiB), Oracle UTXO push, CEX response. |
| Per day | ~43 MiB | 1440 cycles × ~30 KiB. |
| Per month | ~1.3 GiB | Well under any entry-level VPS bandwidth allowance. |
Funding (BCH per cycle)
Every cycle the publisher's funder wallet pays fees for its slot.attest tx plus a share of the Oracle.update tx if it wins the race. At 1 sat/byte mempool floor:
slot.attest | ~2,200 sats/cycle | ~2.2 KiB wire × 1 sat/byte. Paid every cycle. |
Oracle.update share | ~1,000 sats/cycle | 12.8 KiB tx ÷ 13 racers (one winner per cycle, cost amortised across the federation). |
| Total per publisher | ~3,200 sats/cycle | = 4.6 M sats/day = 0.046 BCH/day per slot. |
On chipnet sats are free (testnet); the current deploy runs perpetually on test funds. On mainnet, at a reference BCH/USD of $300, one publisher costs ~$13.80/day (~$415/month) in tx fees alone. The 13-publisher federation total: ~$180/day. Costs scale linearly with stride — halving to 30 s would double the fee burn; doubling to 120 s would halve it.
Minimum-viable VPS
For one publisher slot:
- 1 vCPU (uses < 0.1 %; spec is for headroom + restart handling)
- 256 MiB RAM (uses < 10 MiB; spec is for OS + journald + buffer)
- 5 GiB disk (uses < 100 MiB inc. logs; spec is for OS + 1 yr log retention)
- 100 GiB/month bandwidth (uses ~1.3 GiB; spec is generous)
- Linux x86-64 + systemd (the supported deployment target)
The cheapest tier of any mainstream provider (Hetzner CX11, Vultr $3.50, Linode Nanode $5) is overspec'd. The 13-publisher federation today co-locates on a single CX22-class VPS using ~60 MiB RAM total.
Optional: own BCHN + Fulcrum
The daemon talks to any Electrum-compatible Fulcrum. By default it uses a 3-endpoint failover pool (one operator-run, two public). Running your own BCHN + Fulcrum is optional — it tightens the trust model (no Fulcrum operator to trust for UTXO reads) but adds an order of magnitude to the resource budget: a chipnet BCHN node needs ~2 GiB RAM, ~10 GiB disk for the chain (chipnet height ~308 k blocks ≈ 250 MiB pruned, 2-3 GiB unpruned), and Fulcrum adds another ~1-2 GiB RAM for indexing. Reasonable if you're already running BCH infra; not required for a publisher.
ticker-ops setup-all after generating a 32-byte seed.§08Reference
Parameters
| Publisher count (N) | 13 | One slot per (publisher, source). Closed at genesis. |
| Source count | 13 | 9 USD + 2 USDC + 2 USDT, operator-diverse. |
| Quorum floor (Tfloor) | 7 | Minimum distinct publishers per cycle. |
| Quorum ratchet | ~50% | Threshold = max(Tfloor, ⌈0.5 × oldActive⌉). Active count decays 10%/cycle on participation drop. |
| Cycle stride (min) | 60 s | Enforced by the covenant (Oracle.cash:85: require(newTs - prevTs >= 60)); publisher daemon races to broadcast as soon as the floor elapses + quorum forms. |
| Tickers per cycle (K) | 2 | Mutable; anyone-can-spend; self-replicate. |
| Price scale | USD × 108 | medianPrice / 1e8 = USD. |
| Network | chipnet | Mainnet follows after stability period. |
Commit layouts
| Oracle | 19 B · version 0x60 | 0x60 | seq(4) | lastTs(4) | medianPrice(8) | activeCount(2) |
| Slot | 39 B · version 0x73 | 0x73 | sourceId(2) | publisherPkh(20) | price(8) | timestamp(4) | cycleSeq(4) |
| Ticker | 17 B · version 0x80 | 0x80 | seq(4) | lastTs(4) | medianPrice(8) |
Live contracts (chipnet · v14 · deployed 2026-05-30)
| Oracle | bchtest:p0nrdzy7…r2ug7jck |
| Slot | bchtest:pvq90xk4…5yy6wch |
| Ticker | bchtest:pw8gptmx…nrd62lp |
| Oracle category | 3dd6b6c3c57040d35df36f1bc9b9099b8d307b553a229683a250a67a15beb7ee |
| Slot category | de1c438b6969c90782fc637d759b609b1b2ac0880d7219669a689c47fb1b3ba5 |
| Oracle genesis tx | 203a78fd…ed191868b |
| Slot genesis tx | 611cc605…532521e71f0 |
§09FAQ
Is this real money?
No. ticker.cash runs on chipnet, BCH's persistent testnet. Sats are free from a faucet; the published price is a real on-chain commit but it's chipnet-only. Mainnet is a future ceremony — there's no fixed date.
How is this different from Chainlink, Pyth, RedStone?
Those systems aggregate prices off-chain and post the result on chain; the on-chain step is a write by a trusted multisig or DON. ticker.cash aggregates on chain — the covenant itself position-checks the median across slot inputs, verifies the distinct-pkh quorum, enforces the stride floor. The chain is the aggregator. There's no off-chain "writer" to trust beyond the publisher quorum.
What if 7+ publishers go down at once?
The covenant's quorum floor blocks Oracle.update below 7 distinct slot inputs at the matching cycleSeq. The chain pauses. Once enough publishers come back online, the next Oracle.update resumes the stream — same Oracle UTXO, new median, advancing seq. No state is lost. The last published cycle is still spendable as a Ticker NFT, just stale.
Why 60-second cycles? Could it go faster?
60 seconds is the covenant's enforced minimum stride (Oracle.cash:85): require(newTs - prevTs >= 60). Picked to balance CEX rate limits and per-day sat burn against consumer latency. The actual measured stride hovers slightly above 60 s depending on quorum-wait + propagation. Faster would require a covenant migration.
Why CashTokens, not OP_RETURN / sidechains / etc.?
CashTokens NFTs give us four properties that nothing else on BCH provides together: (1) a slot's identity is enforced by covenant + consensus, not by a script we wrote, (2) the slot category is closed forever after genesis, so no one can mint new slots, (3) tickers self-replicate via the Ticker covenant so consumers don't need to re-mint anything, (4) on-chain composability — a consumer's covenant can spend a Ticker as a price gate without intermediate trust. OP_RETURN lacks all of these.
Who runs the publishers?
Currently a single operator (this project) runs all 13 publisher slots on one VPS. The manifest + per-slot key layout supports a future multi-operator distribution, but the federation hasn't split yet. The trust assumption is honest about this — see §02's collapsed-trust note.
Can I run my own slot?
Yes, the binary is the same (ticker-node --publisher --slot N) — but slot identity is pinned at genesis to a specific pubkey hash, and adding a 14th slot is a hard cutover (new covenant, new genesis, slot fleet migration). For now there's no open process to join the federation; the structure exists for when there is.
How do consumers handle freshness?
The Ticker NFT's commit carries lastTs. A consumer's covenant can require tx.locktime - lastTs ≤ N for any freshness window N. The reference example contracts/examples/PriceGatedRelease.cash uses a simpler shape — a deploy-time minLastTs floor (require(int(tickerCommit.slice(5, 9)) >= minLastTs), line 47). That gives you "anytime after this fixed wall-clock", not "current price ≥ strike now". A generic sliding-window freshness witness is planned.
What does it cost to operate?
About 2.8 k sats per cycle per publisher: ~2.2 k for the slot.attest tx (1 sat/byte × ~2.2 KB) plus a 1/13 amortized share of the Oracle.update fee (~8 k for the race winner → ~0.6 k each). At ~60 cycles/hour federation-wide that works out to roughly 4 M sats/day per slot in steady state. The protocol burns sats; it doesn't earn them.