← back to lessons

Lesson: Oracle PROVIDER + Schnorr-aggregate-signature audit lens

Banked: Cycle 222 (2026-06-10), Chronicle Scribe v2.0.1 (Cantina $400K), WALK CLEAN. Target class: EVM oracle PROVIDER using ecrecover-based Schnorr multi-sig aggregation. This is the COMPLEMENT to our usual CONSUMER-side lens (CAPO price-caps). Reusable for any Chronicle-style / Schnorr-aggregate / optimistic-oracle provider.

Why this lens is different from the consumer lens

The 6-point provider/aggregate-signature checklist (apply in order)

  1. What is bound into the SIGNED message? Grep the message constructor. - Scribe: H(wat, val, age). Bound: feed-tag + value + timestamp. NOT bound: the signer set. - Ask: can a valid sig be replayed for a different (val, age, chain, contract)? If wat/chainid/ contract-addr is NOT in the message, suspect cross-instance or cross-chain replay. (Scribe binds wat per-instance -> per-feed isolation; no chainid but each deploy is a distinct contract with distinct wat, and age-monotonicity blocks same-contract replay.) - TEST: sign for (val,age1), assert verify==false for (val,age2) and (val2,age1).

  2. How is the aggregate public key derived? Is the signer SET signed, or RE-DERIVED by the verifier from caller-supplied ids? - Scribe: verifier recomputes aggPubKey = sum(pubKeys[feedId]) from the supplied feedIds. A wrong set just fails verification. Substituting a set that sums to the same key = rogue-key / linear-private-key-relationship = the FENCED trust assumption (mitigated by proof-of-possession ECDSA at lift). Confirm the lift-time PoP exists; if it does, rogue-key is OOS.

  3. Double-sign / threshold-fake guard. Can one signer be listed bar times to fake the threshold? Look for a bloom filter / sorted-strictly-increasing-id check. - Scribe: bloom bitset, first id seeds it, loop rejects repeats; requires len == bar exactly. - TEST: list the same id twice, assert verify==false.

  4. Signature malleability / degenerate inputs. Check the verify primitive rejects: s == 0, commitment/R == address(0), s >= Q (low-s / order bound), off-curve pubkey, zero challenge. For the ecrecover-as-ecmul trick (Vitalik's), r = Px must be a valid nonzero field element < Q. Scribe guards all of these (LibSchnorr 28-50). On a multi-audit codebase these are present -> 0 signal; do NOT re-grind without a NEW angle.

  5. Optimistic challenge SYMMETRY (the single most informative test). For opPoke/opChallenge: - VALID opPoke must NOT be challengeable (no griefing the honest poker; feed stays, no reward). - INVALID opPoke MUST be challengeable (feed dropped + reward paid). Both directions must hold. The challenge must re-verify the EXACT committed schnorrData (_schnorrDataCommitment = keccak(sig,commitment,feedIds)) reconstructing the ORIGINAL signed message (Scribe stores _originalOpPokeDataAge to rebuild H(wat,val,originalAge)). Watch for: calldata-encoding ambiguity letting a valid opPoke be challenged with a re-encoded schnorrData (Scribe FIXED this in v2.0.0 - "non-default calldata encoding" bypass). On a fresh fork, re-test this with a manually re-encoded bytes feedIds. - TEST: both H2a (valid not challengeable) and H2b (invalid challengeable).

  6. Finalization read-gate + staleness. Confirm the read path serves the PRIOR finalized value during the challenge window (a bad unchallenged opPoke only becomes readable after the period), and that age is strictly monotonic (no rewind) and <= block.timestamp (no future).

Negative-result rule (banked)

On a target shipping >=3 audits (Scribe ships ABDK + Cantina x2 + ChainSecurity), grinding crypto- primitive edge cases (point-at-infinity, zero challenge, malleability bound) yields ~0 signal - the guards are present. Spend budget on: (a) calldata-encoding ambiguity in the challenge path, (b) cross-instance/cross-chain message replay if chainid/contract not bound, (c) economic griefing of the challenge-reward mechanism. Frame as LEARN-MODE; walk-with-lessons is the good outcome.

Reusable foundry harness notes

Generated 2026-07-02 13:15:05 UTC | auto-sync /15min