Introduction
kaspulse serves signed price feeds, threshold-signed by a 5-node committee (3-of-5), each carrying everything needed to verify it — the exact signed message, the signer pubkeys and the signatures. Never trust the API — verify. The oracle and its signatures are live and real; on-chain consumers ran on Kaspa testnet-10 — the bond record changed on 2026-07-27 and the bond redeem gained a 32-byte record length pin on 2026-08-25, and the TN10 slashing loop was re-run against those exact bytes the same day (deploy 1af295c8…775b → slash ff02cf22…e376, both accepted). Mainnet publishing is next.
Two kinds of feed, and they are not the same product. Test kind against a set, never inline against one string — it has moved twice: "krc20" became "krc20-l2" the day the L1 tier landed (every consumer comparing it inline stopped matching in silence), and on 2026-08-24 "krc20-l2" was removed altogether along with the 58 Kasplex / Igra EVM DEX feeds it named. A consumer that still filters for it now matches nothing, which is the intended failure mode: it matches nothing loudly rather than pricing something wrong quietly.
| kind | what the price is | read these |
| "major" | KAS · BTC · ETH — a MAD-filtered median across independent exchange venues. Read sources[] for which ones, this round | num_sources spread_bps outliers |
| "kcc20-pool" | Kaspa L1 KCC20 AMM pool covenants, read from kascov. An exact integer rational — the covenant's own gated marginal, or the newest verified pool state when that marginal is withheld — with the txid of the executed fill it is anchored to. Every admitted fill is re-run against the covenant's audited program in kaspulse's own code before it can move a price | basis last_trade_age_s anchor_txid price_source_url move_10pct_usd |
Removed 2026-08-24: "krc20-l2" — 58 Kasplex / Igra EVM DEX pool feeds. They were pool state reads, not executed trades. Measured on their last round: median reserve touch age 11.5 days, and 55 of 58 markets movable 10% for under $250 (median $14.59). An EVM contract can call getReserves() for itself, so an oracle there was a convenience rather than a mechanism — the tier is gone, and with it pool_age_s, venues[], peg_ok and the envelope’s peg block. peg_ok is still parsed by the SDK and the zero-dep clients (their verify logic rejects peg_ok == false); no feed sets it any more, so that check is now permanently inert rather than removed — nothing breaks.
All endpoints are GET (plus HEAD/OPTIONS); live data is served Cache-Control: no-store. Poll — there is deliberately no push stream.
# everything, one round — plus the L1 census and every refusal
curl $ORIGIN/v1/feed
# one pair (dash form, case-insensitive)
curl $ORIGIN/v1/feed/KAS-USD
# an L1 KCC20 pair — 16-hex token prefix, two legs per market
curl $ORIGIN/v1/feed/KCC20.c58c826d0aa9cee6-USD
curl $ORIGIN/v1/feed/KCC20.c58c826d0aa9cee6-KAS
# light catalog for dashboards
curl $ORIGIN/v1/feeds
The signed message
The keystone of the whole API. For every feed, the committee signs the ASCII string
kaspulse/v2|PAIR|mant|expo|ts|round
Pair names. A major is ticker-shaped (KAS/USD) because its ticker is what every exchange in the median already calls it. An L1 KCC20 token has no ticker with any chain authority — measured across all 93 kascov mainnet markets on 2026-08-24, exactly one has ever claimed one, and none of the seven kaspulse prices has — so its pair is built from its covenant id instead: KCC20.<first 16 hex>/USD and KCC20.<first 16 hex>/KAS. That binds covenant identity into the signed message for free, and it is why the board labels those feeds with a name and an avatar derived from the same id rather than a ticker somebody typed. The full 64-hex id is always published as token_covenant_id; the 16-hex prefix is what fits the dash-form URL charset, and if two tokens ever collided on a prefix the second is refused registration rather than served. KCC20. stays a reserved symbol prefix even though the EVM tier that motivated the reservation is gone: a ticker-shaped pair can never collide with a covenant-shaped one.
where PAIR is slash form (KAS/USD) and the rest are decimal integers (expo may be negative). The signed price is mant × 10^expo — exact at any magnitude. The digest is unkeyed blake2b-256 of those ASCII bytes, and each node’s signature is standard BIP340 Schnorr over that 32-byte digest as the message m (the digest is not hashed again outside BIP340’s own tagged hashing). signers[i] are 32-byte x-only pubkeys, index-paired with signatures[i].
Worked example, captured from a live round:
# the message string, verbatim from the feed:
kaspulse/v2|KAS/USD|295500000|-10|1783791092|183775
# its blake2b-256 digest (this is BIP340's m):
0f86dadc725bf4cc21900ab008ee1f9d75884935d9e6a0e4a48db2f4d8cda88d
A feed is VALID when ≥ threshold signatures verify and the message’s PAIR|mant|expo|ts fields equal the JSON’s pair/mant/expo/signed_ts — the field binding stops a lying server from serving a price the signatures don’t cover. Every kaspulse verifier checks both.
// JS (see clients/js/kaspulse.mjs for the real thing)
digest = blake2b256(utf8(feed.message))
valid = count(i => bip340_verify(feed.signers[i], digest, feed.signatures[i]))
ok = valid >= feed.threshold && fieldsBound(feed)
// Rust — the SDK does all of it, binding included
let feed = kaspulse_sdk::fetch(base, "KAS/USD")?;
let price = feed.checked_value_fresh(Duration::from_secs(30))?;
Prices are signed on change plus a 5s heartbeat — signed_ts/signed_round belong to the signature, the envelope’s timestamp/round to the serve tick. Full grammar: docs/MESSAGE-FORMAT.md.
Zero-dependency clients
One file each, no installs, no keys. Both fetch a feed AND verify it locally (signatures + field binding + freshness + safety flags), and both self-test their crypto core at load — a broken core refuses to verify rather than risk a fake ✓.
# JS — Node 18+ or the browser
curl -O https://raw.githubusercontent.com/Knitser/kaspulse/main/clients/js/kaspulse.mjs
node kaspulse.mjs verify KAS/USD $ORIGIN
# Python 3.9+ — stdlib only
curl -O https://raw.githubusercontent.com/Knitser/kaspulse/main/clients/py/kaspulse.py
python3 kaspulse.py verify KAS/USD $ORIGIN
# L1 KCC20: signature-verify AND re-derive every leg from kascov.
# Still zero dependencies — price_source_url is a plain HTTPS GET.
node kaspulse.mjs kcc20 $ORIGIN
python3 kaspulse.py kcc20 $ORIGIN # exit 1 if any leg does not reproduce
| JS | Python | what it does |
| k.kcc20Feeds() | k.kcc20_feeds() | the L1 feeds out of an envelope |
| k.kcc20Census() | k.kcc20_census() | the census + every refusal |
| k.kcc20Info(feed) | k.kcc20_info(feed) | pure: basis, anchor txid, gate counts, depth |
| await k.reDeriveKcc20(feed) | k.re_derive_kcc20(feed) | network: re-fetch kascov, decide the tier independently, demand the exact integer pair back |
Honest scope, in three lines. They verify the committee’s signatures over the served message. For an L1 feed they additionally re-fetch kascov and check the published rational against it — that is a cross-check of kascov, not independence from it, and it does not replay the admission gates. The deepest audit is cargo run --bin verify in the repo: it re-fetches the exchanges and recomputes the majors’ median, re-runs the ported bracket and invariant gates over the same window of fills, and exits non-zero if anything fails (--details prints every check and every curl URL). The same L1 re-derivation runs in the browser from any KCC20 feed page.
Full envelope
GET/v1/feed
Every feed, one round, one document — around half a megabyte at the current feed count, and it also carries the L1 census and every refusal. Permanent aliases: /api/feed, /feed.json. For dashboards, poll /v1/feeds instead.
// captured from a live round (feeds abridged)
{
"round": 4468857779, "timestamp": 1787542163,
"threshold": 3, "num_nodes": 5, "transport": "websocket",
"kcc20": { /* the L1 census + every refusal — see "refusals" below */ },
"feeds": [ /* FeedObj × N — see /v1/feed/{PAIR} */ ]
}
| field | type | meaning |
| round | u64 | serve-tick round counter |
| timestamp | u64 | serve-tick unix seconds |
| threshold | u32 | signatures required per feed (3) |
| num_nodes | u32 | committee size (5) |
| transport | str | how majors arrive ("websocket") |
| kcc20 | obj | the L1 tier's census and its refusals — how many markets kascov indexes, how many kaspulse prices, and every market it declined with the gate it failed. Full shape below |
| feeds | [FeedObj] | all feeds — full shape below |
Single feed
GET/v1/feed/{PAIR}
One FeedObj. PAIR is dash form, case-insensitive: KAS-USD ↔ pair "KAS/USD". Unknown pair → real HTTP 404 with body {"error":"no such feed"} (the legacy alias /api/feed/{PAIR} now 404s too).
curl $ORIGIN/v1/feed/KAS-USD
// the shape of one feed (arrays abridged; live values, moves every round)
{
"pair": "KAS/USD", "kind": "major",
"price": 0.02955, "price_e8": 2955000,
"mant": 295500000, "expo": -10,
"sources": [
{ "name": "Gate.io", "price": 0.02955, "age_ms": 12467 },
{ "name": "KuCoin", "price": 0.02955, "age_ms": 12890 },
{ "name": "MEXC", "price": 0.029564, "age_ms": 12279 }
],
"num_sources": 3, "outliers": [], "divergent": false,
"halted": false, "degraded": false, "freshest_ms": 12279,
"low": 0.02955, "high": 0.029564, "spread_bps": 4.74, "median": 0.02955,
// majors are an instantaneous median — never TWAPed:
"twap": false, "twap_samples": 0, "twap_window_s": 0,
// depth is a pool concept; majors are CEX-sourced, so it is null:
"liq_wkas": 0.0, "thin": false,
"move_10pct_usd": null, "depth_2pct_usd": null,
"signers": [ "0dd71bf25a98da9ff89a661537997fc9…", /* ×5 */ ],
"threshold": 3,
"signatures": [ "7eb9f2973d6976da3dc8bdc29d66e697…", /* ×5 */ ],
"message": "kaspulse/v2|KAS/USD|295500000|-10|1783791092|183775",
"signed_ts": 1783791092, "signed_round": 183775,
"history": [ [1783791030, 0.02955], /* ~120 [ts, price] points */ ]
}
An L1 KCC20 feed carries the same shape plus a provenance block. Captured live from mainnet, abridged — the full field table is in L1 fields:
{
"pair": "KCC20.7a98370c0d6b037c/USD", "kind": "kcc20-pool",
"price": 0.00023107896435551974, "mant": 231078964, "expo": -12,
"leg": "USD",
"token_covenant_id": "7a98370c0d6b037c28089d7b77cd8229120804cb416ef592ddb99e23820544e4",
"display_name": "rapid-jade-narwhal", // derived from the id — nobody claimed it
"claimed_ticker": null, "claimed_name": null, "claimed_decimals": null, // UNVERIFIED if present
"token_status": "verified", "holders": 123, "supply": 1000000000000000, // kascov's decode state / index counts
"art_url": null, // kascov-hosted hash-verified art, when the token has any
"price_num_sompi": 45207783000000, "price_den": 55795904,
"price_unit": "sompi per token base unit", // there are NO verified decimals
"basis": "last_verified_state",
"basis_note": "kascov withheld spot (t_live 55795904 != held_by_covenant 55780625);
using the newest non-carried 1h pool candle close",
"verified_as_of_ms": 1787541301267, "last_trade_age_s": 850,
"anchor_txid": "6a7ed22829b4a3ea9fd076e1190b313b0acfdf53567331d2d1afe33f99bed4f1",
"anchor_daa": 521209446,
"newest_admitted_txid": "6a7ed22829b4a3ea9fd076e1190b313b0acfdf53567331d2d1afe33f99bed4f1",
"stale_fill": false,
"admitted": 38, "rejected_bracket": 12, "rejected_dust": 0, "rejected_co_covenant": 0,
"gate_window": 50,
"gate_note": "re-ran bracket_holds+invariant_holds over the newest 50 fills at (30,30) bps",
"market_covenant_id": "10f2155ebcd2e0cd55cc9354f78d43fa4e8b9f93de98683919d1f490da726c4f",
"skeleton": "KRON pool v1", "invariant_ok": true,
"exercised_trades": 3089, "reserve_kas": 452201.7,
"trades_24h": 27, "volume_24h_sompi": 13432755000000, "change_24h_bps": -2933,
"window_note": null,
"move_10pct_usd": 600.18, "depth_2pct_usd": 127.07, "thin": false,
"taker_fee_bps": 130, "resting_asks": 0, "unpriced_reason": null,
"last_fill": { "quote_sompi": 99100000000, "base_amount": 122546, "side": "buy" },
"price_source_url": "https://kascov.io/data/mainnet/token/7a98…44e4/candles?bucket=1h&phase=pool"
}
The fields worth honoring. A consumer that reads price and ignores the flags is doing it wrong — the flags are the oracle telling you when not to trust the number:
| field | type | meaning |
| halted | bool | do not consume this round. Set by the circuit breaker (a >20% one-round jump publishes the last good price until it persists), on any pool feed whose TWAP window is less than half full, on any kcc20-pool feed whose newest verified fill is older than 48 h (or whose age cannot be established at all), and on every feed whose price is a KAS price × KAS/USD when KAS/USD itself is halted — that is the kcc20-pool /USD leg. The L1 /KAS leg does not inherit that halt: it is not denominated in dollars, so it keeps publishing |
| move_10pct_usd | f64|null | On any pool feed, the number to read. USD trade size that moves this feed’s price 10%: the sell-side honest form min((V+R)·(1−1/√1.1), R) × KAS/USD, capped at the live reserve, where V is virtual KAS (0 on every graduated pool) and R the live reserve in sompi — a factor of 0.04653741 at δ=0.10, sell side, no fee term. Do not compare it to a figure from the removed EVM tier without the offset: that one used the buy-side form with the 30 bps fee divided back in (0.04895572), so an L1 number reads 4.94% below its old L2 counterpart on an identical reserve — enough to flip thin on a pool sitting near the $250 bar. null on majors. It is a trade size, not a net cost: the attacker still faces arbitrage and must hold the move across the TWAP window |
| depth_2pct_usd | f64|null | same at 2% — the everyday-slippage number |
| thin | bool | move_10pct_usd < $250. Real price, shallow book, cheaply moved — honor it |
| taker_fee_bps | u32 | kcc20-pool only — the trader’s full outlay in bps: 130 on L1 (30 bps retained in the reserve plus two P2PK fee legs of 30 and 70 bps paid outside the pool). Only the in-reserve 30 bps enters the constant-product arithmetic, which is why the depth formula uses that figure and this one is published separately. POOL-SPEC’s own caveat carries over: fee-leg floors on very small swaps are unconfirmed |
| divergent | bool | ≥2 sources and spread_bps > 500 — the venues genuinely disagree and the published price is between them, i.e. one no venue quotes |
| degraded | bool | fewer venues than nominal — widen your margins |
| peg_ok | bool|absent | No feed sets this any more. It was the WKAS/USDC bridge check on the removed EVM tier, and there is no bridge in an L1 or a major price path. It is still accepted on the wire and still parsed by the SDK and the zero-dep clients, whose verify logic rejects a feed carrying peg_ok == false — that check is inert rather than deleted, so an old client keeps working unchanged. Do not add it to new code |
| freshest_ms | u64 | age of the freshest venue read at serve time. On a pool feed this is how recently we polled kascov — not how recently the price moved. That is last_trade_age_s, and on a quiet market the gap between them is days |
| outliers | [str] | venues MAD-filtered out of this round’s median |
All of the above is UNSIGNED advisory metadata. The committee signs exactly kaspulse/v2|PAIR|mant|expo|ts|round — pair, price, time, round. The flags, the depth figures, the TWAP fields and the whole L1 provenance block sit next to the attestation, not inside it: a compromised server could change them without breaking a signature. They are not added to the signed message because v2 is frozen and every verifier demands exact field equality — binding them is a v3 job, and we would rather say that than imply a guarantee we don’t deliver.
Everything else:
| field | type | meaning |
| pair / kind | str | "KAS/USD" · one of "major" | "kcc20-pool". Match on a set — this string has changed twice, and "krc20-l2" was deleted outright on 2026-08-24 |
| mant / expo | u64 / i32 | the exact signed price: mant × 10^expo |
| price | f64 | convenience float of mant × 10^expo — display only |
| price_e8 | i64 | price × 1e8 — the unit on-chain covenants gate on |
| sources | [obj] | per-venue {name, price, age_ms} |
| num_sources | usize | venues in the median after filtering |
| low / high / spread_bps / median | f64 | venue range, spread (bps) and raw median this round. With one source, low == high == price and spread_bps is 0.00 — that is “no second venue”, not agreement |
| twap | bool | true only when this is a kcc20-pool feed and its window is full (12 samples). False on majors — they are instantaneous medians — and false on an L1 feed until 12 pool rounds have accumulated: after a restart, and again after any gap in the pool reads longer than 30 s, which clears the window rather than letting stale samples pose as a warm one. A pool round is SLOW_EVERY (5 s) plus however long the kascov read takes, so read the elapsed time off twap_window_s, not off a clock |
| twap_samples | u32 | samples actually in the window right now (0 on majors) |
| twap_window_s | u32 | Measured: the elapsed seconds between the oldest and the newest sample actually in the window of the venue that set the depth figures. Not twap_samples × 5 s — that nominal form was wrong in both directions, because a pool round takes as long as the RPCs take. This is also a staleness bound: the price you are reading is an average over the last twap_window_s seconds, so part of it is that old |
| liq_wkas | f64 | pool depth in KAS; 0 on majors. On kcc20-pool it is the live covenant reserve and equals reserve_kas — there is exactly one covenant behind an L1 market, so there is no deepest-vs-shallowest split to worry about. The name is a leftover from the removed EVM tier, where it really was WKAS; it is kept because /v1 is frozen, and there is no wrapped asset anywhere in an L1 price path. Prefer move_10pct_usd either way: KAS is a moving target in dollars |
| signers / signatures | [hex] | 5 x-only pubkeys / 5 BIP340 sigs, index-paired |
| threshold | u32 | signatures required (3) |
| message | str | the exact signed string — see “the signed message” |
| signed_ts / signed_round | u64 | what the signature covers (≠ envelope tick) |
| history | [[u64,f64]] | last ~120 [ts, price] points — the only history there is |
Catalog
GET/v1/feeds
The light board: one small row per pair, built once per round. This is what dashboards should poll. Catalog rows are not signed — verify a pair’s full feed before acting on it.
// the shape of a catalog row (rows abridged; live values, moves every round)
{
"round": 4468857779, "timestamp": 1787542163, "count": 17,
"feeds": [
{ "pair": "KAS/USD", "kind": "major", "price": 0.02852,
"num_sources": 3, "halted": false, "degraded": false, "thin": false,
"liq_wkas": 0.0, "spread_bps": 4.74, "freshest_ms": 12279,
"move_10pct_usd": null },
// an L1 row carries eight extra keys — which tier priced it, the age of
// the fill behind it, and enough IDENTITY to draw the row, so a board
// never has to open the envelope just to label a token
{ "pair": "KCC20.a73cdef004099b19/USD", "kind": "kcc20-pool", "price": 0.04547,
"num_sources": 1, "halted": false, "degraded": false, "thin": false,
"liq_wkas": 295663, "spread_bps": 0.0, "freshest_ms": 145,
"move_10pct_usd": 392.42,
"basis": "spot", "last_trade_age_s": 9212,
"token_covenant_id": "a73cdef004099b191759d320de970451be0e10423a7eb15b07d5e51d050b47cd",
"display_name": "humble-teal-lemur", "token_status": "verified",
"holders": 121, "claimed_ticker": null, "art_url": null }
]
}
Catalog rows carry move_10pct_usd, thin, and — on an L1 row — basis + last_trade_age_s, so a board can be honest without fetching every full feed. An L1 row also carries the token’s identity — token_covenant_id (the full 64 hex, not the 16-hex prefix in the pair string, so the deterministic avatar is derivable straight from the row: it reads 25 bytes), display_name, token_status, holders, claimed_ticker and art_url. That is by design: a board should render a complete, correctly-labelled row from this endpoint alone and never block on the envelope. One warning travels with it: claimed_ticker is a string a deployer typed in and nobody verified — if you show it to a human, label it unverified, exactly as the full-feed table below says and as this dashboard does on every surface. It is null on all seven markets kaspulse prices today, and null means “this token never claimed one”, not “it claims a blank”. art_url is kascov’s hash-verified copy or null — fall back to the avatar on any load failure. Five fields stay envelope-only because the row does not carry them: supply, claimed_name, claimed_decimals, claimed_image_hash and basis_note. If you need those, open the envelope — but render the row first, then enrich, which is what this dashboard does. count counts feeds, and an L1 market is two of them — count markets by filtering kind == "kcc20-pool" and taking the /USD legs. Same caveat as everywhere: catalog rows are unsigned.
The L1 KCC20 tier
Why it exists. A KIP-17 covenant can only introspect the transaction that spends it. It therefore cannot read an AMM pool’s reserves without trading against that pool — and it cannot see an exchange at all. On Kaspa L1 an attested off-chain price is the only mechanism. On an EVM L2 the same contract just calls getReserves(), which is why an oracle is structurally necessary here and merely convenient there. That is the whole argument for this tier, and it is not a liquidity argument — L1 is a small pile of KAS. It is why kaspulse deleted its own EVM KRC-20 tier on 2026-08-24 rather than keep 58 feeds an EVM contract never needed an oracle for. The live table, and the dated measurement that ended that tier, are on #/l1.
Where the data comes from — stated once, plainly. kaspulse fetches L1 market data from kascov (the covenant explorer), re-runs the covenant’s own bracket and invariant arithmetic in its own code before publishing, and ships the txid the price is anchored to. That is not independence: a systematic decode bug upstream yields a self-consistent set of integers that passes the re-check. kascov and kaspulse are written and run by the same person, on the same box, behind the same Caddy — co-location does not reduce that gap, it only makes it legible. The honest claim is “we re-verify every admitted fill against the covenant’s audited program and publish the txid”, never “independent of kascov”. One more known drift risk, disclosed rather than hidden: the per-skeleton fee table kaspulse’s bracket check uses is a hand-copy of a private table in kascov (fee_in_bps is never served over HTTP), so it would go stale silently if kascov added a build family. kaspulse’s allowlist refuses unknown skeletons rather than guessing a fee.
Two tiers of price, and the feed says which one you got. Never a self-computed reserve ratio — that reads 22% wrong on a mainnet pool today, because someone donated 100,000,000 tokens into its covenant.
| basis | what it is | when |
| "spot" | kascov’s live gated marginal: (virtual KAS + live reserve) / tokens held, as an exact integer rational. Virtual KAS is 0 on every graduated pool | published only while the covenant’s live token balance still equals the after-balance of its newest verified trade — an anti-donation gate |
| "last_verified_state" | the newest non-carried 1h pool candle close, as the same {base_amount, quote_sompi} integer pair. A verified past pool state, not a live marginal | when that gate withholds spot. It fires silently upstream and can withhold spot from any pool, including the deepest. The live split is the census strip on #/l1 and the per-feed basis, never a number typed in here — it was 1 of 7 on 2026-08-24 and 3 of 7 a week earlier. basis_note carries the reconstructed condition |
Verified on every pool that carries both: the tier-2 candle close is byte-identical to the tier-1 spot pair. Tier 2 is not an approximation of tier 1 — it is the same arithmetic on the newest state kascov will vouch for.
Identity — there is almost none, and inventing some would be the lie. A graduated KCC20 pool token publishes no ticker, no name and no logo on chain: measured 2026-08-24 across all 93 kascov mainnet markets, exactly one carries a claimed_ticker and an image, and none of the seven kaspulse prices carries either. What every token does have is its token_covenant_id, and kascov derives a stable name (display_name, e.g. humble-teal-lemur) and a stable avatar from it. kaspulse’s dashboard runs the same avatar function as kascov — a byte-exact port in web/core/format.js, checked against kascov’s own module on all seven live ids — so one token wears one face on both sites. Two rules if you render these yourself: the avatar reads 25 bytes of the id, so it needs the full 64-hex token_covenant_id and not the 16-hex prefix in the pair string (the name reads only 6 bytes, so the prefix is fine for that); and a claimed_ticker, where one exists, must carry an unverified label — kaspulse never shows one bare.
Units — read this before you scale anything. There is no verified decimals for a KCC20 token anywhere on chain; kascov publishes only a claimed_decimals a deployer typed in, documented as display scale only. So every L1 price kaspulse publishes is sompi per token BASE UNIT, and it says so in price_unit. kaspulse will not silently apply unverified metadata. If you scale by claimed_decimals, that is your decision and you should log it.
Composition. TOKEN/KAS is the TWAPed pool leg (the existing 12-sample window). TOKEN/USD is that leg × the guarded KAS/USD major — the product is never windowed, because the KAS leg is defended by five to seven exchange venues and the token leg by a few hundred dollars of pool. Consequence you must handle: the /USD leg inherits a KAS/USD halt and the /KAS leg does not.
L1 fields
Every field below is unsigned advisory metadata, exactly like the depth and TWAP fields on the other kinds: the v2 message covers pair|mant|expo|ts|round and nothing else. They are what makes the price re-derivable, not what attests to it. A compromised server could change any of them without breaking a signature — which is why price_source_url points at something you can curl instead of asking you to believe them.
| field | type | meaning |
| leg | str | "USD" or "KAS". Two feeds per market: PAIR already domain-separates the signed message, so cross-leg replay is impossible and a consumer sees which leg moved by diffing two attestations |
| token_covenant_id | hex64 | The canonical asset identity. The pair string carries only the first 16 hex; this is the whole thing. Key your storage on this, never on display_name and never on claimed_ticker |
| display_name | str | kascov’s deterministic name, e.g. "humble-crimson-tortoise". Derived from the covenant id, so it is stable and unspoofable — but it carries no chain authority either. Display it; do not key on it |
claimed_ticker claimed_name claimed_decimals | str|null str|null u32|null | UNVERIFIED deployer metadata. Anyone can claim anything. null means the field is absent upstream, which is the truth on every mainnet KCC20 token today — not "it claims a blank ticker". Measured 2026-08-24 across all 93 kascov mainnet markets: exactly one carries a claimed_ticker, and none of the seven kaspulse prices does. If you show these to a human, label them unverified; kaspulse does |
token_status holders supply | str|null u64|null u64|null | the identity signals a KCC20 token genuinely has. token_status is kascov’s decode state for the token’s covenant ("verified" = it recognised the program and decoded the state) — a statement about the CODE, never an endorsement of the project. supply is in token base units and there are no verified decimals to scale it by. It is a bare JSON number, not a quoted string — parse it into a 64-bit integer type, not a String. In a language whose default number is an IEEE double (JS included) a supply above Number.MAX_SAFE_INTEGER would lose its low digits at JSON.parse; the largest live today is 2.1e15, under that bound, but read the raw bytes if you need the exact integer at any size. null when kascov does not publish the field for that token |
| art_url | str|null | null on every market kaspulse prices today. Non-null only when the deployer’s claimed image has a claimed_image_hash kascov verified, in which case this is kascov’s serving URL for those bytes. The deployer’s own claimed_image is deliberately never published or hotlinked — it is an uncontrolled third-party fetch whose bytes are not the bytes the hash covers. A renderer should treat this as an enhancement over the deterministic avatar and fall back to the avatar on any load failure; kaspulse’s dashboard does exactly that |
price_num_sompi price_den | u128 u128 | The exact price, as an integer rational. The price float is this pair evaluated once, at the signing boundary. Divide them yourself and you have the number with no float in the path |
| price_unit | str | always "sompi per token base unit". See units, above |
| basis | str | "spot" | "last_verified_state" — which tier produced this price. Do not collapse them in your UI |
| basis_note | str | a short human string: on tier 2, kascov’s withhold condition with the two balances that disagree |
| verified_as_of_ms | u64 | ms timestamp of the pool state this price is derived from |
| last_trade_age_s | u64|null | The freshness number on an L1 feed — not freshest_ms, which is only how recently we polled. Seconds since the newest fill kaspulse replayed and admitted — an executed trade, not a reserve touch. It is also the age kascov will vouch for: a pool can have an unadmitted on-chain fill newer than this, and that is the honest reading, not a bug |
anchor_txid anchor_daa newest_admitted_txid | hex64|null u64|null hex64|null | the transaction the price is derived from, and its accepting DAA score. On basis: "last_verified_state" that is the candle’s own last_txid, which can be older than newest_admitted_txid: kascov memoizes the pools/trades documents and the candles document separately, so a fill can be admitted by kaspulse’s gate re-run before the candle carrying it is served. Stamping the newer fill onto the older rational would advertise a stale price as current, so the two are published apart. Caveat, stated: the upstream payload carries a txid but no accepting block hash, and stock kaspad has no txid lookup — so a historical fill is not checkable against an arbitrary node. Live pool state is |
admitted rejected_bracket rejected_dust rejected_co_covenant gate_window gate_note | u32 ×5, str | kaspulse’s own re-run of the gates over the newest gate_window fills, using its own copies of bracket_holds and invariant_holds — pure i128, no division, no float, reproducible by anyone with the same integers. rejected_bracket is the anti-donation gate firing: the executed price fell outside the marginals the program itself computed before and after the trade |
market_covenant_id skeleton program_hash invariant_ok | hex64, str, hex64, bool | the venue (a token can migrate curve→pool, so this is advisory and the token id is the identity), the recognised program family, and the replayed two-sided constant-product verdict. Do not pin on program_hash — the KRON program embeds its own mutable state block, so its hash changes on every trade; pinning on it means a permanently dark oracle. kaspulse pins on skeleton against its own allowlist and publishes the hash as evidence |
reserve_kas exercised_trades | f64, i64 | live covenant reserve and lifetime replay-verified fill count |
trades_24h volume_24h_sompi change_24h_bps window_note | u64|null, i128|null, i64|null, str|null | the 24h aggregates, and all three are null together when kascov refuses to re-verify the window — window_note then carries its reason (live today on 2 of 7 pools, e.g. “complete 24h history refused: historical market … names a different token”). null is not 0: change_24h_bps: 0 would assert the market was flat, and the truth is that nobody knows |
| resting_asks | u32 | always 0 today. The on-chain book is ask-only by schema and empty on every mainnet token, under its own provenance disclaimer that nothing in it is a quote. Published as market structure; it is not a price source and cannot yield a mid |
| last_fill | obj | {quote_sompi, base_amount, side} of the newest swap. This is the average fill of that swap, biased by its size and direction — measured against spot it runs from −8.6% to +3.3% on live pools, and the sign tracks side every time. Provenance only. Never a price. |
| indexer_lag_daa | u64|null | how far kascov’s index sits behind its own node’s tip, in DAA score. Read this next to last_trade_age_s, always. On its own a growing trade age is ambiguous — it means either a quiet market or a stalled indexer, and those call for opposite reactions. null means kascov’s /health did not answer, which is not the same as zero lag; a feed carrying null here is one whose freshness you cannot currently check |
| anchor_confirmations | u64|null | tip_daa − anchor_daa: how deeply buried the fill this price is anchored to is. Published rather than enforced, so you set your own reorg tolerance instead of inheriting kaspulse’s. null when the tip is unknown — again not zero |
| stale_fill | bool | true once last_trade_age_s passes 6 h. A flag, not a gate — the feed keeps publishing. Past 48 h it stops being a flag and halted is set instead |
| unpriced_reason | str|null | null on a healthy feed. Non-null means the price on this round has no provenance behind it — do not consume it |
| price_source_url | str | The exact request a third party curls to re-derive this price. The point of the whole block |
Quickstart — integrate an L1 KCC20 price
Ten minutes, no dependencies, and the last step is the one that matters: re-derive the number from kascov yourself and compare.
# 1 — find the markets. Filter kind, take the /USD legs (2 feeds per market).
curl -s $ORIGIN/v1/feeds | jq '.feeds[]
| select(.kind=="kcc20-pool" and (.pair|endswith("/USD")))
| {pair, price, basis, last_trade_age_s, move_10pct_usd, thin, halted}'
# 2 — take one feed. Everything you need to re-derive it is in the JSON.
curl -s $ORIGIN/v1/feed/KCC20.c58c826d0aa9cee6-USD | jq '{
price, price_num_sompi, price_den, price_unit,
basis, basis_note, last_trade_age_s, anchor_txid,
token_covenant_id, display_name, claimed_ticker,
move_10pct_usd, thin, halted, price_source_url }'
# 3 — VERIFY THE SIGNATURES. One file, zero deps. Never skip this.
node kaspulse.mjs verify KCC20.c58c826d0aa9cee6/USD $ORIGIN
# 4 — RE-DERIVE THE PRICE from the upstream source, without trusting us.
# price_source_url is the exact request kaspulse read this round.
curl -s "$(curl -s $ORIGIN/v1/feed/KCC20.c58c826d0aa9cee6-USD | jq -r .price_source_url)"
# ...then check num/den against that document, and the anchor txid on an explorer.
# The full first-party audit — re-runs the ported gates too:
cargo run --bin verify -- $ORIGIN/v1/feed/KCC20.c58c826d0aa9cee6-USD
Then honor five things, in this order. halted — do not consume, and remember the /USD leg inherits a KAS/USD halt while the /KAS leg does not. unpriced_reason — non-null means no provenance this round. basis — decide for yourself whether last_verified_state is good enough for what you are doing. last_trade_age_s — set your own line; kaspulse publishes stale_fill: true above 6 h and sets halted above 48 h (both enforced in halt_out(), not just documented). move_10pct_usd — set your own line rather than trusting the thin boolean, which is only < $250.
// JS — the same shape as any other feed, plus the two L1 gates
const f = await k.feed('KCC20.c58c826d0aa9cee6/USD');
if (!k.verifyFeed(f).ok) throw new Error('signatures');
if (f.halted || f.unpriced_reason) throw new Error('do not consume');
if (f.last_trade_age_s > 6*3600) console.warn('stale fill', f.basis);
if (f.move_10pct_usd < myFloor) console.warn('thin for my size');
// exact, no float in the path — sompi per token BASE UNIT
const exact = BigInt(f.price_num_sompi) * 10n**18n / BigInt(f.price_den);
Refusals — kcc20 on the envelope
kascov indexes far more mainnet markets than kaspulse prices. Publishing only the priced ones would be a lie by omission, so every market kaspulse declines is published with the gate it failed, in the envelope’s kcc20 block. A market is skipped when it is an LP-share token (an LP share is not a price), when its discovery_state is unrecognized or unrevealed, when its skeleton is outside kaspulse’s allowlist, when invariant_ok is false, when it has fewer than three replay-verified fills, or when kaspulse’s own gate re-run rejects it. Nothing is ever dropped silently.
curl -s $ORIGIN/v1/feed | jq '.kcc20 | {network, source, pools_total, markets_total,
priced, skipped_count, taker_fee_bps, kascov_kas_usd, disagreement_bps}'
# group the refusals by reason:
curl -s $ORIGIN/v1/feed | jq -r '.kcc20.skipped[].unpriced_reason' | sort | uniq -c | sort -rn
| field | type | meaning |
| network / source | str | "mainnet", and the public kascov base a third party can curl. KASPULSE_KASCOV_BASE can point the read at a loopback address; in the current deployment it does not (WSL2 NAT — see DEPLOY.md), so the read goes over the public domain and a Caddy fault does take the L1 tier off the air. The published base is separate either way — a 127.0.0.1 URL in a public feed helps nobody |
pools_total markets_total priced skipped_count | usize ×4 | the census: graduated pools upstream, all markets upstream, feeds kaspulse published, markets it refused |
| snapshot_ms / census_ms | u64 | when the priced snapshot and the (slower) full-market census were taken |
| taker_fee_bps | u32 | 130 — see the per-feed field |
kascov_kas_usd disagreement_bps | f64|null | Advisory only, and read kascov_kas_usd_source before reading the gap. kascov serves either its own single unsigned exchange read ("kraken"/"coingecko") or kaspulse’s own signed price ("kaspulse") once its hop to this oracle succeeds. On the latter, disagreement_bps is null: comparing this feed to itself is not a cross-check, and printing a ≈0 bps agreement would be a fabricated one. disagreement_note says which case you are in. Never fed into kaspulse’s MAD filter either way — that would double-weight a venue kaspulse already reads directly, or close the loop for real |
| indexer | obj | {status, lag_daa, tip_daa, last_sync_ok_ms, source, note} from kascov’s own /health — the only place upstream that serves a tip. Without it a stalled indexer and a quiet market are the same observation on the wire: both just show last_trade_age_s climbing. Every field is null when /health did not answer, and null is not zero: an unanswered health check cannot support the claim “perfectly in sync” |
| skipped | [obj] | {market_covenant_id, token_covenant_id, display_name, source, unpriced_reason} per refused market |
Rendered, with every refusal grouped and expandable, on #/l1.
Health
GET/health
Liveness for uptime checks: status 200 when ok, 503 when not. ok := build_age_ms < 5000 && feeds_total ≥ 1 && feeds_live ≥ 1.
curl $ORIGIN/health
# → {"ok":true,"round":4468857779,"uptime_s":86432,"build_age_ms":412,
# "feeds_total":17,"feeds_live":17,"pools":7}
Share pages & OG cards
GET/share/{PAIR}
A crawler-visible page with OpenGraph meta (title, description, live price card) that redirects humans to the SPA’s #/feed/{PAIR}. Paste it in a chat and it unfurls with a live price card. Unknown pair → 404.
GET/og/{PAIR}.png
The 1200×630 card itself: pair, live price, sparkline, the trust line and thin/halted badges rendered on the card. Only served by builds with the og feature (the deployed image); otherwise 404. Cached ~60s.
L1 pairs work here too — the pair name was deliberately built from the charset these routes accept ([A-Za-z0-9-._]), which is the reason it is KCC20.<16hex> and not the colon-separated form the design first reached for. Dash form is case-insensitive, so KCC20.c58c826d0aa9cee6-USD and its uppercase spelling are the same card.
curl $ORIGIN/share/KAS-USD
curl -o card.png $ORIGIN/og/KAS-USD.png
# an L1 KCC20 market
curl $ORIGIN/share/KCC20.c58c826d0aa9cee6-USD