Reference
Signing and the price payload
The signature scheme, the domain digest, the 96-byte PriceUpdate layout, the price frame, and the steps that verify one update.
Each node signs every update with its own secp256k1 key. The consumer recovers the signer from the signature and looks it up in the registry.
A signature proves who signed an update. It does not prove that the price is correct. The median across independent nodes is the defence against a bad node. See Trust model.
Signature scheme
The scheme is secp256k1 ECDSA, recoverable, over a Keccak-256 digest.
| Property | Value |
|---|---|
| Curve | secp256k1 |
| Hash | Keccak-256 (not SHA3-256; the two differ in padding) |
| Encoding | 65 bytes: r ‖ s ‖ v |
v |
27 or 28, the Ethereum convention. An EVM ecrecover takes the bytes unchanged. |
r, s |
Not zero |
s |
Low-S only: s ≤ n/2. Decoders reject a high-S signature. |
| Signer address | The last 20 bytes of keccak256 of the uncompressed public key, without the 0x04 prefix. This is the Ethereum address derivation. |
The low-S rule removes signature malleability. The limit n/2 is:
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0The payload does not contain the signer. The consumer recovers the signer from the signature.
Domain separation
The digest is EIP-712-shaped:
DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId)")
DOMAIN_SEPARATOR = keccak256(DOMAIN_TYPEHASH
‖ keccak256("PriceOracle")
‖ keccak256("1")
‖ uint256(0)) // chainId 0: off-chain, chain-agnostic
digest = keccak256(0x19 ‖ 0x01 ‖ DOMAIN_SEPARATOR ‖ keccak256(payload_bytes))| Constant | Value |
|---|---|
DOMAIN_TYPEHASH |
0xc2f8787176b8ac6bf7215b4adcc1e069bf4ab82d9ab1df05a57a91d425935b6e |
DOMAIN_SEPARATOR |
0x13bb3c3ca2fe9d51da4217029742cf381b8759cc6d9730e6b60d3d5fabe0f149 |
- The digest is not EIP-712-compliant. It hashes the canonical payload bytes, not a
structHash. Walleteth_signTypedDatadoes not produce these digests. chainIdis 0 for off-chain use. An on-chain deployment uses the real chain id, and off-chain signatures do not verify there.- A change to the domain version invalidates every signature.
- One digest function serves every payload type. The
kindbyte, byte 1 of every payload, keeps the types apart.
Payload kinds
| Kind | Payload | Signed by | Size |
|---|---|---|---|
0x01 |
PriceUpdate |
node key | 96 bytes |
0x02 |
PriceBatch: price updates under one signature |
node key | 4 + 96 × n bytes |
0x03 |
VenueReport |
node key | 55 bytes, and 96 + L bytes for each entry with a symbol of L bytes |
0x04 |
BookUpdate |
node key | variable |
0x05 |
FundingUpdate |
node key | 104 + L bytes, for a symbol of L bytes |
0x81 |
Reserved. An earlier design used it. It is never used again. | — | — |
0x82 |
Registry |
governance | variable |
0x83 |
Client authentication | client API key | 34 bytes |
0x84 |
Node auth_ok |
node key | 34 bytes |
0x85 |
ClientList |
governance | variable |
0x86 |
FeedSet |
governance | variable |
Control-plane kinds have the high bit set, so a data payload never decodes as a control-plane payload. Venue reports, books and funding describes kinds 0x03, 0x04 and 0x05. Node WebSocket protocol describes kinds 0x83 and 0x84. The network feed set describes the feed set, kind 0x86.
The PriceUpdate v1 payload
The payload is exactly 96 bytes. All integers are big-endian. This byte layout is the contract. A JSON form of an update is only a view of these bytes.
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 1 | version |
u8 | Always 1. |
| 1 | 1 | kind |
u8 | Always 0x01. |
| 2 | 2 | flags |
u16 | Status bits. See Flags. |
| 4 | 32 | feed_id |
bytes32 | keccak256 of the canonical symbol. See Feed IDs. |
| 36 | 8 | price |
i64 | Price mantissa. The value is price × 10^expo. |
| 44 | 8 | conf |
u64 | Confidence half-width mantissa, at the same expo as price. |
| 52 | 4 | expo |
i32 | Decimal exponent for price and conf. |
| 56 | 8 | publish_time_ns |
u64 | When the node signed the update, unix nanoseconds, node clock. |
| 64 | 8 | observation_time_ns |
u64 | The receipt time of the newest observation in the aggregate, unix nanoseconds. |
| 72 | 8 | prev_publish_time_ns |
u64 | This node's previous publish_time_ns for this feed, or 0 if none. |
| 80 | 8 | seq |
u64 | Sequence number for each (node, feed) pair. It always increases and is never reused. |
| 88 | 4 | num_sources |
u32 | The number of venues that the node claims contributed. |
| 92 | 4 | source_bitmap |
u32 | One bit for each venue that the node claims contributed. See Source bitmap. |
Field notes:
- Staleness checks use
observation_time_ns. Replay checks usepublish_time_ns. - The node seeds
seqfrom its clock in milliseconds at startup. A restart thus continues above the previous run. num_sourcesandsource_bitmapare claims. The decoder does not require thatnum_sourcesequals the number of bits in the bitmap.- A
HALTEDupdate still carries the last good price.
Flags
| Bit | Mask | Name | Meaning |
|---|---|---|---|
| 0 | 0x0001 |
HALTED |
The feed is halted. The price is the last good value. A consumer MUST NOT use it for settlement or liquidation. |
| 1 | 0x0002 |
DEGRADED |
The feed is live, but its inputs are impaired. The node widened conf to show this. |
| 2 | 0x0004 |
SYNTHETIC_QUOTE |
At least one source was converted from a stablecoin quote through a peg feed. The price includes the uncertainty of the peg. |
| 3–15 | — | reserved | Must be 0. |
A decoder rejects an update with a reserved bit set. It does not mask the bit. A future flag can be a new warning, and an old client must not conclude that there is no warning.
Source bitmap
Bit i of source_bitmap stands for the venue with wire index i:
| Bit | Venue |
|---|---|
| 0 | Binance |
| 1 | Bybit |
| 2 | OKX |
| 3 | Coinbase |
| 4 | Kraken |
| 5 | Deribit |
| 6 | Hyperliquid |
| 7 | Bitfinex |
| 8 | MEXC |
| 9 | KuCoin |
| 10 | Bitstamp |
| 11 | Bitget |
| 12 | Gate |
| 13 | Lighter |
| 14 | dYdX |
| 15–31 | Not assigned. A decoder rejects them. |
The indexes are wire-stable. A new venue gets the next free index. An existing venue never changes its index. The bitmap has room for 32 venues.
The price frame
A node sends each signed update as a 182-byte frame:
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 1 | frame_type |
1 for a price update. |
| 1 | 20 | signer address | A routing hint. It is not authenticated. |
| 21 | 96 | payload | The signed bytes, as above. |
| 117 | 65 | signature | r ‖ s ‖ v |
Use the signer hint only to route work. Never compare it with the registry. Use the recovered address. On the node WebSocket, each frame arrives encrypted. See Data frames.
Verify an update
A consumer does these steps for each price frame. The Rust SDK does them in this order. See Rust SDK.
- Check that the frame is exactly 182 bytes and that byte 0 is
1. - Parse the signature. Reject it if
vis not 27 or 28, ifrorsis zero, or ifs > n/2. - Decode the payload. Reject a version other than
1, a kind other than0x01, a reserved flag bit, or an unassigned venue bit. - Compute the digest over the payload bytes that you received. Do not hash bytes that you rebuilt from decoded fields.
- Recover the signer address from the signature and the digest.
- Look up the address in a registry that you verified against your pinned governance address. See Registry.
- A retired address: reject the frame. Do not refresh the registry.
- An unknown address: refresh the registry once, then try again. Limit the refreshes to one for each
refresh_interval(default 10 s). Anyone who can inject a frame can cause a refresh.
- Reject the update if
observation_time_ns > publish_time_ns. - Reject the update if
publish_time_nsis more thanmax_skewin the future (default 500 ms). Also reject it if it is older thanmax_age: 2 s by default, or 25 s for a funding or open interest feed. - Reject the update if
seqis not greater than the last acceptedseqfor this (node, feed) pair. Then store the newseq.
Do the clock checks before step 9. A rejected frame must not raise the stored seq, or it can lock out the valid frame after it.
After these steps, the update is one that a listed node signed recently. The consumer then combines the latest update from each node: a quorum of distinct nodes and a median. See Aggregation.
Warning
Recovery always gives some address, also for a forged frame. Only the registry lookup tells a node from an impostor. A signed update also never expires by itself. A relay can hold back new updates and send an old one. The max_age and seq checks are the only defence against that replay.
Key all per-node state by address, not by registry index. The indexes change when the registry changes.
The price batch
A node signs each update on its own for its WebSocket subscribers. When it pushes to the registry canister, it signs its due updates as one batch, so the canister checks one signature for the push. See On-chain prices.
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 1 | version |
1 |
| 1 | 1 | kind |
0x02 |
| 2 | 2 | count |
The number of updates, 1 to 32. |
| 4 | 96 × n | updates | Each update's 96-byte PriceUpdate payload. A feed at most once. |
The frame is [0] frame_type = 5 ‖ [1..21] signer hint ‖ payload ‖ 65-byte signature. Verify it as a price frame, with one signature for the whole batch. Then check each update alone for steps 7 to 9: an update shares the seq of the node's single frames of its feed. The kind byte is inside the signed bytes, so a batch signature never passes as the signature of one of its updates.
Key management
A node has one secret seed. Two keys derive from it. Each key is HKDF-SHA256 of the seed under its own label. Neither key reveals the seed or the other key.
| Key | HKDF label | Use |
|---|---|---|
| secp256k1 | price-oracle/node/secp256k1/v1 |
Signs every price update, venue report and book update, and the node side of the transport handshake. |
| ed25519 | price-oracle/node/ed25519/v1 |
For venues that verify ed25519 signatures in a canister. oracle-node init prints its public key. |
The secp256k1 address is the node's id. The registry lists nodes by address. Consumers key all per-node state by it. There is no separate identity key.
Keys stay local and encrypted
oracle-node initandoracle-node rotatedraw the seed from the operating system's CSPRNG.- The key file is an
agefile, encrypted with a passphrase. The scrypt work factor is fixed at 2^18 (256 MiB, about one second). A key file can ask for at most 2^20. - Inside the encryption, the file holds the seed as
oracle-node-seed-v1:<hex>. Bare hex is an older secp256k1 key without an ed25519 key. Only a local test setup uses that form. - The passphrase comes from a
0600file or from a prompt at a terminal. It never comes from the config, an environment variable or a command-line argument. A node with an encrypted key and no passphrase source refuses to start. - The node decrypts the key into memory at startup and erases it on drop. Run the node with core dumps off and swap encrypted or off.
- There is no KMS and no HSM. A cloud key service is a third party in the signing path.
The seeded keys in the examples and test helpers come from small integers. Never use them for a real node.
No expiry
Keys do not expire. An operator rotates a key on suspicion of compromise, not on a schedule. Each rotation is a governance call. The design accepts these costs:
- A stolen key that nobody notices stays valid until governance replaces or removes the node.
- An attacker can collect keys from several nodes over time. Watch the divergence between nodes.
- An abandoned node stays listed until governance removes it.
Rotation and retired keys
A rotation replaces one address with a new one through replace_node(old, new) on the registry canister. The canister keeps each retired address with its added_at and removed_at, and never lists it again. A live frame verifies only against a listed address. A stored frame verifies against the listing that covered its publish time. See Registry.