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