# kaspulse message format — v2 (normative) *This is the interoperability specification for the hosted API at `https://pulse.kascov.io`. It contains the rules and vectors needed to verify published responses without access to the private oracle implementation. Optional MIT-licensed clients are available directly from this service: [/clients/kaspulse.mjs](/clients/kaspulse.mjs) and [/clients/kaspulse.py](/clients/kaspulse.py). No KasPulse repository or server installation is required to consume or verify the hosted API.* Version: **v2** (the literal prefix in every signed message). Any change to the grammar, hash, or signature scheme bumps the version string; verifiers MUST reject prefixes they don't know. --- ## 1. The signed message Each attestation signs one ASCII string: ``` kaspulse/v2|PAIR|mant|expo|ts|round ``` Six fields joined by `|` (0x7C). No whitespace, no trailing separator, ASCII only. Example (a real one — see the test vector in §9): ``` kaspulse/v2|KAS/USD|824000000|-10|1784380800|4242 ``` | field | type | encoding | constraints | |---|---|---|---| | prefix | literal | `kaspulse/v2` | exact match, else reject | | `PAIR` | string | `BASE/QUOTE`, charset `A-Z a-z 0-9 . _ -` per side plus the single `/` | e.g. `KAS/USD`, `NACHO/USD`, `KCC20.c58c826d0aa9cee6/USD`. The `/` inside PAIR is unambiguous because `\|` is the field separator and PAIR never contains `\|`. **Case is significant** — an L1 pair's hex is lowercase and the message is signed exactly as published (the *URL* dash form is case-insensitive; the *message* is not). See §1.1 | | `mant` | u64 | decimal, no sign, no leading zeros | normalized to 9 significant digits: `100000000 ≤ mant ≤ 999999999` for any positive price; `0` only for a zero/invalid price (which no consumer should accept) | | `expo` | i32 | decimal, `-` sign iff negative, no `+`, no leading zeros | typically negative (e.g. `-10` for KAS at ~$0.08) | | `ts` | u64 | decimal | unix seconds when this attestation was signed | | `round` | u64 | decimal | oracle round counter at signing time | The price is **`mant × 10^expo`** — exact at any magnitude. §6 gives the normalization algorithm and why it exists. ## 1.1 Pair naming, and what a price is *per* Two pair shapes exist and they carry different amounts of authority. (A third, the L2 KRC-20 shape `NACHO/USD` — an EVM token's `symbol()` — was **removed on 2026-08-24** along with the tier that produced it. No feed uses it now; a verifier written from this spec needs no special case for it, and an archived feed carrying one still verifies, because nothing in §1–§5 ever depended on the shape.) | shape | example | base identity | price is per | |---|---|---|---| | major | `KAS/USD` | a ticker everyone agrees on | one KAS | | **L1 KCC20** | `KCC20.c58c826d0aa9cee6/USD`
`KCC20.c58c826d0aa9cee6/KAS` | the **first 16 hex of the token covenant id** | **one token BASE UNIT** — see below | **Why the covenant id and not a ticker.** A KCC20 token has no ticker with chain authority. `claimed_*` strings are deployer payload claims; `listed_*` strings are separate KRON registry publisher claims whose testable structural facts kascov checked. Neither provenance turns a human name into canonical asset identity. Putting the covenant id in the pair binds asset identity into the v2 preimage for free, with no spec change. The **full 64-hex id** is always published as `token_covenant_id` in the feed JSON, and that — not the shortened pair prefix, display name, claimed ticker or listed ticker — is what a consumer MUST key storage on. **Why 16 hex and not 64.** The pair must survive the dash-form URL routes (`/v1/feed/{PAIR}`, `/share/{PAIR}`, `/og/{PAIR}.png`), whose key charset is `[A-Za-z0-9-._]`. A colon-separated `KCC20:<64hex>/KAS` would 404. If two tokens ever shared a 16-hex prefix, the second is **refused registration** with a logged alert rather than served — a prefix collision must never silently mint two feeds over one pair string. `KCC20.` also remains a **reserved symbol prefix**: no feed of any other kind may ever mint a pair beginning with it, so a future tier cannot collide with a real L1 pool's signed-message domain. (It was introduced as a guard against an attacker-named ERC-20 on the since-removed L2 tier; the reservation outlives the tier and costs nothing.) **Two legs per L1 market, and why.** Each market publishes `…/USD` and `…/KAS` as separate feeds. PAIR already domain-separates the preimage, so cross-leg replay is impossible with zero spec change; a consumer sees which leg moved by diffing two signed messages; and the `/KAS` leg keeps publishing when KAS/USD halts, because it is not denominated in dollars. **Base units — the part that will bite you.** There is no *verified* `decimals` for a KCC20 token anywhere on chain. `claimed_decimals` is a deployer claim and `listed_decimals` is a separate registry-publisher claim. An L1 price is therefore **sompi per token base unit**, and the feed says so in `price_unit`. kaspulse will not silently apply either metadata value to a signed number. If your integration scales by one, that is an explicit trust decision you own. **Exactness.** Alongside the signed `mant`/`expo`, an L1 feed publishes `price_num_sompi` / `price_den`: the exact integer rational the float was evaluated from, once, at the signing boundary. Divide those two integers and there is no float in the path at all. ## 2. The digest ``` digest = BLAKE2b(message_ascii_bytes) — unkeyed, 32-byte output ``` Plain BLAKE2b with `digest_size = 32` (a.k.a. blake2b-256). No key, no salt, no personalization, no domain tag outside the message's own `kaspulse/v2` prefix. In Python: `hashlib.blake2b(msg, digest_size=32)`. In Rust: `blake2b_simd::Params::new().hash_length(32).hash(msg)`. Known answer for implementation sanity: `blake2b-256("abc")` = `bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319`. ## 3. The signature Each committee node signs the digest with **BIP340 Schnorr over secp256k1**, where the 32-byte BIP340 message `m` is the blake2b digest itself: ``` sig_i = BIP340_Sign(seckey_i, m = digest) valid = BIP340_Verify(pubkey_i, m = digest, sig_i) ``` **Explicitly: the digest is NOT hashed again outside BIP340's own tagged hashing.** BIP340 internally computes `e = int(tagged_hash("BIP0340/challenge", r ‖ P ‖ m)) mod n` — that is the only further hashing. If your Schnorr library takes a "message" and hashes it with SHA-256 first, do not use that path; pass the 32-byte digest as `m` directly. Encodings: - **`signers[i]`** — 32-byte **x-only** public key, lowercase hex (64 chars). - **`signatures[i]`** — 64-byte BIP340 signature (`r ‖ s`), lowercase hex (128 chars). - Verifiers SHOULD accept hex case-insensitively; kaspulse emits lowercase. ## 4. Committee, threshold, index pairing The hosted feed carries `signers` (n = `num_nodes` = 5 entries), `signatures` (5 entries), and `threshold` (= 3). The arrays are **index-paired**: `signatures[i]` is the signature offered for `signers[i]` over the digest. There is no subset selection or reordering — verify position by position, decode each x-only public key, and identify a signer by its decoded 32 bytes rather than the spelling or case of its hex string. ``` V = { decoded_key(signers[i]) : BIP340_Verify(signers[i], digest, signatures[i]) } VALID self-described feed := |V| ≥ feed.threshold AND the field-binding check of §5 passes ``` `V` is a **set**. A decoded key earns at most one vote even if its string and valid signature are repeated at several indexes. For an independently pinned committee, the `/v1/committee` artifact supplies `committee.threshold`, `committee.num_nodes` and `committee.signers`. A conforming pinned verifier MUST fail closed if the threshold or signer set is empty, `num_nodes` differs from the signer count, any key is malformed or not a valid x-only secp256k1 public key, two encodings decode to the same key, or the threshold exceeds the number of distinct decoded keys. It MUST then require: ``` |V ∩ PINNED_KEYS| ≥ committee.threshold ``` The feed's own `threshold` cannot weaken this independently supplied policy. A matching signer label is not a vote: the aligned signature must be valid. When `covenant.preimage` is present, repeat the same distinct-valid-pinned-key count over `blake2b-256(rebuilt_cov_v2_preimage)` and require the committee threshold in that domain independently. A valid pinned message quorum must never launder attacker signatures in the covenant domain. Fetching `/v1/committee` from the same origin as the feed is useful consistency checking, not independent identity. Pin or compare that artifact through a separate trusted channel if origin compromise is in scope. The hosted deployment currently uses five keys in one process under one operator; 3-of-5 describes the cryptography, not five independent machines. A verifier SHOULD report per-node results (which indexes verified), not just the boolean — that is what the site's verify button and both clients do. ## 5. Field binding (REQUIRED) A verifier **MUST** parse the message string and check that its fields equal the JSON fields it is about to use: ``` message.PAIR == feed.pair message.mant == feed.mant (compare as strings or exact integers) message.expo == feed.expo message.ts == feed.signed_ts ``` **Why this is not optional:** the signatures cover the *message string*, not the JSON. Without this check, a compromised or buggy server could serve valid signatures over one price next to JSON fields claiming another — every signature verifies, and you still consume an unsigned number. Binding the fields closes that gap; it is required by the public JavaScript, Python and browser verifiers. `message.round == feed.signed_round` is the same check for the round and MUST also be enforced. Note the binding is against `signed_ts` / `signed_round`, **not** the envelope's `timestamp` / `round` — see §7. ## 6. mant/expo — the 9-significant-digit normalization The signed price is a mantissa/exponent pair, computed from the median `p` (a finite floating-point value) as follows: ``` if p <= 0 or p not finite: (mant, expo) = (0, 0) expo = floor(log10(p)) - 8 mant = round(p / 10^expo) if mant >= 1_000_000_000: # rounding carried into a 10th digit mant /= 10; expo += 1 ``` Result: `mant` always has exactly 9 significant digits and `p ≈ mant × 10^expo` to 9 significant digits at **any** magnitude. **Why not just `price_e8`?** A fixed 8-decimal integer quantizes tiny prices to zero: measured live on the since-removed L2 tier, a $3e-9 token signed `price_e8 = 0` (100% error), and other sub-1e-7 tokens signed with 3–27% error. The reason still stands — an L1 KCC20 price is sompi per token **base unit** (§1.1), which is exactly where tiny numbers live. The feed still carries `price_e8` as an *informational* field; the **signed** number — the only one a consumer should trust — is `mant × 10^expo`. To compare against a strike, bring both to a common exponent using integer arithmetic; do not round-trip through floats on-chain. ## 7. Timing semantics — sign on change, 5-second heartbeat Prices are signed **when they change**, plus a heartbeat re-sign of unchanged prices at most every **5 s**. Consequently a feed carries two clocks: - **`signed_ts` / `signed_round`** — belong to the **signature**: when the attestation you hold was produced. This is what field binding checks and what freshness checks must use. - **`timestamp` / `round`** (envelope level) — belong to the **serve tick** (~400 ms cadence): when the JSON you fetched was assembled. A fresh envelope can legitimately carry an attestation up to ~5 s old (price unchanged, heartbeat not yet due). Consumers enforce staleness against `signed_ts`: use `checkedValue(feed, {maxAgeMs: 30000})` in JavaScript or `checked_value(feed, max_age_s=30)` in Python after independently pinned committee verification (§4). Both public clients also reject a timestamp more than 30 seconds in the future, so modest clock skew is tolerated but fabricated future freshness fails closed. ## 8. On-chain encodings (bond record, price_bytes) Two fixed binary encodings are used by the covenant tooling. ### 8.0 WITHDRAWN, 2026-07-27: the unbound covenant signature > **Superseded 2026-08-24 by §8.0b, `kaspulse/cov/v2`.** `covenant.signatures` > is published again — but *only* under the bound domain below, never under the > `blake2b-256(price_bytes)` domain this subsection describes. Read this > subsection as the post-mortem it is; it explains what the new domain has to > bind and why. **The `blake2b-256(price_bytes)` domain is dead and will never be a signing domain again.** Earlier builds published five BIP340 signatures over `blake2b-256(price_bytes)` under each feed's `covenant` object, and earlier revisions of this section told you to use them in production. Do not. **They are not revoked, and we cannot revoke them.** The committee keys are unchanged — the same five x-only pubkeys are still served by `/v1/committee` — so every covenant-domain signature anyone captured while that field was live still verifies today and will verify forever, and still satisfies any **legacy** `price_gate_redeem` gate below the price it covered (or, for the feeds that quantized to `price_e8 = 0`, every legacy `AtOrBelow` gate). BIP340 has no revocation; only key rotation would orphan them, and rotation has not happened. **What they cannot do is satisfy a `kaspulse/cov/v2` gate**: their preimage is a bare 1–8 byte integer, and it cannot carry the 24-byte tag‖pair‖expo prefix the v2 redeem script slices out with `OpSubstr` and compares. A legacy operand pushed at a v2 covenant aborts on the substring bounds check, not on the signature check. So: do not build on the legacy script, ever — but the hosted committee itself is usable again, through §8.0b. **Why it was withdrawn.** The signed preimage was `price_bytes` and nothing else: the bare integer `price_e8`, with no pair, no exponent, no round and no timestamp in it. So a signature was never a statement about *a price* — it was a statement about *a number*, and the same number occurs in every feed's strike space. BTC/USD's published signature over `6524045000000` therefore satisfied any `AtOrAbove` price gate on **any** pair whose strike was lower, which is every realistic KAS or token gate; feeds whose price quantized to `price_e8 = 0` (sub-1e-8 tokens do) carried valid signatures over `blake2b-256("")` and satisfied every `AtOrBelow` gate permanently; and nothing in the encoding ever expired, so every attestation was replayable forever. That is not a tuning problem, it is a missing domain — the only correct action is to stop publishing it. `price_e8`, `price_bytes`, `record` and `record_signatures` remain in the `covenant` object; `record`/`record_signatures` are the bond domain (§8.1), which **is** bound to pair and round. `price_e8` / `price_bytes` are kept as **convenience operands only** — a caller may push them at a script they built themselves. They are never again a kaspulse signing domain. ### 8.0b SHIPPED, 2026-08-24: `kaspulse/cov/v2`, the bound covenant signature > **DEPLOYED 2026-08-25.** The hosted instance at `https://pulse.kascov.io` > runs this build: `/v1/committee` announces the `kaspulse/cov/v2` domain, and a > feed's `covenant` object carries `{preimage, signatures, price_e8, > price_bytes, record, record_signatures}`. Everything in this section describes > what the hosted oracle emits. This public specification is sufficient to > verify everything below. The five committee keys are unchanged. The replacement binds the price to its pair, exponent, round and timestamp in **one** preimage, which is published verbatim as `covenant.preimage` (hex) alongside `covenant.signatures`: ``` preimage = "kaspulse/cov/v2" # 15 bytes ASCII offset 0..15 ‖ blake2b-256(PAIR)[0..8] # 8-byte pair id offset 15..23 ‖ expo as i8 # 1 byte offset 23..24 ‖ round as u64 big-endian # 8 bytes offset 24..32 ‖ ts as u64 big-endian # 8 bytes offset 32..40 ‖ mant as little-endian minimal script number VARIABLE, offset 40..end sig = BIP340_Sign(node_key, m = blake2b-256(preimage)) ``` Bytes `0..24` — tag ‖ pair id ‖ expo — are the **bound prefix**. The covenant bakes those 24 bytes literally into its redeem script, and therefore into its P2SH address, and the script requires the supplied preimage's first 24 bytes to equal them. It signs `mant`+`expo`, not `price_e8`, because e8 quantization loses more than 1% on six live pairs and 100% on the sub-1e-8 ones. Known-answer vector checked by the public clients and browser verifier (`KAS/USD`, mant 290000000, expo −10, round 4242, ts 1756000000): ``` 6b617370756c73652f636f762f7632 b84ad8389aa2ebb0 f6 0000000000001092 0000000068aa6f00 800c4911 └──────── tag, 15 ────────────┘ └── pair id ──┘ ex └──── round ────┘ └──── ts ──────┘ └mant┘ ``` **Verify it by rebuilding, never by reading.** A verifier must reconstruct the preimage from the feed's own `pair` / `mant` / `expo` / `signed_round` / `signed_ts` and require `covenant.preimage` to match **byte for byte** before it looks at a single signature. Parsing the published blob's own fields and checking them against themselves is the document grading itself — that is the shape of the 2026-07-27 failure. The public clients perform this check through `verifyCovenant(feed)` in JavaScript and `verify_covenant(feed)` in Python. **Redeem script outline**, with the preimage on top of the signature slots: ```text OpDup <0> <24> OpSubstr OpEqualVerify ; 1. bind tag ‖ pair ‖ expo OpSize <41> OpGreaterThanOrEqual OpVerify ; 2. mantissa tail is non-empty OpDup OpSize <40> OpSwap OpSubstr cmp OpVerify ; 3. mant vs strike (cmp = >= or <=) OpBlake2b … OpCheckSigFromStack … ; 4. threshold of committee sigs ``` Step 3 is the variable-width slice: `OpSize` pushes the blob's length *without* popping it, so `OpDup OpSize <40> OpSwap` leaves `(data, 40, len)` — exactly `OpSubstr`'s `(data, start, end)` order — and no fixed mantissa width is ever baked in. A blob shorter than 40 bytes underflows `end − start` and aborts, so a truncated preimage cannot slip through. Step 2 exists because of the withdrawal, not for tidiness. A blob of **exactly** 40 bytes slices to an **empty** tail, and an empty stack item is numeric **zero** — which satisfies every `AtOrBelow` gate. That is the `blake2b("")` failure mode reborn one layer down. Conforming encoders must refuse a zero-mantissa preimage. The script must not depend on the signer being careful, so the length floor is enforced on-chain too. **Limitation A: the bare gate is ANYONE-CAN-SPEND once it is in the money.** The redeem has no `OpCheckSig`, no `OpCheckSequenceVerify` and no output introspection — it never looks at the transaction that spends it. A winning witness needs only `covenant.preimage` and `covenant.signatures`, which this oracle **publishes**, plus redeem parameters that are all public. So the moment the price condition holds, any member of the public can rebuild the redeem and take the coin. A beneficiary-bound script appends `OpVerify OpCheckSig` so a spend also needs the beneficiary's transaction signature. That protection was checked in internal script-engine tests; the historical TN10 txids below deployed the *bare* gate. **Limitation B: there is no on-chain freshness enforcement of any kind.** `round` and `ts` are inside the signed blob, so they cannot be swapped for another round's — they are bound *cryptographically* — but nothing compares them against a minimum. **An old but genuine attestation for this pair at this exponent satisfies the redeem script forever.** Two corrections to earlier versions of this document: * A script-side `min_round` is **not** impossible. Minimality is not enforced when covenants are enabled, so a fixed-width 8-byte **little-endian** round is a legal numeric operand and `OpSubstr <24> <32>` + ` OpGreaterThanOrEqual OpVerify` is a working floor, demonstrated in internal script-engine tests. However, cov/v2 encodes round and ts **big-endian**; that is our own encoding choice and it is what forecloses the floor. Fixing it is a cov/v3 domain break, not a patch to this domain — and this document said "cannot" where it meant "chose not to", two paragraphs before explaining why "cannot" was false. * "Enforce spend-time freshness with an nSequence / DAA relative timelock on the spending transaction" is **retracted**. The spender builds that transaction and the script never reads it (Limitation A), so nothing obliges anyone to set a sequence. `OpCheckSequenceVerify` binds only from *inside* a redeem — for example, a bond's reclaim branch — and a *relative* lock proves a UTXO is old, never that an attestation is recent. Until cov/v3, **nothing shipped bounds attestation staleness.** Binding a payee makes a stale attestation worthless to a *stranger* — that is Limitation A, and it is all of what it is. It does nothing between the funder and the payee: in the canonical payoff case the payee IS the party a stale attestation pays. Two things that used to be advised here are withdrawn for the same reason the nSequence advice was: "keep the funded UTXO short-lived" bounds nothing (this redeem never reads the output's age, and with no reclaim branch the funder cannot retire the output at all), and "check `signed_ts` off-chain before you sign" binds only whoever signs the *spend*, which in the beneficiary-bound script is the payee. An age window on `signed_ts` is real advice for a CONSUMER reading a price (`checkedValue` / `checked_value`); it is not a control the funder holds over the payee. So: fund a payee-bound gate only where you are content for the payee to be paid on ANY round that ever cleared the strike. *This covenant proves the committee said it — not that they said it recently, and not who may spend against it.* **Expo is part of the address.** `expo = floor(log10(price)) − 8`, so when a price crosses a power of ten the feed's exponent shifts and fresh attestations stop matching a prefix baked at the old one. A consumer must detect this mismatch; a script bound to the new exponent has a different address. This follows from binding the exponent and prevents the old domain's 10× ambiguity. **The preimage is one blob, not an equivalence class.** The VM would accept a non-canonical mantissa tail when covenants are enabled, so on-chain `[0x01, 0x00]` and `[0x01]` both read as 1. The public verifiers rebuild the canonical preimage and reject a redundantly padded tail. That is not a hole (the byte-for-byte field binding still catches a swap); it means there is exactly **one** correct encoding of a given price, and a verifier compares bytes rather than decoded values. **Historical validation, in order:** (1) internal tests executed the script bytes through Kaspa's real `TxScriptEngine`, without a chain transaction: 24/24 cases passed, including rejection of **valid** cross-pair and cross-exponent signatures. The same script was also tested with the hosted committee's published signatures. (2) Validation then ran end-to-end on **testnet-10** with those hosted signatures — `KAS/USD` @ expo −10, strike $0.02, deploy `72f4d4df956eba645a9cb14ff8421feb1073b4d4cbec59fa62683ad6816e4a4d` → spend `399e85ccac69ef1c35f8a18cf146abd8b697d502eff4db4dfd50e93f599d2e7c`, and `KCC20.7a98370c0d6b037c/KAS` @ expo −10, strike 0.005 KAS, deploy `65941216fe445de6f4198f607ddc2afa8f5f7a9a1cf026192ffd1c1f2d6c8c71` → spend `21843392f5a6eaca7bbb7903ffe992e2e132d80ef7c5e4e098718100cfa0f597`; all four `is_accepted: true`, each spend consuming its own deploy's output 0 with a 616-byte covenant witness. **Not proven: an on-chain mainnet consumer.** No consumer, gate, standing publisher, or slashing transaction from this project has run on mainnet; the oracle itself reads mainnet market data. Pin the committee independently and use `verifyWithCommittee(feed, pin)` in JavaScript or `verify_with_committee(feed, pin)` in Python; treat `GET /v1/committee` as the artifact to compare, not as an independent channel when it shares the feed's origin. The `committee.threshold` distinct-valid-key requirement applies separately to the §1 message domain and to this one. ### 8.1 The 32-byte attestation record, v2 (equivocation bond) For the slashing bond, a node signs fixed-width records: ``` record (32 bytes) = blake2b-256("kaspulse/bond/v2|" ‖ PAIR_ascii)[0..8] ‖ round as u64 big-endian # 8 bytes ‖ mant as u64 big-endian # 8 bytes ‖ expo as i64 big-endian # 8 bytes (two's complement) slot = record[0..16] (pair id ‖ round) tail = record[16..32] (mant ‖ expo — the compared price) sig = BIP340_Sign(node_key, m = blake2b-256(record)) ``` Two valid records with the **same slot** and a **different tail**, both signed by one node key, are a proof of equivocation — the bond covenant verifies the proof on L1 and releases the bond to whoever supplies it. Worked example (PAIR `KAS/USD`, round 4242, mant 824000000, expo −10 — i.e. $0.0824): ``` blake2b-256("kaspulse/bond/v2|KAS/USD") = 9dab6487a796ddcb92bfa3ae1290d099d6e9a24c681b82927da88035de203b9d pair id = 9dab6487a796ddcb record = 9dab6487a796ddcb 0000000000001092 00000000311d3e00 fffffffffffffff6 ``` A conforming implementation must reproduce this record byte for byte. **`expo` is in the compared tail on purpose.** Without it, mant 293800000 at expo −10 and at expo −9 produce byte-identical records: a 10× price move would be provably *unslashable*. **The pair id is domain-separated** so a v1 record can never share a slot with a v2 one. **Migration — v1 and v2 are mutually unslashable.** Records changed on 2026-07-27. Earlier revisions of this section specified a 24-byte record `blake2b-256(PAIR)[0..8] ‖ round ‖ mant` (pair id `b84ad8389aa2ebb0` for `KAS/USD`). `bond_redeem` / `bond_redeem_with_reclaim` moved their `OpSubstr` indices from `(16,24)` to `(16,32)`, which is a different script and therefore a different P2SH address: a v1 bond aborts on every record the oracle now signs, and a v2 bond aborts on every v1 record. **Any bond already posted behind a v1 redeem is stranded** — the plain `bond_redeem` has no reclaim branch at all, and the `bond_redeem_with_reclaim` variant's branch was itself unspendable until 2026-08-25 (a Bitcoin-style `OpDrop` after Kaspa's *popping* `OpCheckSequenceVerify` consumed the node's signature; see the [public changelog](/changelog.html) for the correction). Earlier revisions of this line said such a bond "must be reclaimed and re-posted"; that was not actually possible. Testnet only, so nothing was lost. **The redeem also pins record LENGTH, added 2026-08-25.** Both scripts now open each record with `OpSize <32> OpNumEqualVerify`. Without it the script never checked how long the blob was — it just sliced `[0..16]` and `[16..32]` out of whatever it was handed — so two of the oracle's *own published* signatures answered "same slot, different price": two `kaspulse/cov/v2` preimages share the constant prefix `"kaspulse/cov/v2" ‖ pairId[0]` in `[0..16]` and carry the round in `[16..32]`, and two `kaspulse/v2` ASCII messages share `"kaspulse/v2|KAS/"` with the mantissa moving in `[16..32]`. Either pair released the bond with no double-signing and no secret. A domain tag cannot fix this because the tag sits *inside* the compared window; length can, since no other kaspulse domain is 32 bytes (a cov/v2 blob is ≥ 41, a v2 message ≥ 39). Internal regression tests reject both cross-domain attempts. This too changes both scripts' bytes and P2SH addresses. **Proven on TN10 — 2026-08-25.** The current `bond_redeem` (108 bytes) was deployed and slashed live: deploy [`1af295c8…775b`](https://explorer-tn10.kaspa.org/txs/1af295c8f6033ef069568c644f4f38c6d46635dfcac01a9166b0bf987a49775b) → slash [`ff02cf22…e376`](https://explorer-tn10.kaspa.org/txs/ff02cf225346dd8a3c05c27edcb53921d20a6dc1fd0157aca7ad854239b8e376), both `is_accepted: true`. The slash witness carries two records with pair id `9dab6487a796ddcb`, round 42, and mantissas 2 900 000 vs 5 800 000 at expo −10 — the layout above, executed by L1. ### 8.2 price_bytes — minimal script-number encoding The covenant price is `price_e8` (i64) pushed as a **minimal little-endian script number**: ``` 0 → empty byte string otherwise → little-endian bytes of |price_e8|, minimal length; if the top byte has bit 0x80 set, append 0x00 (or 0x80 if negative); else if negative, set 0x80 on the top byte ``` Examples: `8240000` (= $0.0824 e8) → `80bb7d`; `128` → `8000`; `127` → `7f`. Non-minimal encodings break the on-chain numeric comparison — encode exactly this way. Historical demonstrations signed `BIP340(m = blake2b-256(price_bytes))`; the hosted committee no longer uses that withdrawn domain (§8.0). Today `price_bytes` is an encoding, not an attestation domain. ## 9. Test vectors ### 9.1 Implementation sanity (embed these as self-tests) Every kaspulse verifier runs these at load and refuses to run if they fail: - **BIP340 official test vector 0** — pubkey `F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9`, message `0000…00` (32 zero bytes), signature `E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0` → must verify (and a corrupted copy must not). - **blake2b-256("abc")** → the known answer in §2. ### 9.2 The kaspulse end-to-end vector One full example from message string to valid signature, generated with a throwaway key. The signature uses BIP340 aux randomness of 32 zero bytes, so the snippet below reproduces this output **byte-identically**: ``` secret key : 4fea87744110fb2fbf7d15b0b72f07fa3c47b20bb70b737d70b9f192df35e41f signer : 10a26a455a1abec4e1de900005b618f0dd0650db3ed842737d46f9d8793506af message : kaspulse/v2|KAS/USD|824000000|-10|1784380800|4242 digest : 41f7fcbffcd7ccfeb8e0047a0b3e71e64c2d34a9923e63a118aeabb910efcb8c signature : 2102a2f6e3900436efb837f3cca8b64b50efb400feb7a4b873147c6679bc3ee8ef5575257329255b68f1b9d42b207f6bb4dcbb88c148160013e4e566d909316a ``` (The throwaway secret key is `blake2b-256("kaspulse test vector 1")` — derive it, don't trust it. It secures nothing.) A conforming verifier MUST accept this vector, and MUST reject it when any single hex character of the message, digest, signature, or signer is changed. Optional standalone vector regeneration: the Rust program below uses only public cryptographic libraries and needs no KasPulse implementation. Its library dependencies are `secp256k1 = { version = "0.29", features = ["global-context", "rand-std"] }`, `blake2b_simd = "1"`, and `hex = "0.4"`. ```rust use secp256k1::{Keypair, Message, SECP256K1}; fn main() { // throwaway secret key = blake2b-256("kaspulse test vector 1") let sk_bytes = blake2b_simd::Params::new() .hash_length(32) .hash(b"kaspulse test vector 1"); let sk = secp256k1::SecretKey::from_slice(sk_bytes.as_bytes()).unwrap(); let kp = Keypair::from_secret_key(SECP256K1, &sk); let message = "kaspulse/v2|KAS/USD|824000000|-10|1784380800|4242"; let digest = blake2b_simd::Params::new() .hash_length(32) .hash(message.as_bytes()); let msg = Message::from_digest_slice(digest.as_bytes()).unwrap(); // aux_rand = 32 zero bytes → deterministic output (any BIP340-valid // signature over this digest is equally acceptable to a verifier) let sig = SECP256K1.sign_schnorr_with_aux_rand(&msg, &kp, &[0u8; 32]); println!("secret key : {}", hex::encode(sk_bytes.as_bytes())); println!("signer : {}", hex::encode(kp.x_only_public_key().0.serialize())); println!("message : {message}"); println!("digest : {}", hex::encode(digest.as_bytes())); println!("signature : {}", hex::encode(sig.as_ref())); assert!(SECP256K1.verify_schnorr(&sig, &msg, &kp.x_only_public_key().0).is_ok()); println!("self-check : signature verifies"); } ``` ## 10. Verifier checklist A conforming verifier, in order: 1. Parse the message; reject unless the prefix is exactly `kaspulse/v2` and there are exactly 6 fields. 2. **Bind the fields** (§5) — else report `bound = false` and do not use the price. 3. `digest = blake2b-256(message ASCII bytes)`. 4. For each `i`: decode `signers[i]`, then `BIP340_Verify(signers[i], digest, signatures[i])`; count each decoded valid key at most once. 5. For self-described verification, accept iff the distinct-valid count is at least `feed.threshold`. For pinned verification, validate the committee artifact and require at least `committee.threshold` distinct valid decoded keys from its set, independently in the message and cov/v2 domains (§4). 6. Then honor the safety flags — `halted`, `thin`, `degraded`, and `peg_ok == false` **if the feed carries it** — and check freshness against `signed_ts`. Valid signatures over a halted or stale price are still a price you shouldn't use. **A note on `peg_ok`, for verifier authors.** The oracle **no longer computes or emits it.** It was a WKAS/iKAS bridge-depeg check that existed only for the L2 KRC-20 tier, removed with that tier on 2026-08-24. Every conforming verifier **MUST still parse the field and MUST still refuse a feed whose `peg_ok` is `false`**, because archived feeds carrying it must retain their safety verdict. The public JavaScript, Python and browser verifiers all enforce this rule. The earlier browser gap was corrected on 2026-08-25. Absent is not `false`: treat a missing `peg_ok` as "not asserted", which is what a live feed now always is. Same for `pool_age_s` — gone from the wire, harmless if you still read it. **What the signature does and does not cover.** The v2 message is exactly the six fields of §1. Everything else in the feed JSON — the safety flags (`halted`, `degraded`, `thin`, `divergent`), the depth figures (`move_10pct_usd`, `depth_2pct_usd`), the TWAP fields (`twap`, `twap_samples`, `twap_window_s`), **the entire L1 provenance block** (`basis`, `basis_note`, `verified_as_of_ms`, `last_trade_age_s`, `anchor_txid`, `anchor_daa`, `admitted`, `rejected_bracket`, `price_num_sompi`, `price_den`, `token_covenant_id`, `market_covenant_id`, `skeleton`, `program_hash`, `invariant_ok`, `newest_admitted_txid`, `stale_fill`, `window_note`, `last_fill`, `taker_fee_bps`, `resting_asks`, `price_source_url`) **and the entire identity block of §10.1** — is **unsigned advisory metadata**. The L1 block is the one place where that limitation is *designed around* rather than merely disclosed: `price_source_url` is the exact upstream request the price was derived from, so a consumer who does not want to trust the block does not have to. Curl it, divide `price_num_sompi / price_den`, check the `anchor_txid` on an explorer. Nothing about that check needs kaspulse to be honest. (Binding a txid into the preimage was considered and rejected: it would re-sign an unchanged price on every fill, breaking the sign-on-change cache, and a TWAPed leg has no single txid to bind.) It is computed by the same process that signs, but a compromised server could alter it without breaking a single signature. Honor it (it is the oracle telling you when not to trust the number) while understanding that it is a server claim, not an attestation. It is deliberately not folded into the signed message: v2 is frozen and public verifiers demand field equality, so adding fields to the message would break every published attestation and every existing verifier. Binding the flags is a v3 job. ## 10.1 The L1 KCC20 identity block (unsigned, provenance-separated) An L1 KCC20 token has no human name or ticker with chain authority. The wire keeps three sources separate so a consumer never has to infer provenance: `display_name` is deterministic from the covenant id, `claimed_*` comes from the deployer's on-chain genesis payload, and `listed_*` comes from the KRON launchpad registry after kascov checked the row's testable structural claims. All three are **unsigned advisory metadata**. The full 64-hex `token_covenant_id` remains canonical even when a useful name or ticker exists. | field | type | what it is | |---|---|---| | `display_name` | `str` | kascov's **deterministic** name, derived from the token covenant id (adjective-colour-animal, e.g. `humble-teal-lemur`). Stable and unspoofable because it is a function of the id — but it carries **no chain authority**. Display it; never key on it | | `token_status` | `str \| null` | kascov's indexing/verification state for this token (for example, `"verified"`). Read it per row; market counts change. This is a decode state, **not an endorsement** of the asset | | `holders` | `u64 \| null` | distinct holders kascov counts | | `supply` | `u64 \| null` | supply **in base units** — there is no verified `decimals` (§1.1) | | `claimed_ticker` | `str \| null` | a string the deployer typed into the genesis payload. **UNVERIFIED.** If you show it to a human, label it so — kaspulse does, everywhere, without exception | | `claimed_name` | `str \| null` | deployer-supplied genesis name claim; same trust tier and warning as `claimed_ticker` | | `claimed_decimals` | `u32 \| null` | deployer-supplied display-scale claim. Never rescale the signed base-unit price implicitly | | `claimed_image_hash` | `hex64 \| null` | hash of the deployer-published image. Its presence is what makes the art servable | | `art_url` | `str \| null` | absolute URL of the **hash-verified** art, served by kascov at `/img/mainnet/{token_covenant_id}`. Emitted **only** when `claimed_image_hash` is non-null (that endpoint 404s otherwise — verified 2026-08-24). The deployer's own `claimed_image` URL is deliberately **never** published or hotlinked | | `listed_ticker` | `str \| null` | KRON registry publisher's ticker claim, kept separate from every `claimed_*` field. Show its registry provenance; it is not an on-chain ticker | | `listed_name` | `str \| null` | KRON registry publisher's name claim, with the same provenance and semantic limitation | | `listed_decimals` | `u32 \| null` | KRON registry publisher's display-scale claim. It is not a verified rescaling instruction | | `listed_known` | `bool \| null` | `true` means kascov indexed the registry row's full covenant id. It does not validate the human name or ticker | | `listed_checks_passed` | `bool \| null` | `true` means every testable covenant/genesis/creator statement in the row agreed with kascov's chain index. It is a structural verdict, **not** a semantic endorsement of `listed_name` or `listed_ticker` | | `listed_list_name` | `str \| null` | publisher/list provenance (currently `KRON`) that a UI should retain beside listed identity | | `listed_art_url` | `str \| null` | emitted only when the registry row is known, every structural check passed, and kascov has a positive same-covenant-id witness for its local `/listed-img/mainnet/{token_covenant_id}` copy. The publisher's original image URL is ignored, avoiding a tracking/SSRF surface | The registry boundary is fail-closed but cosmetic: wrong-network documents, duplicate or malformed full covenant ids, malformed fields, or more than 5,000 rows reject the whole refresh and retain the last-known-good registry cache. Registry or census failure never halts or unprices a feed. A consumer MAY promote `listed_name` / `listed_ticker` as its primary human label only when both `listed_known` and `listed_checks_passed` are `true`, and SHOULD keep `listed_list_name` or an equivalent registry badge visible. This is still advisory display policy, not canonical identity or price verification. **The avatar is not a field.** Every token gets a visual with zero invention by rendering an SVG **derived from the token covenant id** — hue from id bytes 6–7, accent offset from byte 8, 2 or 3 shapes from byte 9, each shape's kind, angle, distance, size and rotation from bytes 10.. / 15.. / 20.., on a 64×64 viewBox. This is deliberately **the same algorithm kascov uses** (`avatarSvg` in its `web/core/format.js`) so one token renders identically on both sites. It is a client-side rendering of data you already hold, so it is not on the wire, and it is the fallback whenever both `art_url` and `listed_art_url` are absent or fail to load. **The rule this block exists to enforce:** kaspulse never invents a ticker and never collapses a deployer claim, a structurally checked registry claim and a deterministic id-derived label into one unnamed trust tier. Human metadata is useful when labelled; the full covenant id is authoritative.