Skip to main content

alea_verifier/instructions/
verify.rs

1use anchor_lang::prelude::*;
2
3use crate::crypto::{
4    constants::G2_GENERATOR,
5    hash_to_g1::hash_round_to_g1,
6    pairing::{negate_g1, on_curve_g1, verify_pairing},
7};
8use crate::errors::AleaError;
9use crate::events::BeaconVerified;
10use crate::state::Config;
11
12/// Accounts for the `verify` instruction.
13///
14/// `bump = config.bump` reuses the stored canonical bump instead of
15/// re-deriving (≈10K CU saving per ADR 0028).
16///
17/// `payer` (T2.27 rename from `verifier`) is the tx signer that funds
18/// the verification. Emitted in `BeaconVerified` for analytics; privacy
19/// note in `program/spec.md §"Privacy Considerations"`.
20#[derive(Accounts)]
21pub struct Verify<'info> {
22    #[account(
23        seeds = [b"config"],
24        bump = config.bump,
25    )]
26    pub config: Account<'info, Config>,
27    pub payer: Signer<'info>,
28}
29
30/// Pure BLS verification pipeline — no Anchor context dependency.
31///
32/// Factored out of `verify_handler` so it can be unit-tested natively
33/// (round-1 + round-9337227 drand fixtures, round-0 guard, corrupt sig,
34/// non-canonical G1 encoding). The Anchor handler is a thin emit-wrapper.
35///
36/// Returns the 32-byte randomness on success; mapped to
37/// `Anchor::Result<[u8; 32]>` error codes per `program/spec.md §"Error
38/// Codes"` and §"Error Handling Details" (T3.09 tri-state for pairing).
39fn verify_beacon_full(round: u64, signature: &[u8; 64], pubkey_g2: &[u8; 128]) -> Result<[u8; 32]> {
40    // SECURITY: guard ordering is load-bearing. DO NOT REORDER.
41    // 1. round > 0 (cheapest; protects against drand genesis sentinel)
42    // 2. on_curve_g1 (canonical-form check BEFORE curve equation — CVE-
43    //    2025-30147 parallel pattern: subgroup/curve ordering inversion
44    //    allows bypass)
45    // 3. hash_round_to_g1 (pure; no attacker input reaches this)
46    // 4. pairing (only runs if prior guards passed)
47    // T2.Y — `config.pubkey_g2 == EXPECTED_EVMNET_PUBKEY` defense-in-
48    // depth considered and deliberately skipped: invariant already holds
49    // via ADR 0028 PDA-singleton + init-time guards; +200 CU per verify
50    // not justified by current attack surface. Reference: cross-model-
51    // delta.md + R3 decision #13.
52    require!(round > 0, AleaError::RoundZero); // 6002
53    require!(on_curve_g1(signature), AleaError::InvalidG1Point); // 6001
54
55    // msg_hash = keccak256(round.to_be_bytes()) happens inside hash_round_to_g1
56    // (T1.02/T1.03: drand signs H2C(keccak256(8-byte BE round)))
57    // T1.05 — hash_round_to_g1 now returns Result; None from map_to_point
58    // maps to AleaError::NoSquareRoot (6004), Err from g1_add syscall maps
59    // to AleaError::PairingError (6006). ? propagates both.
60    let m = hash_round_to_g1(round)?;
61
62    // T2.I — defense-in-depth: SVDW + hash_to_field + g1_add must produce
63    // on-curve output. debug_assert compiles out in release (zero CU cost)
64    // but catches any refactor-introduced regression in tests.
65    debug_assert!(
66        on_curve_g1(&m),
67        "SVDW invariant violated: hash_round_to_g1 returned off-curve point"
68    );
69
70    let neg_m = negate_g1(&m);
71
72    match verify_pairing(signature, &neg_m, pubkey_g2, &G2_GENERATOR) {
73        Some(true) => {
74            // randomness = sha256(signature) — NOT keccak256 (ADR 0036).
75            // drand evmnet `bls-bn254-unchained-on-g1` scheme; verified
76            // empirically against live API rounds 1 + 9337227.
77            let randomness = anchor_lang::solana_program::hash::hash(signature).to_bytes();
78            Ok(randomness)
79        }
80        Some(false) => Err(AleaError::InvalidSignature.into()), // 6000
81        None => Err(AleaError::PairingError.into()),            // 6006 (BPF syscall Err only)
82    }
83}
84
85/// `verify` handler — wires the crypto pipeline into Anchor return data.
86///
87/// `Ok([u8; 32])` return type instructs Anchor 0.30.x to auto-serialize
88/// the randomness into program return data (ADR 0030 — Pattern A, empirical
89/// confirmation deferred to Phase 2 Wave 10 test #12).
90pub fn verify_handler(ctx: Context<Verify>, round: u64, signature: [u8; 64]) -> Result<[u8; 32]> {
91    let randomness = verify_beacon_full(round, &signature, &ctx.accounts.config.pubkey_g2)?;
92
93    emit!(BeaconVerified {
94        round,
95        randomness,
96        payer: ctx.accounts.payer.key(),
97    });
98
99    Ok(randomness)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::crypto::constants::EXPECTED_EVMNET_PUBKEY;
106    use hex_literal::hex;
107
108    /// Extract the numeric error code from an Anchor `Error`. Panics on
109    /// non-AnchorError variants since all AleaError codes go through
110    /// the `#[error_code]` macro.
111    fn err_code(err: anchor_lang::error::Error) -> u32 {
112        match err {
113            anchor_lang::error::Error::AnchorError(ae) => ae.error_code_number,
114            other => panic!("expected AnchorError, got {other:?}"),
115        }
116    }
117
118    const ROUND_1_SIG: [u8; 64] = hex!(
119        "11f812d738a36b2210dc88c2d635ad8039588205f42445d6de09e6530165c346"
120        "2a23aca348c84badcf8df5321ac24577b7963d5b0d780bc4626baedb45cde373"
121    );
122
123    const ROUND_9337227_SIG: [u8; 64] = hex!(
124        "01d65d6128f4b2df3d08de85543d8efe06b0281d0770246ae3672e8ddd3efda0"
125        "269373123458f0b5c0073eeed1c816a06809e127421513e34ee07df6987910b3"
126    );
127
128    #[test]
129    fn verify_round_1_fixture_produces_drand_randomness() {
130        let randomness = verify_beacon_full(1, &ROUND_1_SIG, &EXPECTED_EVMNET_PUBKEY)
131            .expect("round 1 must verify");
132        assert_eq!(
133            hex::encode(randomness),
134            "781b75698adc3af62cfa55db83cf0c73ae54e1ac8c0d4c3a2224126b65369ec5",
135            "round 1 randomness must match drand API fixture"
136        );
137    }
138
139    #[test]
140    fn verify_round_9337227_fixture_produces_drand_randomness() {
141        let randomness = verify_beacon_full(9337227, &ROUND_9337227_SIG, &EXPECTED_EVMNET_PUBKEY)
142            .expect("round 9337227 must verify");
143        assert_eq!(
144            hex::encode(randomness),
145            "a1e645cd6193837f626716851f5c42ad4bf63ad75193b2cae40f88c08c8f3bd8",
146            "round 9337227 randomness must match drand API fixture (randa-mu test vector)"
147        );
148    }
149
150    #[test]
151    fn verify_round_zero_returns_6002() {
152        let err = verify_beacon_full(0, &ROUND_1_SIG, &EXPECTED_EVMNET_PUBKEY)
153            .expect_err("round 0 must fail");
154        assert_eq!(
155            err_code(err),
156            6002,
157            "round 0 must return AleaError::RoundZero"
158        );
159    }
160
161    #[test]
162    fn verify_non_canonical_g1_x_equals_p_returns_6001() {
163        // x = p (non-canonical — field element encoding is invalid)
164        let p_be = hex!("30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47");
165        let mut sig = [0u8; 64];
166        sig[0..32].copy_from_slice(&p_be);
167        // y bytes can be anything — on_curve_g1 rejects at the canonical-form
168        // gate before looking at y
169        let err = verify_beacon_full(1, &sig, &EXPECTED_EVMNET_PUBKEY)
170            .expect_err("non-canonical x must fail");
171        assert_eq!(
172            err_code(err),
173            6001,
174            "x=p must return AleaError::InvalidG1Point"
175        );
176    }
177
178    #[test]
179    fn verify_off_curve_signature_returns_6001() {
180        // (x=1, y=1) is off curve: y² = 1 ≠ x³ + 3 = 4
181        let mut sig = [0u8; 64];
182        sig[31] = 1; // x = 1
183        sig[63] = 1; // y = 1
184        let err = verify_beacon_full(1, &sig, &EXPECTED_EVMNET_PUBKEY)
185            .expect_err("off-curve sig must fail");
186        assert_eq!(
187            err_code(err),
188            6001,
189            "off-curve sig must return AleaError::InvalidG1Point"
190        );
191    }
192
193    // T1.09 — split the old `verify_corrupt_signature_bit_flip_rejected`
194    // into two tests that pin down EXACTLY one error code each. The old
195    // test accepted `code == 6000 || code == 6001` which was permissive
196    // enough to pass regardless of which branch the corrupt sig took —
197    // a regression that caused pairing to return Some(true) on invalid
198    // sigs (or inverted guard order) would still pass. Now:
199    //   * on-curve-but-wrong sig → MUST return exactly 6000
200    //   * off-curve bit flip     → MUST return exactly 6001
201    // Source: P10-T3-03 (Sonnet test coverage), Codex E CRITICAL (2,8).
202
203    #[test]
204    fn verify_on_curve_forgery_returns_6000_exact() {
205        // Use round-1 sig presented as round-2: the sig IS on-curve
206        // (passes on_curve_g1), but pairing fails because drand signed
207        // a different round. This is an "on-curve forgery" scenario —
208        // the only path to AleaError::InvalidSignature (6000).
209        let err = verify_beacon_full(2, &ROUND_1_SIG, &EXPECTED_EVMNET_PUBKEY)
210            .expect_err("on-curve forgery must fail pairing");
211        assert_eq!(
212            err_code(err),
213            6000,
214            "on-curve forgery must return EXACTLY InvalidSignature (6000), not 6001 or other"
215        );
216    }
217
218    #[test]
219    fn verify_off_curve_bit_flip_returns_6001_exact() {
220        // Flip the highest byte of x to force off-curve. Verified: this
221        // puts x > p OR leaves x on-canonical but makes y² != x³ + 3.
222        // Either way: on_curve_g1 rejects at 6001 BEFORE reaching pairing.
223        let mut sig = ROUND_1_SIG;
224        sig[0] ^= 0xFF;
225        let err = verify_beacon_full(1, &sig, &EXPECTED_EVMNET_PUBKEY)
226            .expect_err("off-curve bit flip must fail");
227        assert_eq!(
228            err_code(err),
229            6001,
230            "off-curve bit flip must return EXACTLY InvalidG1Point (6001)"
231        );
232    }
233
234    #[test]
235    fn verify_wrong_round_returns_6000() {
236        // round 1 signature presented under round 2 — on curve but pairing fails
237        let err = verify_beacon_full(2, &ROUND_1_SIG, &EXPECTED_EVMNET_PUBKEY)
238            .expect_err("wrong round must fail pairing");
239        assert_eq!(
240            err_code(err),
241            6000,
242            "wrong round must return AleaError::InvalidSignature"
243        );
244    }
245
246    // T2.S — u64::MAX round boundary. Codex E HIGH (1). Submits the
247    // maximum u64 round value with round-1 sig; drand never signed this
248    // round, so pairing must fail with 6000. Proves Alea handles the
249    // numeric upper bound without overflow/panic.
250    #[test]
251    fn verify_u64_max_round_with_wrong_sig_returns_6000() {
252        let err = verify_beacon_full(u64::MAX, &ROUND_1_SIG, &EXPECTED_EVMNET_PUBKEY)
253            .expect_err("u64::MAX round with round-1 sig must fail pairing");
254        assert_eq!(
255            err_code(err),
256            6000,
257            "u64::MAX round must return InvalidSignature (6000) — numeric boundary handled"
258        );
259    }
260
261    // T2.CC — explicit replay safety test. Codex E LOW (12). The suite
262    // calls round 1 multiple times across tests, implying stateless
263    // replay-safety, but no test intentionally asserts this as a
264    // PROPERTY. Now it does: same round twice → same 32-byte randomness.
265    #[test]
266    fn verify_same_round_twice_returns_identical_randomness() {
267        let r1 = verify_beacon_full(1, &ROUND_1_SIG, &EXPECTED_EVMNET_PUBKEY)
268            .expect("first verify must succeed");
269        let r2 = verify_beacon_full(1, &ROUND_1_SIG, &EXPECTED_EVMNET_PUBKEY)
270            .expect("second verify must succeed");
271        assert_eq!(
272            r1, r2,
273            "Alea verify is stateless + replay-safe: same (round, sig) MUST produce byte-identical randomness"
274        );
275    }
276
277    // T1.11 — partial native coverage for PairingError (6006). The BPF
278    // tri-state None branch in verify_pairing can only be triggered via
279    // a real syscall Err (Agave / Firedancer infrastructure failure);
280    // native verify_pairing always returns Some(bool). This test pins
281    // the error-code mapping at the type level: if err_code() ever
282    // returns something other than 6006 for AleaError::PairingError,
283    // something in the Anchor macro or error numbering drifted. Proves
284    // the CPI contract (consumer SDKs) is stable for 6006.
285    //
286    // Full BPF integration test (forcing real syscall Err) lives in
287    // Wave G TS test suite and exercises the runtime path. Source:
288    // P10-T1-01, Codex E HIGH (8).
289    #[test]
290    fn pairing_error_6006_code_mapping_stable() {
291        let err: anchor_lang::error::Error = AleaError::PairingError.into();
292        assert_eq!(
293            err_code(err),
294            6006,
295            "AleaError::PairingError MUST map to numeric code 6006 per ADR 0028 CPI interface"
296        );
297
298        // Also pin 6004 NoSquareRoot (activated by T1.05 panic→Result)
299        let err: anchor_lang::error::Error = AleaError::NoSquareRoot.into();
300        assert_eq!(
301            err_code(err),
302            6004,
303            "AleaError::NoSquareRoot MUST map to numeric code 6004 per ADR 0028"
304        );
305
306        // Also pin 6010/6011 added in T2.E (Wave E)
307        let err: anchor_lang::error::Error = AleaError::InvalidGenesisTime.into();
308        assert_eq!(
309            err_code(err),
310            6010,
311            "AleaError::InvalidGenesisTime MUST map to numeric code 6010"
312        );
313        let err: anchor_lang::error::Error = AleaError::InvalidPeriod.into();
314        assert_eq!(
315            err_code(err),
316            6011,
317            "AleaError::InvalidPeriod MUST map to numeric code 6011"
318        );
319    }
320}