Esc

    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:

    text
    0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0

    The 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:

    • ecrecover is 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:

    text
    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. Wallet eth_signTypedData does not produce these digests.
    • chainId is 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 kind byte, 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_ns tells how fresh the market data is. publish_time_ns tells when the node signed. Staleness checks use the first. Replay checks use the second.
    • The node seeds seq from its clock in milliseconds at startup. A restart thus continues above the previous run, not at zero.
    • num_sources and source_bitmap are claims. Nothing binds them to what the node really received. The decoder does not require that num_sources equals the number of bits in the bitmap.
    • A HALTED update 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.

    1. Check that the frame is exactly 182 bytes and that byte 0 is 1.
    2. Parse the signature. Reject it if v is not 27 or 28, if r or s is zero, or if s > n/2.
    3. Decode the payload. Reject a version other than 1, a kind other than 0x01, a reserved flag bit, or an unassigned venue bit.
    4. Compute the digest over the payload bytes that you received. Do not hash bytes that you rebuilt from decoded fields.
    5. Recover the signer address from the signature and the digest.
    6. 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.
    7. Reject the update if observation_time_ns > publish_time_ns.
    8. Reject the update if publish_time_ns is more than max_skew in the future (default 500 ms). Also reject it if it is older than max_age: 2 s by default, or 25 s for a funding or open interest feed.
    9. Reject the update if seq is not greater than the last accepted seq for this (node, feed) pair. Then store the new seq.

    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 init and oracle-node rotate draw the seed from the operating system's CSPRNG.
    • The key file is an age file, 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 init sets 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:
    text
    added_at ≤ publish_time_ns < removed_at

    Without removed_at, a stolen key could sign backdated frames that verify forever. The times are the canister's word. No signature covers them.