Skip to main content

molpha_verifier/
verify.rs

1//! High-level attestation 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::AttestationError;
12use crate::message::compute_message_hash;
13use crate::payload::{Attestation, AttestationPayload, 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 an attestation against caller-supplied signer pubkeys.
24///
25/// # Caller contract
26/// - `node_count` is the registry node count for `attestation.payload.registry_version`.
27/// - `ordered_signers` holds one `(x, y)` per set bit of `attestation.signature.signers_bitmap`,
28///   in **ascending bit-index order** — the same order EVM `Validator.verify` combines pubkeys.
29///   The caller is responsible for resolving the authentic pubkeys; this function trusts the
30///   supplied set.
31///
32/// Re-derives the selection bitmap internally and enforces `signers ⊆ selection`. Checks run in the
33/// same order as the on-chain monolith: scalar validity → signer threshold → selection subset →
34/// signer-count match → coalition reconstruction → message hash → Schnorr recovery.
35pub fn verify_attestation(
36    attestation: &Attestation,
37    node_count: u32,
38    redundancy_buffer: u8,
39    ordered_signers: &[SignerXy],
40) -> Result<(), AttestationError> {
41    verify_attestation_parts(
42        &attestation.payload,
43        &attestation.signature,
44        node_count,
45        redundancy_buffer,
46        ordered_signers,
47    )
48}
49
50/// Like [`verify_attestation`] but taking compressed (33-byte) signer pubkeys.
51pub fn verify_attestation_compressed(
52    attestation: &Attestation,
53    node_count: u32,
54    redundancy_buffer: u8,
55    ordered_signers_compressed: &[[u8; 33]],
56) -> Result<(), AttestationError> {
57    let xy = decompress_all(ordered_signers_compressed)?;
58    verify_attestation_parts(
59        &attestation.payload,
60        &attestation.signature,
61        node_count,
62        redundancy_buffer,
63        &xy,
64    )
65}
66
67pub(crate) fn verify_attestation_parts(
68    payload: &AttestationPayload,
69    signature: &SchnorrSignature,
70    node_count: u32,
71    redundancy_buffer: u8,
72    ordered_signers: &[SignerXy],
73) -> Result<(), AttestationError> {
74    if signature.agg_sig_s == [0u8; 32] || !secp256k1_scalar_is_valid_nonzero(&signature.agg_sig_s)
75    {
76        return Err(AttestationError::InvalidAggregateSignature);
77    }
78
79    let signers = bitmap_load(&signature.signers_bitmap);
80    let signer_count = signers.count_ones();
81    if signer_count < payload.signatures_required {
82        return Err(AttestationError::InsufficientSigners);
83    }
84
85    let expected_selection = derive_selection_bitmap(
86        &payload.source_id,
87        payload.registry_version,
88        payload.canonical_timestamp,
89        node_count,
90        payload.signatures_required,
91        redundancy_buffer,
92    )?;
93    if !bitmap_is_subset_u256(signers, bitmap_load(&expected_selection)) {
94        return Err(AttestationError::SignersNotSubsetOfSelection);
95    }
96
97    if ordered_signers.len() != signer_count as usize {
98        return Err(AttestationError::SignerCountMismatch);
99    }
100
101    let x_coalition = reconstruct_coalition_key(ordered_signers)?;
102    let message_hash = compute_message_hash(
103        payload,
104        signature.signers_bitmap,
105        payload.signatures_required,
106    );
107
108    if recover_and_match(
109        &x_coalition,
110        &message_hash,
111        &signature.agg_sig_s,
112        &signature.commitment_addr,
113    ) {
114        Ok(())
115    } else {
116        Err(AttestationError::InvalidAggregateSignature)
117    }
118}
119
120/// Reconstruct the coalition key `Σ X_i` from ordered signer pubkeys → compressed (33 bytes).
121///
122/// Errors on an empty signer set or a point-at-infinity sum.
123pub fn reconstruct_coalition_key(
124    ordered_signers: &[SignerXy],
125) -> Result<[u8; 33], AttestationError> {
126    if ordered_signers.is_empty() {
127        return Err(AttestationError::InvalidSignersBitmap);
128    }
129    let mut coalition = CoalitionAccumulator::default();
130    for (x, y) in ordered_signers {
131        coalition.add_stored_xy(x, y)?;
132    }
133    coalition.compressed_pubkey()
134}
135
136/// Compressed-pubkey variant of [`reconstruct_coalition_key`].
137pub fn reconstruct_coalition_key_compressed(
138    ordered_signers_compressed: &[[u8; 33]],
139) -> Result<[u8; 33], AttestationError> {
140    let xy = decompress_all(ordered_signers_compressed)?;
141    reconstruct_coalition_key(&xy)
142}
143
144/// Verify the aggregate Schnorr signature over an arbitrary `message_hash` against the coalition
145/// formed by `ordered_signers`.
146///
147/// Returns `Ok(true)` when valid (no fraud), `Ok(false)` when invalid (fabricated / committed
148/// garbage → slashable). `Err` only on malformed input (empty signer set, bad curve point). This
149/// mirrors the dispute-path semantics in the Molpha program.
150pub fn verify_aggregate_over_hash(
151    ordered_signers: &[SignerXy],
152    agg_sig_s: &[u8; 32],
153    commitment_addr: &[u8; 20],
154    message_hash: &[u8; 32],
155) -> Result<bool, AttestationError> {
156    if !secp256k1_scalar_is_valid_nonzero(agg_sig_s) {
157        return Ok(false);
158    }
159    let x_coalition = reconstruct_coalition_key(ordered_signers)?;
160    Ok(recover_and_match(
161        &x_coalition,
162        message_hash,
163        agg_sig_s,
164        commitment_addr,
165    ))
166}
167
168/// Run the Schnorr→ECDSA recovery trick and compare the recovered address to `commitment_addr`.
169fn recover_and_match(
170    x_coalition: &[u8; 33],
171    message_hash: &[u8; 32],
172    agg_sig_s: &[u8; 32],
173    commitment_addr: &[u8; 20],
174) -> bool {
175    let (recovery_id, ecdsa_signature, ecdsa_hash) =
176        match evm_schnorr_ecdsa_inputs(x_coalition, message_hash, agg_sig_s, commitment_addr) {
177            Ok(v) => v,
178            Err(_) => return false,
179        };
180    let recovered = match secp256k1_recover(&ecdsa_hash, recovery_id, &ecdsa_signature) {
181        Ok(r) => r,
182        Err(_) => return false,
183    };
184    eth_address_from_uncompressed_pubkey(recovered.to_bytes()) == *commitment_addr
185}
186
187fn decompress_all(compressed: &[[u8; 33]]) -> Result<Vec<SignerXy>, AttestationError> {
188    use libsecp256k1::{PublicKey, PublicKeyFormat};
189    compressed
190        .iter()
191        .map(|c| {
192            let pk = PublicKey::parse_slice(c, Some(PublicKeyFormat::Compressed))
193                .map_err(|_| AttestationError::InvalidAggregateSignature)?;
194            let full = pk.serialize(); // 0x04 || x || y
195            let x: [u8; 32] = full[1..33].try_into().unwrap();
196            let y: [u8; 32] = full[33..65].try_into().unwrap();
197            Ok((x, y))
198        })
199        .collect()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::message::MESSAGE_PREFIX;
206    use libsecp256k1::{PublicKey, PublicKeyFormat};
207
208    // ----------------------------------------------------------------------------------------
209    // End-to-end EVM-compatibility regression for the full Schnorr-recovery verification path.
210    // 12-node registry; 7 signers at bit positions 0, 2, 3, 4, 9, 10, 11 (signersBitmap = 3613).
211    // ----------------------------------------------------------------------------------------
212
213    const FIXTURE_REGISTERED_NODE_COUNT: u32 = 12;
214    const FIXTURE_REGISTRY_VERSION: u32 = 12;
215    const FIXTURE_SIGNATURES_REQUIRED: u32 = 5;
216    const FIXTURE_REDUNDANCY_BUFFER: u8 = 2;
217    const FIXTURE_CANONICAL_TIMESTAMP: u64 = 1_708_525_180;
218    const FIXTURE_SIGNER_COUNT: u32 = 7;
219
220    const FIXTURE_SOURCE_ID: [u8; 32] = [
221        0x0b, 0x0c, 0x5c, 0x4a, 0x0e, 0x67, 0x58, 0x69, 0xda, 0xc2, 0x27, 0x2a, 0x40, 0x04, 0x63,
222        0x65, 0xa2, 0x9c, 0x8a, 0xe7, 0x63, 0x5e, 0x52, 0xc4, 0x94, 0xd8, 0x40, 0xda, 0x2e, 0xc8,
223        0x26, 0xcb,
224    ];
225
226    const FIXTURE_VALUE: [u8; 32] = [
227        0xe1, 0xcd, 0x5b, 0x4f, 0x67, 0xac, 0xdc, 0x78, 0x68, 0xc3, 0xb1, 0x5f, 0x7b, 0x6c, 0xc2,
228        0xdc, 0x27, 0x70, 0x54, 0x53, 0x71, 0x34, 0x2c, 0xab, 0x76, 0x62, 0x71, 0xbb, 0x3f, 0xd5,
229        0xe7, 0x34,
230    ];
231
232    /// EVM `uint256(3613)` big-endian — bits 0, 2, 3, 4, 9, 10, 11 set.
233    const FIXTURE_SIGNERS_BITMAP: [u8; 32] = [
234        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
235        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
236        0x0e, 0x1d,
237    ];
238
239    /// `schnorrSignature.signature` — the Schnorr scalar `s`.
240    const FIXTURE_S: [u8; 32] = [
241        0x1b, 0x8d, 0xd2, 0x78, 0xb3, 0x67, 0xb3, 0x4d, 0x4e, 0xce, 0x69, 0xb8, 0x8c, 0x28, 0xff,
242        0x13, 0x01, 0xb6, 0x72, 0x51, 0xfc, 0x3d, 0x79, 0x26, 0xac, 0xb5, 0x25, 0xd1, 0x1f, 0xd3,
243        0x17, 0x1d,
244    ];
245
246    /// `schnorrSignature.commitment` — Ethereum address (20 bytes).
247    const FIXTURE_COMMITMENT: [u8; 20] = [
248        0x51, 0xbe, 0x44, 0x69, 0x33, 0x1a, 0x9e, 0xb3, 0xed, 0x48, 0xb1, 0xd4, 0xe1, 0x1e, 0xc9,
249        0xa0, 0xa5, 0x95, 0x2d, 0xf4,
250    ];
251
252    /// Full registry — compressed secp256k1 pubkeys for nodes 0–11.
253    const FIXTURE_PUBKEYS: [[u8; 33]; 12] = [
254        [
255            0x03, 0x04, 0xb2, 0x3a, 0xff, 0xb9, 0xae, 0xb2, 0x80, 0xd6, 0xa2, 0x75, 0xb8, 0x65,
256            0xe6, 0x3b, 0x1f, 0x27, 0xb0, 0xd5, 0x01, 0x6e, 0x35, 0x6d, 0xdb, 0xfe, 0x8b, 0xd2,
257            0x5b, 0x27, 0xd1, 0x7e, 0x5f,
258        ],
259        [
260            0x02, 0x1b, 0xdf, 0x3b, 0x69, 0xc5, 0x3c, 0x4e, 0xb2, 0xa9, 0x4c, 0x44, 0x3e, 0x68,
261            0x65, 0x02, 0x68, 0x0f, 0xe3, 0x69, 0xd8, 0xba, 0xe5, 0xef, 0x02, 0x2b, 0x6e, 0x07,
262            0xcc, 0xac, 0x05, 0xaa, 0x7d,
263        ],
264        [
265            0x02, 0xdc, 0x2d, 0x88, 0xad, 0x9d, 0x1c, 0x4f, 0xc7, 0x6b, 0xc5, 0xaf, 0x00, 0xc3,
266            0x90, 0x20, 0x08, 0xa0, 0xbe, 0x5f, 0x8f, 0x10, 0x48, 0xd1, 0xd5, 0xb3, 0xfb, 0xc7,
267            0x19, 0xfc, 0x7a, 0xd5, 0xec,
268        ],
269        [
270            0x02, 0xc2, 0x6e, 0xd5, 0xda, 0x51, 0x58, 0xfd, 0x27, 0xe5, 0xaf, 0xc0, 0x5f, 0x88,
271            0xeb, 0xe4, 0x4b, 0xcb, 0xf0, 0x90, 0xae, 0x9b, 0xc5, 0xe7, 0x02, 0x4d, 0xf0, 0xd5,
272            0x7e, 0xa4, 0xcd, 0x7a, 0x44,
273        ],
274        [
275            0x02, 0x85, 0x07, 0x3b, 0x91, 0x57, 0xfb, 0xd6, 0x77, 0x95, 0x9b, 0xf9, 0x12, 0xac,
276            0x07, 0x95, 0x8c, 0x4a, 0x62, 0x5d, 0xcc, 0xd7, 0x4f, 0xa1, 0x3c, 0x92, 0x9e, 0x3d,
277            0xbb, 0x8d, 0x3d, 0xbd, 0x41,
278        ],
279        [
280            0x02, 0x25, 0x50, 0xee, 0x49, 0x3c, 0x38, 0x43, 0x8a, 0xa7, 0x40, 0xc0, 0xa9, 0x97,
281            0x8b, 0x20, 0x84, 0xa3, 0x50, 0x86, 0xbf, 0xef, 0x28, 0x9f, 0x3b, 0xe8, 0x58, 0xe2,
282            0xe7, 0xda, 0x3c, 0x09, 0x7f,
283        ],
284        [
285            0x03, 0x30, 0x96, 0x23, 0x4e, 0x51, 0x78, 0xf3, 0x71, 0x03, 0xa6, 0x6d, 0x86, 0x81,
286            0x76, 0x02, 0x58, 0xdd, 0xc5, 0x2d, 0x1a, 0x06, 0xbd, 0xed, 0xa6, 0xaa, 0xa3, 0x2f,
287            0xbe, 0x32, 0xb8, 0x78, 0x60,
288        ],
289        [
290            0x03, 0xd4, 0xa4, 0x66, 0x9d, 0xbc, 0x8e, 0x33, 0x9a, 0x9c, 0x1d, 0xa3, 0x42, 0xf3,
291            0x14, 0x54, 0x04, 0x92, 0x4c, 0x65, 0x1d, 0x94, 0x16, 0xb0, 0xb5, 0x8c, 0xc3, 0x0b,
292            0x1f, 0xc8, 0x03, 0x7a, 0x92,
293        ],
294        [
295            0x03, 0xfb, 0x7a, 0xae, 0x5c, 0x57, 0x4c, 0xd5, 0x0e, 0x2a, 0xd6, 0xed, 0x8e, 0x15,
296            0x64, 0xa6, 0x70, 0x75, 0x56, 0xa1, 0x50, 0xa6, 0x4f, 0x24, 0x72, 0x67, 0xa2, 0x7d,
297            0xe5, 0x9b, 0x82, 0xe2, 0x63,
298        ],
299        [
300            0x02, 0x58, 0xbf, 0x41, 0xcf, 0xea, 0x2b, 0x1d, 0x34, 0x4c, 0xc3, 0x0b, 0xb7, 0x35,
301            0xa1, 0x32, 0xc1, 0x75, 0x5b, 0x11, 0x2d, 0xb5, 0x8f, 0xaa, 0x7e, 0x4c, 0x44, 0x65,
302            0x95, 0x2e, 0x00, 0x04, 0xbf,
303        ],
304        [
305            0x02, 0x5d, 0xc1, 0x4d, 0x6b, 0xc2, 0x04, 0x42, 0xbe, 0x79, 0xf5, 0x1c, 0xf5, 0x20,
306            0x33, 0xc3, 0x96, 0x7b, 0xcc, 0xdd, 0xc5, 0xd3, 0x66, 0x95, 0x95, 0x13, 0x73, 0x20,
307            0xdf, 0xe5, 0xc6, 0xab, 0xfc,
308        ],
309        [
310            0x03, 0xdc, 0xa6, 0x3a, 0x35, 0xd0, 0x48, 0xf7, 0x94, 0x5c, 0x95, 0x9d, 0x61, 0x8c,
311            0x2f, 0xe8, 0xee, 0x5d, 0x40, 0x00, 0x29, 0x19, 0xa4, 0x6d, 0xff, 0x81, 0x27, 0x9c,
312            0x04, 0xb9, 0x71, 0xe6, 0x06,
313        ],
314    ];
315
316    fn fixture_payload() -> AttestationPayload {
317        AttestationPayload {
318            value: FIXTURE_VALUE,
319            source_id: FIXTURE_SOURCE_ID,
320            registry_version: FIXTURE_REGISTRY_VERSION,
321            canonical_timestamp: FIXTURE_CANONICAL_TIMESTAMP,
322            signatures_required: FIXTURE_SIGNATURES_REQUIRED,
323        }
324    }
325
326    fn fixture_signature() -> SchnorrSignature {
327        SchnorrSignature {
328            agg_sig_s: FIXTURE_S,
329            commitment_addr: FIXTURE_COMMITMENT,
330            signers_bitmap: FIXTURE_SIGNERS_BITMAP,
331        }
332    }
333
334    fn fixture_signers_xy() -> Vec<SignerXy> {
335        use crate::bitmap::for_each_set_bit;
336        let mut signers = Vec::new();
337        for_each_set_bit(&FIXTURE_SIGNERS_BITMAP, |i| {
338            let c = &FIXTURE_PUBKEYS[i];
339            let pk = PublicKey::parse_slice(c, Some(PublicKeyFormat::Compressed))
340                .expect("fixture pubkey must be a valid curve point");
341            let full = pk.serialize();
342            let x: [u8; 32] = full[1..33].try_into().unwrap();
343            let y: [u8; 32] = full[33..65].try_into().unwrap();
344            signers.push((x, y));
345        });
346        signers
347    }
348
349    fn fixture_signer_pubkeys_compressed() -> Vec<[u8; 33]> {
350        use crate::bitmap::for_each_set_bit;
351        let mut signers = Vec::new();
352        for_each_set_bit(&FIXTURE_SIGNERS_BITMAP, |i| {
353            signers.push(FIXTURE_PUBKEYS[i]);
354        });
355        signers
356    }
357
358    #[test]
359    fn fixture_pubkeys_are_valid_curve_points() {
360        for (i, pk) in FIXTURE_PUBKEYS.iter().enumerate() {
361            PublicKey::parse_slice(pk, Some(PublicKeyFormat::Compressed))
362                .unwrap_or_else(|_| panic!("fixture pubkey {i} is not a valid curve point"));
363        }
364    }
365
366    #[test]
367    fn fixture_signers_bitmap_popcount_meets_threshold() {
368        use crate::bitmap::bitmap_popcount_evm;
369        let popcount = bitmap_popcount_evm(&FIXTURE_SIGNERS_BITMAP);
370        assert_eq!(popcount, FIXTURE_SIGNER_COUNT);
371        assert!(popcount >= FIXTURE_SIGNATURES_REQUIRED);
372    }
373
374    /// The coalition-from-pubkeys path must match `PublicKey::combine`.
375    #[test]
376    fn reconstruct_coalition_key_matches_combine() {
377        let signer_pubkeys = fixture_signer_pubkeys_compressed();
378        let pks: Vec<PublicKey> = signer_pubkeys
379            .iter()
380            .map(|c| PublicKey::parse_slice(c, Some(PublicKeyFormat::Compressed)).unwrap())
381            .collect();
382        let combined = PublicKey::combine(&pks).unwrap().serialize_compressed();
383        let got = reconstruct_coalition_key(&fixture_signers_xy()).unwrap();
384        assert_eq!(got, combined);
385        let got_c = reconstruct_coalition_key_compressed(&signer_pubkeys).unwrap();
386        assert_eq!(got_c, combined);
387    }
388
389    fn fixture_attestation() -> Attestation {
390        Attestation {
391            payload: fixture_payload(),
392            signature: fixture_signature(),
393        }
394    }
395
396    /// Full end-to-end EVM-compat verification with caller-supplied pubkeys — no anchor, no PDAs.
397    #[test]
398    fn verify_attestation_accepts_evm_fixture() {
399        let attestation = fixture_attestation();
400        let signer_pubkeys = fixture_signer_pubkeys_compressed();
401        verify_attestation(
402            &attestation,
403            FIXTURE_REGISTERED_NODE_COUNT,
404            FIXTURE_REDUNDANCY_BUFFER,
405            &fixture_signers_xy(),
406        )
407        .expect("fixture attestation must verify");
408        verify_attestation_compressed(
409            &attestation,
410            FIXTURE_REGISTERED_NODE_COUNT,
411            FIXTURE_REDUNDANCY_BUFFER,
412            &signer_pubkeys,
413        )
414        .expect("compressed variant must verify");
415    }
416
417    #[test]
418    fn tampered_s_fails_verification() {
419        let mut attestation = fixture_attestation();
420        attestation.signature.agg_sig_s[31] ^= 0x01;
421        let res = verify_attestation(
422            &attestation,
423            FIXTURE_REGISTERED_NODE_COUNT,
424            FIXTURE_REDUNDANCY_BUFFER,
425            &fixture_signers_xy(),
426        );
427        assert_eq!(res, Err(AttestationError::InvalidAggregateSignature));
428    }
429
430    #[test]
431    fn wrong_signer_count_is_rejected() {
432        let attestation = fixture_attestation();
433        let mut signers = fixture_signers_xy();
434        signers.pop();
435        assert_eq!(
436            verify_attestation(
437                &attestation,
438                FIXTURE_REGISTERED_NODE_COUNT,
439                FIXTURE_REDUNDANCY_BUFFER,
440                &signers,
441            ),
442            Err(AttestationError::SignerCountMismatch)
443        );
444    }
445
446    #[test]
447    fn verify_aggregate_over_hash_roundtrip() {
448        let payload = fixture_payload();
449        let signature = fixture_signature();
450        let signers = fixture_signers_xy();
451        let message_hash = compute_message_hash(
452            &payload,
453            signature.signers_bitmap,
454            payload.signatures_required,
455        );
456        assert!(verify_aggregate_over_hash(
457            &signers,
458            &signature.agg_sig_s,
459            &signature.commitment_addr,
460            &message_hash,
461        )
462        .unwrap());
463
464        // Tampered hash → invalid (slashable), not an error.
465        let mut bad_hash = message_hash;
466        bad_hash[0] ^= 0xff;
467        assert!(!verify_aggregate_over_hash(
468            &signers,
469            &signature.agg_sig_s,
470            &signature.commitment_addr,
471            &bad_hash,
472        )
473        .unwrap());
474    }
475
476    #[test]
477    fn message_prefix_matches_known_constant() {
478        // Guard against accidental edits to the domain-separation prefix.
479        assert_eq!(MESSAGE_PREFIX[0], 0xa7);
480    }
481}