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/// - `raw_value` is the raw feed value carried alongside the payload; it is hashed into the
27///   EVM-compatible message (`keccak256(raw_value)` + length). A wrong raw value fails signature
28///   verification.
29/// - `node_count` is the registry node count for `payload.registry_version`.
30/// - `ordered_signers` holds one `(x, y)` per set bit of `payload.signers_bitmap`, in **ascending
31///   bit-index order** — the same order EVM `Validator.verify` combines pubkeys. The caller is
32///   responsible for resolving the authentic pubkeys; this function trusts the supplied set.
33///
34/// Re-derives the selection bitmap internally and enforces `signers ⊆ selection`. Checks run in the
35/// same order as the on-chain monolith: scalar validity → signer threshold → selection subset →
36/// signer-count match → coalition reconstruction → message hash → Schnorr recovery.
37pub fn verify_data_update(
38    payload: &DataUpdate,
39    raw_value: &[u8],
40    node_count: u32,
41    redundancy_buffer: u8,
42    ordered_signers: &[SignerXy],
43) -> Result<(), DataUpdateError> {
44    if payload.agg_sig_s == [0u8; 32] || !secp256k1_scalar_is_valid_nonzero(&payload.agg_sig_s) {
45        return Err(DataUpdateError::InvalidAggregateSignature);
46    }
47
48    let signers = bitmap_load(&payload.signers_bitmap);
49    let signer_count = signers.count_ones();
50    if signer_count < u32::from(payload.signatures_required) {
51        return Err(DataUpdateError::InsufficientSigners);
52    }
53
54    let expected_selection = derive_selection_bitmap(
55        &payload.feed_id,
56        payload.registry_version,
57        payload.canonical_timestamp,
58        node_count,
59        payload.signatures_required,
60        redundancy_buffer,
61    )?;
62    if !bitmap_is_subset_u256(signers, bitmap_load(&expected_selection)) {
63        return Err(DataUpdateError::SignersNotSubsetOfSelection);
64    }
65
66    if ordered_signers.len() != signer_count as usize {
67        return Err(DataUpdateError::SignerCountMismatch);
68    }
69
70    let x_coalition = reconstruct_coalition_key(ordered_signers)?;
71    let message_hash = compute_message_hash(payload, raw_value);
72
73    if recover_and_match(
74        &x_coalition,
75        &message_hash,
76        &payload.agg_sig_s,
77        &payload.commitment_addr,
78    ) {
79        Ok(())
80    } else {
81        Err(DataUpdateError::InvalidAggregateSignature)
82    }
83}
84
85/// Like [`verify_data_update`] but taking compressed (33-byte) signer pubkeys.
86pub fn verify_data_update_compressed(
87    payload: &DataUpdate,
88    raw_value: &[u8],
89    node_count: u32,
90    redundancy_buffer: u8,
91    ordered_signers_compressed: &[[u8; 33]],
92) -> Result<(), DataUpdateError> {
93    let xy = decompress_all(ordered_signers_compressed)?;
94    verify_data_update(payload, raw_value, node_count, redundancy_buffer, &xy)
95}
96
97/// Reconstruct the coalition key `Σ X_i` from ordered signer pubkeys → compressed (33 bytes).
98///
99/// Errors on an empty signer set or a point-at-infinity sum.
100pub fn reconstruct_coalition_key(
101    ordered_signers: &[SignerXy],
102) -> Result<[u8; 33], DataUpdateError> {
103    if ordered_signers.is_empty() {
104        return Err(DataUpdateError::InvalidSignersBitmap);
105    }
106    let mut coalition = CoalitionAccumulator::default();
107    for (x, y) in ordered_signers {
108        coalition.add_stored_xy(x, y)?;
109    }
110    coalition.compressed_pubkey()
111}
112
113/// Compressed-pubkey variant of [`reconstruct_coalition_key`].
114pub fn reconstruct_coalition_key_compressed(
115    ordered_signers_compressed: &[[u8; 33]],
116) -> Result<[u8; 33], DataUpdateError> {
117    let xy = decompress_all(ordered_signers_compressed)?;
118    reconstruct_coalition_key(&xy)
119}
120
121/// Verify the aggregate Schnorr signature over an arbitrary `message_hash` against the coalition
122/// formed by `ordered_signers`.
123///
124/// Returns `Ok(true)` when valid (no fraud), `Ok(false)` when invalid (fabricated / committed
125/// garbage → slashable). `Err` only on malformed input (empty signer set, bad curve point). This
126/// mirrors the dispute-path semantics in the Molpha program.
127pub fn verify_aggregate_over_hash(
128    ordered_signers: &[SignerXy],
129    agg_sig_s: &[u8; 32],
130    commitment_addr: &[u8; 20],
131    message_hash: &[u8; 32],
132) -> Result<bool, DataUpdateError> {
133    if !secp256k1_scalar_is_valid_nonzero(agg_sig_s) {
134        return Ok(false);
135    }
136    let x_coalition = reconstruct_coalition_key(ordered_signers)?;
137    Ok(recover_and_match(
138        &x_coalition,
139        message_hash,
140        agg_sig_s,
141        commitment_addr,
142    ))
143}
144
145/// Run the Schnorr→ECDSA recovery trick and compare the recovered address to `commitment_addr`.
146fn recover_and_match(
147    x_coalition: &[u8; 33],
148    message_hash: &[u8; 32],
149    agg_sig_s: &[u8; 32],
150    commitment_addr: &[u8; 20],
151) -> bool {
152    let (recovery_id, ecdsa_signature, ecdsa_hash) =
153        match evm_schnorr_ecdsa_inputs(x_coalition, message_hash, agg_sig_s, commitment_addr) {
154            Ok(v) => v,
155            Err(_) => return false,
156        };
157    let recovered = match secp256k1_recover(&ecdsa_hash, recovery_id, &ecdsa_signature) {
158        Ok(r) => r,
159        Err(_) => return false,
160    };
161    eth_address_from_uncompressed_pubkey(recovered.to_bytes()) == *commitment_addr
162}
163
164fn decompress_all(compressed: &[[u8; 33]]) -> Result<Vec<SignerXy>, DataUpdateError> {
165    use libsecp256k1::{PublicKey, PublicKeyFormat};
166    compressed
167        .iter()
168        .map(|c| {
169            let pk = PublicKey::parse_slice(c, Some(PublicKeyFormat::Compressed))
170                .map_err(|_| DataUpdateError::InvalidAggregateSignature)?;
171            let full = pk.serialize(); // 0x04 || x || y
172            let x: [u8; 32] = full[1..33].try_into().unwrap();
173            let y: [u8; 32] = full[33..65].try_into().unwrap();
174            Ok((x, y))
175        })
176        .collect()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::message::MESSAGE_PREFIX;
183    use crate::test_signer;
184    use libsecp256k1::{PublicKey, PublicKeyFormat};
185
186    // ----------------------------------------------------------------------------------------
187    // End-to-end fixtures generated by the in-repo test signer (`crate::test_signer`): eight
188    // deterministic keypairs sign the message over a raw value longer than 32 bytes.
189    // ----------------------------------------------------------------------------------------
190
191    /// Raw feed value carried alongside the payload — deliberately longer than 32 bytes.
192    const FIXTURE_RAW_VALUE: &[u8] =
193        b"molpha raw value that is decidedly longer than thirty-two bytes";
194
195    /// "solana-compat-job" right-padded to 32 bytes.
196    const FIXTURE_FEED_ID: [u8; 32] = [
197        0x73, 0x6f, 0x6c, 0x61, 0x6e, 0x61, 0x2d, 0x63, 0x6f, 0x6d, 0x70, 0x61, 0x74, 0x2d, 0x6a,
198        0x6f, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
199        0x00, 0x00,
200    ];
201
202    /// EVM `uint256(255)` big-endian — bits 0–7 set (8 signers).
203    const FIXTURE_SIGNERS_BITMAP: [u8; 32] = [
204        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
205        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
206        0x00, 0xff,
207    ];
208
209    const FIXTURE_REGISTRY_VERSION: u32 = 1;
210    const FIXTURE_SIGNATURES_REQUIRED: u8 = 8;
211    const FIXTURE_CANONICAL_TIMESTAMP: i64 = 1_700_000_123;
212
213    /// Build the signed fixture: payload with a real aggregate signature plus the signer pubkeys.
214    fn signed_fixture() -> (DataUpdate, Vec<[u8; 33]>) {
215        let keys = test_signer::secret_keys(8);
216        let pubkeys = test_signer::pubkeys_compressed(&keys);
217        let mut payload = DataUpdate {
218            feed_id: FIXTURE_FEED_ID,
219            registry_version: FIXTURE_REGISTRY_VERSION,
220            canonical_timestamp: FIXTURE_CANONICAL_TIMESTAMP,
221            signatures_required: FIXTURE_SIGNATURES_REQUIRED,
222            agg_sig_s: [0u8; 32],
223            commitment_addr: [0u8; 20],
224            signers_bitmap: FIXTURE_SIGNERS_BITMAP,
225        };
226        let message_hash = compute_message_hash(&payload, FIXTURE_RAW_VALUE);
227        let (agg_sig_s, commitment_addr) =
228            test_signer::sign(&keys, &message_hash, b"verify-fixture");
229        payload.agg_sig_s = agg_sig_s;
230        payload.commitment_addr = commitment_addr;
231        (payload, pubkeys)
232    }
233
234    fn fixture_signers_xy(pubkeys: &[[u8; 33]]) -> Vec<SignerXy> {
235        decompress_all(pubkeys).expect("fixture pubkeys must be valid curve points")
236    }
237
238    #[test]
239    fn fixture_signers_bitmap_popcount_is_8() {
240        use crate::bitmap::bitmap_popcount_evm;
241        assert_eq!(
242            bitmap_popcount_evm(&FIXTURE_SIGNERS_BITMAP),
243            FIXTURE_SIGNATURES_REQUIRED as u32
244        );
245    }
246
247    /// The coalition-from-pubkeys path must match `PublicKey::combine`.
248    #[test]
249    fn reconstruct_coalition_key_matches_combine() {
250        let (_, pubkeys) = signed_fixture();
251        let pks: Vec<PublicKey> = pubkeys
252            .iter()
253            .map(|c| PublicKey::parse_slice(c, Some(PublicKeyFormat::Compressed)).unwrap())
254            .collect();
255        let combined = PublicKey::combine(&pks).unwrap().serialize_compressed();
256        let got = reconstruct_coalition_key(&fixture_signers_xy(&pubkeys)).unwrap();
257        assert_eq!(got, combined);
258        let got_c = reconstruct_coalition_key_compressed(&pubkeys).unwrap();
259        assert_eq!(got_c, combined);
260    }
261
262    /// Full end-to-end verification of a >32-byte raw value with caller-supplied pubkeys.
263    #[test]
264    fn verify_data_update_accepts_signed_fixture() {
265        let (payload, pubkeys) = signed_fixture();
266        assert!(FIXTURE_RAW_VALUE.len() > 32);
267        // node_count == signatures_required == 8 → selection is the full set, signers ⊆ selection.
268        verify_data_update(
269            &payload,
270            FIXTURE_RAW_VALUE,
271            8,
272            0,
273            &fixture_signers_xy(&pubkeys),
274        )
275        .expect("fixture DataUpdate must verify");
276        verify_data_update_compressed(&payload, FIXTURE_RAW_VALUE, 8, 0, &pubkeys)
277            .expect("compressed variant must verify");
278    }
279
280    #[test]
281    fn tampered_raw_value_is_rejected() {
282        let (payload, pubkeys) = signed_fixture();
283        let mut raw = FIXTURE_RAW_VALUE.to_vec();
284        raw[0] ^= 0xff;
285        assert_eq!(
286            verify_data_update(&payload, &raw, 8, 0, &fixture_signers_xy(&pubkeys)),
287            Err(DataUpdateError::InvalidAggregateSignature)
288        );
289    }
290
291    #[test]
292    fn tampered_s_fails_verification() {
293        let (mut payload, pubkeys) = signed_fixture();
294        payload.agg_sig_s[31] ^= 0x01;
295        let res = verify_data_update(
296            &payload,
297            FIXTURE_RAW_VALUE,
298            8,
299            0,
300            &fixture_signers_xy(&pubkeys),
301        );
302        assert_eq!(res, Err(DataUpdateError::InvalidAggregateSignature));
303    }
304
305    #[test]
306    fn wrong_signer_count_is_rejected() {
307        let (payload, pubkeys) = signed_fixture();
308        let mut signers = fixture_signers_xy(&pubkeys);
309        signers.pop();
310        assert_eq!(
311            verify_data_update(&payload, FIXTURE_RAW_VALUE, 8, 0, &signers),
312            Err(DataUpdateError::SignerCountMismatch)
313        );
314    }
315
316    #[test]
317    fn verify_aggregate_over_hash_roundtrip() {
318        let (payload, pubkeys) = signed_fixture();
319        let signers = fixture_signers_xy(&pubkeys);
320        let message_hash = compute_message_hash(&payload, FIXTURE_RAW_VALUE);
321        assert!(verify_aggregate_over_hash(
322            &signers,
323            &payload.agg_sig_s,
324            &payload.commitment_addr,
325            &message_hash,
326        )
327        .unwrap());
328
329        // Tampered hash → invalid (slashable), not an error.
330        let mut bad_hash = message_hash;
331        bad_hash[0] ^= 0xff;
332        assert!(!verify_aggregate_over_hash(
333            &signers,
334            &payload.agg_sig_s,
335            &payload.commitment_addr,
336            &bad_hash,
337        )
338        .unwrap());
339    }
340
341    #[test]
342    fn message_prefix_matches_known_constant() {
343        // Guard against accidental edits to the domain-separation prefix.
344        assert_eq!(MESSAGE_PREFIX[0], 0xa7);
345    }
346
347    /// Regenerates the constants embedded in `examples/verify_data_update.rs`.
348    ///
349    /// Run: `cargo test --lib regenerate_example_fixture -- --ignored --nocapture`
350    #[test]
351    #[ignore]
352    fn regenerate_example_fixture() {
353        fn print_bytes(name: &str, bytes: &[u8]) {
354            println!("const {name}: [u8; {}] = [", bytes.len());
355            for chunk in bytes.chunks(16) {
356                let row: Vec<String> = chunk.iter().map(|b| format!("0x{b:02x}")).collect();
357                println!("    {},", row.join(", "));
358            }
359            println!("];");
360        }
361
362        let (payload, pubkeys) = signed_fixture();
363        // Manual borsh-compatible encoding (integers little-endian, fixed arrays verbatim).
364        let mut bytes = Vec::new();
365        bytes.extend_from_slice(&payload.feed_id);
366        bytes.extend_from_slice(&payload.registry_version.to_le_bytes());
367        bytes.extend_from_slice(&payload.canonical_timestamp.to_le_bytes());
368        bytes.push(payload.signatures_required);
369        bytes.extend_from_slice(&payload.agg_sig_s);
370        bytes.extend_from_slice(&payload.commitment_addr);
371        bytes.extend_from_slice(&payload.signers_bitmap);
372        assert_eq!(bytes.len(), 129);
373
374        println!(
375            "const FIXTURE_RAW_VALUE: &[u8] = b\"{}\";",
376            core::str::from_utf8(FIXTURE_RAW_VALUE).unwrap()
377        );
378        print_bytes("FIXTURE_BORSH", &bytes);
379        println!(
380            "const FIXTURE_SIGNER_PUBKEYS: [[u8; 33]; {}] = [",
381            pubkeys.len()
382        );
383        for pk in &pubkeys {
384            let row: Vec<String> = pk.iter().map(|b| format!("0x{b:02x}")).collect();
385            println!("    [{}],", row.join(", "));
386        }
387        println!("];");
388    }
389}