Protocol
Signing and the price payload
How a node signs each update, the 96-byte PriceUpdate layout, the flags and source bitmap, and how a consumer verifies 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 defence against a bad node is the median across independent nodes, not the signature. 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. Without it, a relay could make a second valid byte string for the same update. The limit n/2 is:
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0The payload does not contain the signer. The consumer recovers the signer from the signature. Thus a client cannot attribute an update to the wrong node, and the payload spends no bytes on a key.
Why secp256k1 and not ed25519:
ecrecoveris a 3,000-gas EVM precompile. Ed25519 verification in Solidity costs about 500k gas.- A recoverable signature carries the signer identity.
- The libraries are mature and constant-time. Native builds use libsecp256k1. The wasm32 build uses
k256. The golden vectors run under both backends.
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 EIP-712-shaped, but it is not EIP-712-compliant. It hashes the canonical payload bytes directly, not a
structHash. Walleteth_signTypedDatadoes not produce these digests. chainIdis 0 for off-chain use. A future on-chain deployment uses the real chain id. Off-chain signatures then do not verify on chain.- 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 |
Reserved for batched price updates | — | — |
0x03 |
VenueReport |
node key | 55 + 95 × n bytes |
0x04 |
BookUpdate |
node key | variable |
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 |
Control-plane kinds have the high bit set. A data payload can never decode as a control-plane payload. The 34-byte authentication payloads cannot decode as a 96-byte PriceUpdate. Venue reports and book streams describes kinds 0x03 and 0x04. Node WebSocket protocol describes kinds 0x83 and 0x84.
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:
observation_time_nstells how fresh the market data is.publish_time_nstells when the node signed. Staleness checks use the first. Replay checks use the second.- The node seeds
seqfrom its clock in milliseconds at startup. A restart thus continues above the previous run, not at zero. num_sourcesandsource_bitmapare claims. Nothing binds them to what the node really received. The decoder does not require thatnum_sourcesequals the number of bits in the bitmap.- A
HALTEDupdate still carries the last good price, so that a consumer can show something.
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. An old client that cannot read a new warning 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–31 | Not assigned. A decoder rejects them. |
The indexes are fixed in the code and are wire-stable. A new venue gets the next free index. An existing venue never changes its index, because that would change the meaning of every old signature. 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, and every signature check still passes. 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.
Batching is reserved
The node signs each update on its own. A secp256k1 signature takes about 40 µs. Thus 2,000 updates each second use about 8% of one core.
Merkle-batched signing signs one root for many updates and gives each update an inclusion proof. It is better only above about 10,000 updates each second, or when calldata costs money on chain. Payload kind 0x02 is reserved for it. A later protocol version can add it without a change to the v1 formats.
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), so a key opens the same way on every machine. 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 file that only the operator can read (
0600), 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. The memory is not locked. Run the node with core dumps off and swap encrypted or off. The systemd unit from
initsets both. See Run under systemd. - There is no KMS and no HSM, by design. A cloud key service is a third party in the signing path. Operator diversity is the purpose of the design.
The seeded keys in the examples and test helpers come from small integers. Anyone can guess them. 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 median across nodes is the defence against one bad key. 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. Governance calls replace_node(old, new) on the registry canister. See Rotate a node key.
The canister keeps retired addresses. It records when each address was listed and when it was replaced or removed. An address is never listed again, as a node or as a client.
- A live frame verifies only against a listed address.
- A stored frame, for an audit or a dispute, verifies against the listing that covered its publish time:
added_at ≤ publish_time_ns < removed_atWithout removed_at, a stolen key could sign backdated frames that verify forever. The times are the canister's word. No signature covers them.