Skip to main content

hotshot_contract_adapter/
stake_table.rs

1use alloy::{
2    primitives::{Address, Bytes},
3    sol_types::SolValue,
4};
5use ark_bn254::G2Affine;
6use ark_ec::{AffineRepr, CurveGroup as _};
7use ark_ed_on_bn254::EdwardsConfig;
8use ark_serialize::{CanonicalDeserialize, CanonicalSerialize};
9use hotshot_types::{
10    light_client::{StateKeyPair, StateSignature, StateVerKey, hash_bytes_to_field},
11    signature_key::{BLSKeyPair, BLSPubKey, BLSSignature},
12    traits::signature_key::SignatureKey,
13};
14use jf_signature::{
15    bls_over_bn254,
16    constants::{CS_ID_BLS_BN254, CS_ID_SCHNORR},
17    schnorr,
18};
19
20use crate::{
21    field_to_u256,
22    sol_types::{StakeTableV3, *},
23    u256_to_field,
24};
25
26// Allows us to implement From on existing Bytes type
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct StateSignatureSol(pub Bytes);
29
30#[derive(Debug, Clone, Copy, Default)]
31pub enum StakeTableContractVersion {
32    V1,
33    V2,
34    #[default]
35    V3,
36}
37
38fn version_from_major(major: u8) -> anyhow::Result<StakeTableContractVersion> {
39    match major {
40        1 => Ok(StakeTableContractVersion::V1),
41        2 => Ok(StakeTableContractVersion::V2),
42        3 => Ok(StakeTableContractVersion::V3),
43        _ => anyhow::bail!("Unsupported stake table contract version: {major}"),
44    }
45}
46
47impl TryFrom<StakeTableV3::getVersionReturn> for StakeTableContractVersion {
48    type Error = anyhow::Error;
49
50    fn try_from(value: StakeTableV3::getVersionReturn) -> anyhow::Result<Self> {
51        version_from_major(value.majorVersion)
52    }
53}
54
55/// Fallible because Solidity accepts the all-zero G2 point `(0,0,0,0)` as
56/// the identity and emits it in stake-table events, but arkworks'
57/// `deserialize_uncompressed` runs an on-curve + subgroup check
58/// (`ark-ec` `short_weierstrass::Affine::check`) that rejects it as not on
59/// the curve. Callers must skip the offending L1 event rather than panic.
60impl TryFrom<G2PointSol> for BLSPubKey {
61    type Error = StakeTableSolError;
62
63    fn try_from(value: G2PointSol) -> Result<Self, Self::Error> {
64        let point: G2Affine = value.into();
65        let mut bytes = vec![];
66        point
67            .into_group()
68            .serialize_uncompressed(&mut bytes)
69            .unwrap();
70        Self::deserialize_uncompressed(&bytes[..]).map_err(|_| StakeTableSolError::InvalidBlsKey)
71    }
72}
73
74impl From<BLSPubKey> for G2PointSol {
75    fn from(value: BLSPubKey) -> Self {
76        value.to_affine().into()
77    }
78}
79
80impl TryFrom<EdOnBN254PointSol> for StateVerKey {
81    type Error = StakeTableSolError;
82
83    fn try_from(value: EdOnBN254PointSol) -> Result<Self, Self::Error> {
84        // 1) Coordinates must be canonical field elements. `u256_to_field` silently
85        // reduces mod p, so reject anything that isn't already its own reduced form.
86        let x: ark_ed_on_bn254::Fq = u256_to_field(value.x);
87        let y: ark_ed_on_bn254::Fq = u256_to_field(value.y);
88        if field_to_u256(x) != value.x || field_to_u256(y) != value.y {
89            return Err(StakeTableSolError::InvalidSchnorrKey);
90        }
91        // 2) on curve, 3) in the prime-order subgroup, 4) not the identity.
92        let point = ark_ed_on_bn254::EdwardsAffine::new_unchecked(x, y);
93        if point.is_zero()
94            || !point.is_on_curve()
95            || !point.is_in_correct_subgroup_assuming_on_curve()
96        {
97            return Err(StakeTableSolError::InvalidSchnorrKey);
98        }
99        Ok(Self::from(point))
100    }
101}
102
103impl From<bls_over_bn254::Signature> for G1PointSol {
104    fn from(sig: bls_over_bn254::Signature) -> Self {
105        sig.sigma.into_affine().into()
106    }
107}
108
109impl From<StateVerKey> for EdOnBN254PointSol {
110    fn from(ver_key: StateVerKey) -> Self {
111        ver_key.to_affine().into()
112    }
113}
114
115impl From<StateSignature> for StateSignatureSol {
116    fn from(sig: StateSignature) -> Self {
117        let mut buf = vec![];
118        sig.serialize_compressed(&mut buf).expect("serialize works");
119        Self(buf.into())
120    }
121}
122
123impl From<StateSignatureSol> for Bytes {
124    fn from(sig_sol: StateSignatureSol) -> Self {
125        sig_sol.0
126    }
127}
128
129pub fn sign_address_bls(bls_key_pair: &BLSKeyPair, address: Address) -> bls_over_bn254::Signature {
130    bls_key_pair.sign(&address.abi_encode(), CS_ID_BLS_BN254)
131}
132
133pub fn sign_address_schnorr(schnorr_key_pair: &StateKeyPair, address: Address) -> StateSignature {
134    let msg = [hash_bytes_to_field(&address.abi_encode()).expect("hash to field works")];
135    schnorr_key_pair.sign(&msg, CS_ID_SCHNORR)
136}
137
138/// Authenticate a Schnorr signature over an Ethereum address
139pub fn authenticate_schnorr_sig(
140    schnorr_vk: &StateVerKey,
141    address: Address,
142    schnorr_sig: &StateSignature,
143) -> Result<(), StakeTableSolError> {
144    let msg = [hash_bytes_to_field(&address.abi_encode()).expect("hash to field works")];
145    schnorr_vk.verify(&msg, schnorr_sig, CS_ID_SCHNORR)?;
146    Ok(())
147}
148
149/// Authenticate a BLS signature over an Ethereum address
150pub fn authenticate_bls_sig(
151    bls_vk: &BLSPubKey,
152    address: Address,
153    bls_sig: &BLSSignature,
154) -> Result<(), StakeTableSolError> {
155    let msg = address.abi_encode();
156    if !bls_vk.validate(bls_sig, &msg) {
157        return Err(StakeTableSolError::InvalidBlsSignature);
158    }
159    Ok(())
160}
161
162fn authenticate_stake_table_validator_event(
163    account: Address,
164    bls_vk: G2PointSol,
165    schnorr_vk: EdOnBN254PointSol,
166    bls_sig: G1PointSol,
167    schnorr_sig: &[u8],
168) -> Result<(BLSPubKey, StateVerKey), StakeTableSolError> {
169    let bls_vk = BLSPubKey::try_from(bls_vk)?;
170    let bls_sig_jellyfish = {
171        let sigma_affine: ark_bn254::G1Affine = bls_sig.into();
172        BLSSignature {
173            sigma: sigma_affine.into_group(),
174        }
175    };
176    authenticate_bls_sig(&bls_vk, account, &bls_sig_jellyfish)?;
177
178    let schnorr_vk = StateVerKey::try_from(schnorr_vk)?;
179    let schnorr_sig_jellyfish =
180        schnorr::Signature::<EdwardsConfig>::deserialize_compressed(schnorr_sig)?;
181    authenticate_schnorr_sig(&schnorr_vk, account, &schnorr_sig_jellyfish)?;
182    Ok((bls_vk, schnorr_vk))
183}
184
185/// Errors encountered when processing stake table events
186#[derive(Debug, thiserror::Error)]
187pub enum StakeTableSolError {
188    #[error("Failed to deserialize Schnorr signature")]
189    SchnorrSigDeserializationError(#[from] ark_serialize::SerializationError),
190    #[error("BLS signature invalid")]
191    InvalidBlsSignature,
192    #[error("Schnorr signature invalid")]
193    InvalidSchnorrSignature(#[from] jf_signature::SignatureError),
194    #[error("Invalid BLS key")]
195    InvalidBlsKey,
196    #[error("Invalid Schnorr key")]
197    InvalidSchnorrKey,
198}
199
200impl StakeTableV3::ValidatorRegisteredV3 {
201    /// Verify the BLS and Schnorr signatures in the event and return the parsed keys.
202    pub fn authenticate(&self) -> Result<(BLSPubKey, StateVerKey), StakeTableSolError> {
203        authenticate_stake_table_validator_event(
204            self.account,
205            self.blsVK,
206            self.schnorrVK,
207            self.blsSig.into(),
208            &self.schnorrSig,
209        )
210    }
211}
212
213impl StakeTableV3::ValidatorRegisteredV2 {
214    /// Verify the BLS and Schnorr signatures in the event and return the parsed keys.
215    pub fn authenticate(&self) -> Result<(BLSPubKey, StateVerKey), StakeTableSolError> {
216        authenticate_stake_table_validator_event(
217            self.account,
218            self.blsVK,
219            self.schnorrVK,
220            self.blsSig.into(),
221            &self.schnorrSig,
222        )
223    }
224}
225
226impl StakeTableV3::ConsensusKeysUpdatedV2 {
227    /// Verify the BLS and Schnorr signatures in the event and return the parsed keys.
228    pub fn authenticate(&self) -> Result<(BLSPubKey, StateVerKey), StakeTableSolError> {
229        authenticate_stake_table_validator_event(
230            self.account,
231            self.blsVK,
232            self.schnorrVK,
233            self.blsSig.into(),
234            &self.schnorrSig,
235        )
236    }
237}
238
239#[cfg(test)]
240mod test {
241    use alloy::primitives::{Address, U256};
242    use hotshot_types::{
243        light_client::{StateKeyPair, StateVerKey},
244        signature_key::{BLSKeyPair, BLSPrivKey, BLSPubKey},
245    };
246
247    use super::{StateSignatureSol, sign_address_bls, sign_address_schnorr};
248    use crate::sol_types::{
249        EdOnBN254PointSol, G1PointSol, G2PointSol,
250        StakeTableV3::{ConsensusKeysUpdatedV2, ValidatorRegisteredV2},
251    };
252
253    fn check_round_trip(pk: BLSPubKey) {
254        let g2 = G2PointSol::from(pk);
255        let pk2 = BLSPubKey::try_from(g2).expect("valid BLS key roundtrip");
256        assert_eq!(pk2, pk, "Failed to roundtrip G2PointSol to BLSPubKey: {pk}");
257    }
258
259    #[test]
260    fn test_bls_g2_point_roundtrip() {
261        let mut rng = rand::thread_rng();
262        for _ in 0..100 {
263            let pk = (&BLSPrivKey::generate(&mut rng)).into();
264            check_round_trip(pk);
265        }
266    }
267
268    #[test]
269    fn test_bls_g2_point_alloy_migration_regression() {
270        // This pubkey fails the roundtrip if "serialize_{un,}compressed" are mixed
271        let s = "BLS_VER_KEY~JlRLUrn0T_MltAJXaaojwk_CnCgd0tyPny_IGdseMBLBPv9nWabIPAaS-aHmn0ARu5YZHJ7mfmGQ-alW42tkJM663Lse-Is80fyA1jnRxPsHcJDnO05oW1M1SC5LeE8sXITbuhmtG2JdTAgmLqWOxbMRmVIqS1AQXqvGGXdo5qpd";
272        let pk: BLSPubKey = s.parse().unwrap();
273        check_round_trip(pk);
274    }
275
276    #[test]
277    fn test_validator_registered_event_authentication() {
278        for _ in 0..10 {
279            let bls_key_pair = BLSKeyPair::generate(&mut rand::thread_rng());
280            let schnorr_key_pair = StateKeyPair::generate();
281            let address = Address::random();
282
283            let bls_sig = sign_address_bls(&bls_key_pair, address);
284            let schnorr_sig = sign_address_schnorr(&schnorr_key_pair, address);
285
286            let valid_event = ValidatorRegisteredV2 {
287                account: address,
288                blsVK: bls_key_pair.ver_key().into(),
289                schnorrVK: schnorr_key_pair.ver_key().into(),
290                commission: 1000, // 10%
291                blsSig: G1PointSol::from(bls_sig.clone()).into(),
292                schnorrSig: StateSignatureSol::from(schnorr_sig.clone()).into(),
293                metadataUri: "dummy-meta".to_string(),
294            };
295            assert!(valid_event.authenticate().is_ok());
296
297            let wrong_bls_sig =
298                sign_address_bls(&BLSKeyPair::generate(&mut rand::thread_rng()), address);
299            let mut bad_bls_event = valid_event.clone();
300            bad_bls_event.blsSig = G1PointSol::from(wrong_bls_sig).into();
301            assert!(bad_bls_event.authenticate().is_err());
302
303            let wrong_schnorr_sig = sign_address_schnorr(&StateKeyPair::generate(), address);
304            let mut bad_schnorr_event = valid_event.clone();
305            bad_schnorr_event.schnorrSig = StateSignatureSol::from(wrong_schnorr_sig).into();
306            assert!(bad_schnorr_event.authenticate().is_err());
307        }
308    }
309
310    #[test]
311    fn test_zero_g2_bls_pubkey_conversion_is_err() {
312        let zero_g2 = G2PointSol {
313            x0: U256::ZERO,
314            x1: U256::ZERO,
315            y0: U256::ZERO,
316            y1: U256::ZERO,
317        };
318        assert!(BLSPubKey::try_from(zero_g2).is_err());
319    }
320
321    #[test]
322    fn test_zero_g2_authenticate_returns_err_not_panic() {
323        use alloy::primitives::Bytes;
324        let event = ConsensusKeysUpdatedV2 {
325            account: Address::random(),
326            blsVK: G2PointSol {
327                x0: U256::ZERO,
328                x1: U256::ZERO,
329                y0: U256::ZERO,
330                y1: U256::ZERO,
331            },
332            schnorrVK: StateKeyPair::generate().ver_key().into(),
333            blsSig: G1PointSol {
334                x: U256::ZERO,
335                y: U256::ZERO,
336            }
337            .into(),
338            schnorrSig: Bytes::from(vec![0u8; 64]),
339        };
340        assert!(event.authenticate().is_err());
341    }
342
343    #[test]
344    fn test_consensus_keys_updated_event_authentication() {
345        for _ in 0..10 {
346            let bls_key_pair = BLSKeyPair::generate(&mut rand::thread_rng());
347            let schnorr_key_pair = StateKeyPair::generate();
348            let address = Address::random();
349
350            let bls_sig = sign_address_bls(&bls_key_pair, address);
351            let schnorr_sig = sign_address_schnorr(&schnorr_key_pair, address);
352
353            let valid_event = ConsensusKeysUpdatedV2 {
354                account: address,
355                blsVK: bls_key_pair.ver_key().into(),
356                schnorrVK: schnorr_key_pair.ver_key().into(),
357                blsSig: G1PointSol::from(bls_sig.clone()).into(),
358                schnorrSig: StateSignatureSol::from(schnorr_sig.clone()).into(),
359            };
360            let (bls, schnorr) = valid_event.authenticate().expect("valid keys parse");
361            assert_eq!(bls, bls_key_pair.ver_key());
362            assert_eq!(schnorr, schnorr_key_pair.ver_key());
363
364            let wrong_bls_sig =
365                sign_address_bls(&BLSKeyPair::generate(&mut rand::thread_rng()), address);
366            let mut bad_bls_event = valid_event.clone();
367            bad_bls_event.blsSig = G1PointSol::from(wrong_bls_sig).into();
368            assert!(bad_bls_event.authenticate().is_err());
369
370            let wrong_schnorr_sig = sign_address_schnorr(&StateKeyPair::generate(), address);
371            let mut bad_schnorr_event = valid_event.clone();
372            bad_schnorr_event.schnorrSig = StateSignatureSol::from(wrong_schnorr_sig).into();
373            assert!(bad_schnorr_event.authenticate().is_err());
374        }
375    }
376
377    #[test]
378    fn test_zero_schnorr_verkey_conversion_is_err() {
379        let zero_ed = EdOnBN254PointSol {
380            x: U256::ZERO,
381            y: U256::ZERO,
382        };
383        assert!(StateVerKey::try_from(zero_ed).is_err());
384    }
385
386    #[test]
387    fn test_zero_schnorr_authenticate_returns_err_not_panic() {
388        use alloy::primitives::Bytes;
389        let bls_key_pair = BLSKeyPair::generate(&mut rand::thread_rng());
390        let address = Address::random();
391        let bls_sig = sign_address_bls(&bls_key_pair, address);
392        let event = ValidatorRegisteredV2 {
393            account: address,
394            blsVK: bls_key_pair.ver_key().into(),
395            schnorrVK: EdOnBN254PointSol {
396                x: U256::ZERO,
397                y: U256::ZERO,
398            },
399            commission: 0,
400            blsSig: G1PointSol::from(bls_sig).into(),
401            schnorrSig: Bytes::from(vec![0u8; 64]),
402            metadataUri: String::new(),
403        };
404        assert!(event.authenticate().is_err());
405    }
406
407    /// Coordinates outside the base field must be rejected; `u256_to_field`
408    /// would otherwise silently reduce them mod p.
409    #[test]
410    fn test_schnorr_verkey_out_of_range_is_err() {
411        let out_of_range = EdOnBN254PointSol {
412            x: U256::MAX,
413            y: U256::from(1),
414        };
415        assert!(StateVerKey::try_from(out_of_range).is_err());
416    }
417
418    /// The twisted Edwards identity (0, 1) is on-curve and in the prime-order
419    /// subgroup but is not a valid verification key and must be rejected.
420    #[test]
421    fn test_schnorr_identity_point_is_err() {
422        let identity = EdOnBN254PointSol {
423            x: U256::ZERO,
424            y: U256::from(1),
425        };
426        let point: ark_ed_on_bn254::EdwardsAffine = identity.into();
427        assert!(point.is_on_curve());
428        assert!(point.is_in_correct_subgroup_assuming_on_curve());
429        assert!(StateVerKey::try_from(identity).is_err());
430    }
431
432    /// A validly generated Schnorr key must round-trip through the conversion.
433    #[test]
434    fn test_valid_schnorr_verkey_roundtrips() {
435        let vk = StateKeyPair::generate().ver_key();
436        let sol: EdOnBN254PointSol = vk.clone().into();
437        assert_eq!(StateVerKey::try_from(sol).expect("valid key"), vk);
438    }
439}
440
441// Solidity `validateP2pAddr` and Rust `NetAddr::from_str` both parse `host:port` strings.
442// If Solidity accepts an address that Rust rejects, the validator's p2p address is silently
443// dropped and they become unreachable for cliquenet. This proptest deploys StakeTableV3
444// on anvil and fuzzes the property: Solidity accepts => Rust accepts.
445#[cfg(test)]
446mod proptest_p2p_addr {
447    use alloy::providers::ProviderBuilder;
448    use hotshot_types::addr::NetAddr;
449    use proptest::{
450        prelude::*,
451        test_runner::{Config as ProptestConfig, TestRunner},
452    };
453
454    use crate::sol_types::StakeTableV3;
455
456    fn p2p_addr_strategy() -> impl Strategy<Value = String> {
457        prop_oneof![
458            // Valid IPv4:port
459            (1..255u8, 1..255u8, 1..255u8, 1..255u8, 1..65535u16)
460                .prop_map(|(a, b, c, d, p)| format!("{a}.{b}.{c}.{d}:{p}")),
461            // Valid hostname:port
462            ("[a-z][a-z0-9.-]{0,20}", 1..65535u16).prop_map(|(h, p)| format!("{h}:{p}")),
463            // Edge: missing port
464            "[a-z][a-z0-9.-]{0,20}".prop_map(|h| h.to_string()),
465            // Edge: empty string
466            Just("".to_string()),
467            // Edge: port 0
468            "[a-z]{1,10}".prop_map(|h| format!("{h}:0")),
469            // Edge: leading zero port
470            "[a-z]{1,10}".prop_map(|h| format!("{h}:08080")),
471            // Edge: port too large
472            "[a-z]{1,10}".prop_map(|h| format!("{h}:99999")),
473            // Edge: non-digit port
474            ("[a-z]{1,10}", "[a-z]{1,5}").prop_map(|(h, p)| format!("{h}:{p}")),
475            // Edge: just a colon
476            Just(":".to_string()),
477            // Edge: colon at start
478            (1..65535u16).prop_map(|p| format!(":{p}")),
479            // Multiple colons (IPv6-like)
480            (1..65535u16).prop_map(|p| format!("::1:{p}")),
481            // Bracketed IPv6
482            (1..65535u16).prop_map(|p| format!("[::1]:{p}")),
483            // Two valid addresses concatenated
484            (1..255u8, 1..255u8, 1..65535u16, 1..65535u16)
485                .prop_map(|(a, b, p1, p2)| format!("{a}.{b}.0.1:{p1},{a}.{b}.0.2:{p2}")),
486            // Host with special chars
487            ("[a-z]{1,5}", 1..65535u16).prop_map(|(h, p)| format!("{h}_name:{p}")),
488            // Whitespace in host or port
489            ("[a-z]{1,5}", 1..65535u16).prop_map(|(h, p)| format!(" {h}:{p} ")),
490            // Double colon before port
491            "[a-z]{1,5}".prop_map(|h| format!("{h}::8080")),
492            // Long host (around Solidity 512 byte boundary)
493            (400..600usize, 1..65535u16).prop_map(|(len, p)| format!("{}:{p}", "a".repeat(len))),
494            // Random bytes (UTF-8 lossy, up to Solidity max of 512)
495            prop::collection::vec(any::<u8>(), 0..512)
496                .prop_map(|v| String::from_utf8_lossy(&v).to_string()),
497        ]
498    }
499
500    #[test]
501    fn solidity_rust_p2p_validation_equivalence() {
502        let rt = tokio::runtime::Runtime::new().unwrap();
503
504        let provider = ProviderBuilder::new().connect_anvil_with_wallet();
505        let contract_addr = rt.block_on(async {
506            let contract = StakeTableV3::deploy(&provider).await.unwrap();
507            *contract.address()
508        });
509        let contract = StakeTableV3::new(contract_addr, &provider);
510
511        let cases = std::env::var("PROPTEST_CASES")
512            .ok()
513            .and_then(|v| v.parse().ok())
514            .unwrap_or(512);
515        let mut runner = TestRunner::new(ProptestConfig {
516            cases,
517            ..ProptestConfig::default()
518        });
519
520        runner
521            .run(&p2p_addr_strategy(), |addr_str| {
522                let (sol_valid, rust_valid) = rt.block_on(async {
523                    let sol_valid = contract
524                        .validateP2pAddr(addr_str.clone())
525                        .call()
526                        .await
527                        .is_ok();
528                    let rust_valid = addr_str.parse::<NetAddr>().is_ok();
529                    (sol_valid, rust_valid)
530                });
531
532                if sol_valid {
533                    prop_assert!(
534                        rust_valid,
535                        "Solidity accepted '{}' but Rust rejected it",
536                        addr_str
537                    );
538                }
539                Ok(())
540            })
541            .unwrap();
542    }
543}