This document provides technical documentation for Chronicle Protocol's Scribe oracle system.
Scribe is an efficient Schnorr multi-signature based oracle allowing a subset of feeds to multi-sign a (value, age)
tuple via a custom Schnorr scheme. The oracle advances to a new (value, age)
tuple - via the public callable poke()
function - if the given tuple is signed by exactly IScribe::bar()
many feeds.
The Scribe contract also allows the creation of an optimistic-flavored oracle instance with onchain fault resolution called ScribeOptimistic.
Scribe implements Chronicle Protocol's IChronicle
interface for reading the oracle's value.
To protect authorized functions, Scribe uses chronicle-std
's Auth
module. Functions to read the oracle's value are protected via chronicle-std
's Toll
module.
Scribe uses a custom Schnorr signature scheme. The scheme is specified in docs/Schnorr.md.
The verification logic is implemented in LibSchnorr.sol
. A Solidity library to (multi-) sign data is provided via script/libs/LibSchnorrExtended.sol
.
Scribe needs to perform elliptic curve computations on the secp256k1 curve to verify aggregated/multi signatures.
The LibSecp256k1.sol
library provides the necessary addition and point conversion (Affine coordinates <-> Jacobian coordinates) functions.
In order to save computation-heavy conversions from Jacobian coordinates - which are used for point addition - back to Affine coordinates - which are used to store public keys -, LibSecp256k1
uses an addition formula expecting one point's z
coordinate to be 1. Effectively allowing to add a point in Affine coordinates to a point in Jacobian coordinates.
This optimization allows Scribe to aggregate public keys, i.e. compute the sum of secp256k1 points, in an efficient manner by only having to convert the end result from Jacobian coordinates to Affine coordinates.
For more info, see LibSecp256k1::addAffinePoint()
.
The poke()
function has to receive the set of feeds, i.e. public keys, that participated in the Schnorr multi-signature.
To reduce the calldata load, Scribe does not use type address
, which uses 20 bytes per feed, but encodes the feeds' identifier's byte-wise into a bytes
type called feedIds
.
A feed's identifier is defined as the highest order byte of the feed's address and can be computed via uint8(uint(uint160(feedAddress)) >> 152)
.
Feeds must prove the integrity of their public key by proving the ownership of the corresponding private key. The lift()
function therefore expects an ECDSA signed message, for more info see IScribe.feedRegistrationMessage()
.
Warning
The proof of possession implemented in Scribe is insufficient to defend against rogue-key attacks. In order to sufficiently verify a public key the message being signed MUST be derived from the public key itself.
In order to keep Scribe backwards compatible the extended proof of possession is implemented in the external ValidatorRegistry contract.
For more info, see audits/Cantina@v2.0.0_2.pdf.
If public key's would not be verified, the Schnorr signature verification would be vulnerable to rogue-key attacks. For more info, see docs/Schnorr.md
.
Scribe aims to be partially Chainlink compatible by implementing the most widely, and not deprecated, used functions of the IChainlinkAggregatorV3
interface.
The following IChainlinkAggregatorV3
functions are provided:
latestRoundData()
decimals()
latestAnswer()
ScribeOptimistic is a contract inheriting from Scribe and providing an optimistic-flavored Scribe version. This version is intended to only be used on Layer 1s with expensive computation.
To circumvent verifying Schnorr signatures onchain, ScribeOptimistic
provides an additional opPoke()
function. This function expects the (value, age)
tuple and corresponding Schnorr signature to be signed via ECDSA by a single feed.
The opPoke()
function binds the feed to the data they signed. A public callable opChallenge()
function can be called at any time. The function verifies the current optimistically poked data and, if the Schnorr signature verification succeeds, finalizes the data. However, if the Schnorr signature verification fails, the feed bound to the data is automatically diss
'ed, i.e. removed from the whitelist, and the data deleted.
If an opPoke()
is not challenged, its value finalizes after a specified period. For more info, see IScribeOptimistic::opChallengePeriod()
.
Monitoring optimistic pokes and, if necessary, challenging them can be incentivized via ETH rewards. For more info, see IScribeOptimistic::maxChallengeReward()
.
For all functions being executed during opChallenge()
, it is of utmost importance to have bounded gas usage. These functions are marked with @custom:invariant
specifications documenting their gas usage.
The gas usage must be bounded to ensure an invalid opPoke()
can always be successfully challenged.
Two loops are executed during an opChallenge()
:
- Inside
Scribe::_verifySchnorrSignature
- bounded bybar
- Inside
LibSecp256k1::_invMod
- computing the modular inverse of a Jacobianz
coordinate of a secp256k1 point
- Listen to
opPoked
events:
event OpPoked(
address indexed caller,
address indexed opFeed,
IScribe.SchnorrData schnorrData,
IScribe.PokeData pokeData
);
- Construct message from
pokeData
:
function constructPokeMessage(PokeData calldata pokeData)
external
view
returns (bytes32);
- Verify Schnorr signature is acceptable:
function isAcceptableSchnorrSignatureNow(
bytes32 message,
SchnorrData calldata schnorrData
) external view returns (bool ok);
- If Schnorr signature is not acceptable:
function opChallenge(SchnorrData calldata schnorrData)
external
returns (bool ok);
- ETH Challenge reward can be checked beforehand:
function challengeReward() external view returns (uint challengeReward);
Benchmarks can be found in ./Benchmarks.md
.