Skip to main content

molpha_verifier/
verify.rs

1//! High-level DataUpdate verification over caller-supplied signer pubkeys.
2//!
3//! These functions are pure: the caller resolves the signer pubkeys (e.g. from an on-chain
4//! registry, an off-chain snapshot, or hard-coded constants) and passes them in. No anchor,
5//! no `AccountInfo`, no PDA reads.
6
7use solana_secp256k1_recover::secp256k1_recover;
8
9use crate::bitmap::{bitmap_is_subset_u256, bitmap_load};
10use crate::coalition::CoalitionAccumulator;
11use crate::error::DataUpdateError;
12use crate::message::compute_message_hash;
13use crate::payload::DataUpdate;
14use crate::scalar::{
15    eth_address_from_uncompressed_pubkey, evm_schnorr_ecdsa_inputs,
16    secp256k1_scalar_is_valid_nonzero,
17};
18use crate::selection::derive_selection_bitmap;
19
20/// Stored secp256k1 affine coordinates `(x, y)`, big-endian — as kept in a `Node`.
21pub type SignerXy = ([u8; 32], [u8; 32]);
22
23/// Verify a full `DataUpdate` payload against caller-supplied signer pubkeys.
24///
25/// # Caller contract
26/// - `node_count` is the registry node count for `payload.registry_version`.
27/// - `signatures_required` is the threshold to verify against (passed explicitly because callers
28///   may use a value distinct from `payload.signatures_required`, e.g. the job's configured value).
29/// - `ordered_signers` holds one `(x, y)` per set bit of `payload.signers_bitmap`, in **ascending
30///   bit-index order** — the same order EVM `Validator.verify` combines pubkeys. The caller is
31///   responsible for resolving the authentic pubkeys; this function trusts the supplied set.
32///
33/// Re-derives the selection bitmap internally and enforces `signers ⊆ selection`. Checks run in the
34/// same order as the on-chain monolith: scalar validity → signer threshold → selection subset →
35/// signer-count match → coalition reconstruction → message hash → Schnorr recovery.
36pub fn verify_data_update(
37    payload: &DataUpdate,
38    node_count: u32,
39    redundancy_buffer: u8,
40    ordered_signers: &[SignerXy],
41) -> Result<(), DataUpdateError> {
42    if payload.agg_sig_s == [0u8; 32] || !secp256k1_scalar_is_valid_nonzero(&payload.agg_sig_s) {
43        return Err(DataUpdateError::InvalidAggregateSignature);
44    }
45
46    let signers = bitmap_load(&payload.signers_bitmap);
47    let signer_count = signers.count_ones();
48    if signer_count < u32::from(payload.signatures_required) {
49        return Err(DataUpdateError::InsufficientSigners);
50    }
51
52    let expected_selection = derive_selection_bitmap(
53        &payload.feed_id,
54        payload.registry_version,
55        payload.canonical_timestamp,
56        node_count,
57        payload.signatures_required,
58        redundancy_buffer,
59    )?;
60    if !bitmap_is_subset_u256(signers, bitmap_load(&expected_selection)) {
61        return Err(DataUpdateError::SignersNotSubsetOfSelection);
62    }
63
64    if ordered_signers.len() != signer_count as usize {
65        return Err(DataUpdateError::SignerCountMismatch);
66    }
67
68    let x_coalition = reconstruct_coalition_key(ordered_signers)?;
69    let message_hash = compute_message_hash(payload, payload.signatures_required);
70
71    if recover_and_match(
72        &x_coalition,
73        &message_hash,
74        &payload.agg_sig_s,
75        &payload.commitment_addr,
76    ) {
77        Ok(())
78    } else {
79        Err(DataUpdateError::InvalidAggregateSignature)
80    }
81}
82
83/// Like [`verify_data_update`] but taking compressed (33-byte) signer pubkeys.
84pub fn verify_data_update_compressed(
85    payload: &DataUpdate,
86    node_count: u32,
87    redundancy_buffer: u8,
88    ordered_signers_compressed: &[[u8; 33]],
89) -> Result<(), DataUpdateError> {
90    let xy = decompress_all(ordered_signers_compressed)?;
91    verify_data_update(payload, node_count, redundancy_buffer, &xy)
92}
93
94/// Reconstruct the coalition key `Σ X_i` from ordered signer pubkeys → compressed (33 bytes).
95///
96/// Errors on an empty signer set or a point-at-infinity sum.
97pub fn reconstruct_coalition_key(
98    ordered_signers: &[SignerXy],
99) -> Result<[u8; 33], DataUpdateError> {
100    if ordered_signers.is_empty() {
101        return Err(DataUpdateError::InvalidSignersBitmap);
102    }
103    let mut coalition = CoalitionAccumulator::default();
104    for (x, y) in ordered_signers {
105        coalition.add_stored_xy(x, y)?;
106    }
107    coalition.compressed_pubkey()
108}
109
110/// Compressed-pubkey variant of [`reconstruct_coalition_key`].
111pub fn reconstruct_coalition_key_compressed(
112    ordered_signers_compressed: &[[u8; 33]],
113) -> Result<[u8; 33], DataUpdateError> {
114    let xy = decompress_all(ordered_signers_compressed)?;
115    reconstruct_coalition_key(&xy)
116}
117
118/// Verify the aggregate Schnorr signature over an arbitrary `message_hash` against the coalition
119/// formed by `ordered_signers`.
120///
121/// Returns `Ok(true)` when valid (no fraud), `Ok(false)` when invalid (fabricated / committed
122/// garbage → slashable). `Err` only on malformed input (empty signer set, bad curve point). This
123/// mirrors the dispute-path semantics in the Molpha program.
124pub fn verify_aggregate_over_hash(
125    ordered_signers: &[SignerXy],
126    agg_sig_s: &[u8; 32],
127    commitment_addr: &[u8; 20],
128    message_hash: &[u8; 32],
129) -> Result<bool, DataUpdateError> {
130    if !secp256k1_scalar_is_valid_nonzero(agg_sig_s) {
131        return Ok(false);
132    }
133    let x_coalition = reconstruct_coalition_key(ordered_signers)?;
134    Ok(recover_and_match(
135        &x_coalition,
136        message_hash,
137        agg_sig_s,
138        commitment_addr,
139    ))
140}
141
142/// Run the Schnorr→ECDSA recovery trick and compare the recovered address to `commitment_addr`.
143fn recover_and_match(
144    x_coalition: &[u8; 33],
145    message_hash: &[u8; 32],
146    agg_sig_s: &[u8; 32],
147    commitment_addr: &[u8; 20],
148) -> bool {
149    let (recovery_id, ecdsa_signature, ecdsa_hash) =
150        match evm_schnorr_ecdsa_inputs(x_coalition, message_hash, agg_sig_s, commitment_addr) {
151            Ok(v) => v,
152            Err(_) => return false,
153        };
154    let recovered = match secp256k1_recover(&ecdsa_hash, recovery_id, &ecdsa_signature) {
155        Ok(r) => r,
156        Err(_) => return false,
157    };
158    eth_address_from_uncompressed_pubkey(recovered.to_bytes()) == *commitment_addr
159}
160
161fn decompress_all(compressed: &[[u8; 33]]) -> Result<Vec<SignerXy>, DataUpdateError> {
162    use libsecp256k1::{PublicKey, PublicKeyFormat};
163    compressed
164        .iter()
165        .map(|c| {
166            let pk = PublicKey::parse_slice(c, Some(PublicKeyFormat::Compressed))
167                .map_err(|_| DataUpdateError::InvalidAggregateSignature)?;
168            let full = pk.serialize(); // 0x04 || x || y
169            let x: [u8; 32] = full[1..33].try_into().unwrap();
170            let y: [u8; 32] = full[33..65].try_into().unwrap();
171            Ok((x, y))
172        })
173        .collect()
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::message::MESSAGE_PREFIX;
180    use libsecp256k1::{PublicKey, PublicKeyFormat};
181
182    // ----------------------------------------------------------------------------------------
183    // Test vectors decoded from `tests/fixtures-json/verify-answer-evm.json`. End-to-end
184    // EVM-compatibility regression for the full Schnorr-recovery verification path.
185    // ----------------------------------------------------------------------------------------
186
187    /// "solana-compat-job" right-padded to 32 bytes.
188    const FIXTURE_FEED_ID: [u8; 32] = [
189        0x73, 0x6f, 0x6c, 0x61, 0x6e, 0x61, 0x2d, 0x63, 0x6f, 0x6d, 0x70, 0x61, 0x74, 0x2d, 0x6a,
190        0x6f, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
191        0x00, 0x00,
192    ];
193
194    /// "solana-compat-val" right-padded to 32 bytes.
195    const FIXTURE_VALUE: [u8; 32] = [
196        0x73, 0x6f, 0x6c, 0x61, 0x6e, 0x61, 0x2d, 0x63, 0x6f, 0x6d, 0x70, 0x61, 0x74, 0x2d, 0x76,
197        0x61, 0x6c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
198        0x00, 0x00,
199    ];
200
201    /// EVM `uint256(255)` big-endian — bits 0–7 set (8 signers).
202    const FIXTURE_SIGNERS_BITMAP: [u8; 32] = [
203        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
204        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
205        0x00, 0xff,
206    ];
207
208    const FIXTURE_REGISTRY_VERSION: u32 = 1;
209    const FIXTURE_SIGNATURES_REQUIRED: u8 = 8;
210    const FIXTURE_CANONICAL_TIMESTAMP: i64 = 1_700_000_123;
211
212    /// `schnorrSignature.signature` — the Schnorr scalar `s`.
213    const FIXTURE_S: [u8; 32] = [
214        0xc7, 0xe0, 0x99, 0x60, 0x3c, 0xee, 0xd2, 0xa1, 0x13, 0xd7, 0x5a, 0x9d, 0x95, 0xe2, 0x0f,
215        0x92, 0x00, 0x6b, 0x06, 0xc5, 0x49, 0x7a, 0xdd, 0x09, 0x81, 0x7d, 0xa8, 0x90, 0x8d, 0x39,
216        0x0d, 0xa5,
217    ];
218
219    /// `schnorrSignature.commitment` — Ethereum address (20 bytes).
220    const FIXTURE_COMMITMENT: [u8; 20] = [
221        0xc6, 0xb9, 0x4f, 0xea, 0x5d, 0xd5, 0xf9, 0x65, 0xd8, 0x67, 0x14, 0xb1, 0xd9, 0x9d, 0xcf,
222        0xaf, 0x1e, 0x72, 0xee, 0x35,
223    ];
224
225    /// Compressed secp256k1 pubkeys for nodes at bit positions 0–7 (signersBitmap = 255).
226    const FIXTURE_PUBKEYS: [[u8; 33]; 8] = [
227        [
228            0x03, 0xc0, 0x95, 0x27, 0xe9, 0x78, 0xf6, 0xea, 0x69, 0xf0, 0xc6, 0xb7, 0xac, 0x0f,
229            0xb6, 0x3a, 0xd0, 0x81, 0xa8, 0xa2, 0x91, 0x15, 0x1c, 0x5a, 0x0b, 0x11, 0x5c, 0xce,
230            0x43, 0x57, 0x51, 0xbe, 0x7d,
231        ],
232        [
233            0x02, 0x64, 0xa7, 0x27, 0x04, 0xf3, 0x9f, 0x8d, 0xd1, 0x7f, 0x20, 0xd7, 0x1c, 0x5b,
234            0x21, 0xf3, 0x7b, 0x58, 0x52, 0x65, 0x6b, 0xc0, 0x55, 0x54, 0x42, 0xbf, 0x72, 0x72,
235            0x22, 0xf2, 0x9d, 0x7e, 0x58,
236        ],
237        [
238            0x02, 0x75, 0xae, 0x1e, 0x3d, 0xac, 0x00, 0xeb, 0x7d, 0xf0, 0x2e, 0x9f, 0xe8, 0xd9,
239            0x70, 0x9c, 0x8a, 0x2c, 0x09, 0xa1, 0x1e, 0xd4, 0xf7, 0xd9, 0xaa, 0x46, 0xa7, 0xde,
240            0xa6, 0xcf, 0x37, 0x6d, 0x7f,
241        ],
242        [
243            0x02, 0x6c, 0xe2, 0x5b, 0x3a, 0x16, 0x1a, 0xb8, 0xe0, 0xf0, 0x5e, 0x4c, 0xd1, 0xc7,
244            0x7b, 0x77, 0x69, 0x6d, 0x26, 0xc6, 0x41, 0xeb, 0xde, 0xa4, 0xe8, 0x1a, 0xa8, 0x9a,
245            0x90, 0xf3, 0x2c, 0xfc, 0x54,
246        ],
247        [
248            0x03, 0x5b, 0x95, 0xd7, 0x03, 0x22, 0x8b, 0xef, 0xcc, 0xc3, 0x78, 0x62, 0x9d, 0xc1,
249            0x98, 0x04, 0xce, 0xfe, 0x56, 0xc3, 0x3c, 0x64, 0x5f, 0xa4, 0xbc, 0x1a, 0xa0, 0xf3,
250            0x75, 0xe3, 0xb4, 0xfa, 0x5e,
251        ],
252        [
253            0x03, 0x99, 0x5e, 0x4b, 0xe0, 0xec, 0xd4, 0x22, 0xbf, 0x25, 0x0a, 0x3d, 0xa3, 0xa0,
254            0xb8, 0x34, 0x2e, 0x52, 0x89, 0x3a, 0x3e, 0x06, 0x4f, 0xa6, 0x35, 0x55, 0x73, 0x78,
255            0xb5, 0x9a, 0xfa, 0x8b, 0x50,
256        ],
257        [
258            0x03, 0xec, 0x90, 0x6d, 0x0a, 0x1c, 0xfc, 0x3c, 0x7d, 0xec, 0x18, 0x08, 0x8c, 0x3d,
259            0x14, 0x4f, 0x32, 0x15, 0x80, 0xec, 0xe0, 0xa6, 0xba, 0xe5, 0xce, 0xb2, 0x8d, 0xcf,
260            0x8d, 0xc6, 0xe3, 0xda, 0x03,
261        ],
262        [
263            0x03, 0x27, 0x5f, 0xcf, 0x98, 0x38, 0xb4, 0x7a, 0xac, 0xff, 0x25, 0x1f, 0x4f, 0x09,
264            0x9f, 0x80, 0xc6, 0x4a, 0x1a, 0x9a, 0xed, 0xbd, 0xb6, 0x28, 0xc2, 0xc8, 0x7f, 0x2c,
265            0x5e, 0x12, 0x3d, 0xd0, 0x40,
266        ],
267    ];
268
269    fn fixture_payload() -> DataUpdate {
270        DataUpdate {
271            feed_id: FIXTURE_FEED_ID,
272            registry_version: FIXTURE_REGISTRY_VERSION,
273            value: FIXTURE_VALUE,
274            canonical_timestamp: FIXTURE_CANONICAL_TIMESTAMP,
275            signatures_required: FIXTURE_SIGNATURES_REQUIRED,
276            agg_sig_s: FIXTURE_S,
277            commitment_addr: FIXTURE_COMMITMENT,
278            signers_bitmap: FIXTURE_SIGNERS_BITMAP,
279        }
280    }
281
282    fn fixture_signers_xy() -> Vec<SignerXy> {
283        FIXTURE_PUBKEYS
284            .iter()
285            .map(|c| {
286                let pk = PublicKey::parse_slice(c, Some(PublicKeyFormat::Compressed))
287                    .expect("fixture pubkey must be a valid curve point");
288                let full = pk.serialize();
289                let x: [u8; 32] = full[1..33].try_into().unwrap();
290                let y: [u8; 32] = full[33..65].try_into().unwrap();
291                (x, y)
292            })
293            .collect()
294    }
295
296    #[test]
297    fn fixture_pubkeys_are_valid_curve_points() {
298        for (i, pk) in FIXTURE_PUBKEYS.iter().enumerate() {
299            PublicKey::parse_slice(pk, Some(PublicKeyFormat::Compressed))
300                .unwrap_or_else(|_| panic!("fixture pubkey {i} is not a valid curve point"));
301        }
302    }
303
304    #[test]
305    fn fixture_signers_bitmap_popcount_is_8() {
306        use crate::bitmap::bitmap_popcount_evm;
307        assert_eq!(
308            bitmap_popcount_evm(&FIXTURE_SIGNERS_BITMAP),
309            FIXTURE_SIGNATURES_REQUIRED as u32
310        );
311    }
312
313    /// The coalition-from-pubkeys path must match `PublicKey::combine`.
314    #[test]
315    fn reconstruct_coalition_key_matches_combine() {
316        let pks: Vec<PublicKey> = FIXTURE_PUBKEYS
317            .iter()
318            .map(|c| PublicKey::parse_slice(c, Some(PublicKeyFormat::Compressed)).unwrap())
319            .collect();
320        let combined = PublicKey::combine(&pks).unwrap().serialize_compressed();
321        let got = reconstruct_coalition_key(&fixture_signers_xy()).unwrap();
322        assert_eq!(got, combined);
323        let got_c = reconstruct_coalition_key_compressed(&FIXTURE_PUBKEYS).unwrap();
324        assert_eq!(got_c, combined);
325    }
326
327    /// Full end-to-end EVM-compat verification with caller-supplied pubkeys — no anchor, no PDAs.
328    #[test]
329    fn verify_data_update_accepts_evm_fixture() {
330        let payload = fixture_payload();
331        // node_count == signatures_required == 8 → selection is the full set, signers ⊆ selection.
332        verify_data_update(&payload, 8, 0, &fixture_signers_xy())
333            .expect("fixture DataUpdate must verify");
334        verify_data_update_compressed(&payload, 8, 0, &FIXTURE_PUBKEYS)
335            .expect("compressed variant must verify");
336    }
337
338    #[test]
339    fn tampered_s_fails_verification() {
340        let mut payload = fixture_payload();
341        payload.agg_sig_s[31] ^= 0x01;
342        let res = verify_data_update(&payload, 8, 0, &fixture_signers_xy());
343        assert_eq!(res, Err(DataUpdateError::InvalidAggregateSignature));
344    }
345
346    #[test]
347    fn wrong_signer_count_is_rejected() {
348        let payload = fixture_payload();
349        let mut signers = fixture_signers_xy();
350        signers.pop();
351        assert_eq!(
352            verify_data_update(&payload, 8, 0, &signers),
353            Err(DataUpdateError::SignerCountMismatch)
354        );
355    }
356
357    #[test]
358    fn verify_aggregate_over_hash_roundtrip() {
359        let payload = fixture_payload();
360        let signers = fixture_signers_xy();
361        let message_hash = compute_message_hash(&payload, payload.signatures_required);
362        assert!(verify_aggregate_over_hash(
363            &signers,
364            &payload.agg_sig_s,
365            &payload.commitment_addr,
366            &message_hash,
367        )
368        .unwrap());
369
370        // Tampered hash → invalid (slashable), not an error.
371        let mut bad_hash = message_hash;
372        bad_hash[0] ^= 0xff;
373        assert!(!verify_aggregate_over_hash(
374            &signers,
375            &payload.agg_sig_s,
376            &payload.commitment_addr,
377            &bad_hash,
378        )
379        .unwrap());
380    }
381
382    #[test]
383    fn message_prefix_matches_known_constant() {
384        // Guard against accidental edits to the domain-separation prefix.
385        assert_eq!(MESSAGE_PREFIX[0], 0xa7);
386    }
387}