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::sol_types::{StakeTableV3, *};
21
22// Allows us to implement From on existing Bytes type
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct StateSignatureSol(pub Bytes);
25
26#[derive(Debug, Clone, Copy, Default)]
27pub enum StakeTableContractVersion {
28    V1,
29    V2,
30    #[default]
31    V3,
32}
33
34fn version_from_major(major: u8) -> anyhow::Result<StakeTableContractVersion> {
35    match major {
36        1 => Ok(StakeTableContractVersion::V1),
37        2 => Ok(StakeTableContractVersion::V2),
38        3 => Ok(StakeTableContractVersion::V3),
39        _ => anyhow::bail!("Unsupported stake table contract version: {major}"),
40    }
41}
42
43impl TryFrom<StakeTableV3::getVersionReturn> for StakeTableContractVersion {
44    type Error = anyhow::Error;
45
46    fn try_from(value: StakeTableV3::getVersionReturn) -> anyhow::Result<Self> {
47        version_from_major(value.majorVersion)
48    }
49}
50
51impl From<G2PointSol> for BLSPubKey {
52    fn from(value: G2PointSol) -> Self {
53        let point: G2Affine = value.into();
54        let mut bytes = vec![];
55        point
56            .into_group()
57            .serialize_uncompressed(&mut bytes)
58            .unwrap();
59        Self::deserialize_uncompressed(&bytes[..]).unwrap()
60    }
61}
62
63impl From<BLSPubKey> for G2PointSol {
64    fn from(value: BLSPubKey) -> Self {
65        value.to_affine().into()
66    }
67}
68
69impl From<EdOnBN254PointSol> for StateVerKey {
70    fn from(value: EdOnBN254PointSol) -> Self {
71        let point: ark_ed_on_bn254::EdwardsAffine = value.into();
72        Self::from(point)
73    }
74}
75
76impl From<bls_over_bn254::Signature> for G1PointSol {
77    fn from(sig: bls_over_bn254::Signature) -> Self {
78        sig.sigma.into_affine().into()
79    }
80}
81
82impl From<StateVerKey> for EdOnBN254PointSol {
83    fn from(ver_key: StateVerKey) -> Self {
84        ver_key.to_affine().into()
85    }
86}
87
88impl From<StateSignature> for StateSignatureSol {
89    fn from(sig: StateSignature) -> Self {
90        let mut buf = vec![];
91        sig.serialize_compressed(&mut buf).expect("serialize works");
92        Self(buf.into())
93    }
94}
95
96impl From<StateSignatureSol> for Bytes {
97    fn from(sig_sol: StateSignatureSol) -> Self {
98        sig_sol.0
99    }
100}
101
102pub fn sign_address_bls(bls_key_pair: &BLSKeyPair, address: Address) -> bls_over_bn254::Signature {
103    bls_key_pair.sign(&address.abi_encode(), CS_ID_BLS_BN254)
104}
105
106pub fn sign_address_schnorr(schnorr_key_pair: &StateKeyPair, address: Address) -> StateSignature {
107    let msg = [hash_bytes_to_field(&address.abi_encode()).expect("hash to field works")];
108    schnorr_key_pair.sign(&msg, CS_ID_SCHNORR)
109}
110
111/// Authenticate a Schnorr signature over an Ethereum address
112pub fn authenticate_schnorr_sig(
113    schnorr_vk: &StateVerKey,
114    address: Address,
115    schnorr_sig: &StateSignature,
116) -> Result<(), StakeTableSolError> {
117    let msg = [hash_bytes_to_field(&address.abi_encode()).expect("hash to field works")];
118    schnorr_vk.verify(&msg, schnorr_sig, CS_ID_SCHNORR)?;
119    Ok(())
120}
121
122/// Authenticate a BLS signature over an Ethereum address
123pub fn authenticate_bls_sig(
124    bls_vk: &BLSPubKey,
125    address: Address,
126    bls_sig: &BLSSignature,
127) -> Result<(), StakeTableSolError> {
128    let msg = address.abi_encode();
129    if !bls_vk.validate(bls_sig, &msg) {
130        return Err(StakeTableSolError::InvalidBlsSignature);
131    }
132    Ok(())
133}
134
135fn authenticate_stake_table_validator_event(
136    account: Address,
137    bls_vk: G2PointSol,
138    schnorr_vk: EdOnBN254PointSol,
139    bls_sig: G1PointSol,
140    schnorr_sig: &[u8],
141) -> Result<(), StakeTableSolError> {
142    // TODO(alex): simplify this once jellyfish has `VerKey::from_affine()`
143    let bls_vk = {
144        let bls_vk_inner: ark_bn254::G2Affine = bls_vk.into();
145        let bls_vk_inner = bls_vk_inner.into_group();
146
147        // the two unwrap are safe since it's BLSPubKey is just a wrapper around G2Projective
148        let mut ser_bytes: Vec<u8> = Vec::new();
149        bls_vk_inner.serialize_uncompressed(&mut ser_bytes).unwrap();
150        BLSPubKey::deserialize_uncompressed(&ser_bytes[..]).unwrap()
151    };
152    let bls_sig_jellyfish = {
153        let sigma_affine: ark_bn254::G1Affine = bls_sig.into();
154        BLSSignature {
155            sigma: sigma_affine.into_group(),
156        }
157    };
158    authenticate_bls_sig(&bls_vk, account, &bls_sig_jellyfish)?;
159
160    let schnorr_vk: StateVerKey = schnorr_vk.into();
161    let schnorr_sig_jellyfish =
162        schnorr::Signature::<EdwardsConfig>::deserialize_compressed(schnorr_sig)?;
163    authenticate_schnorr_sig(&schnorr_vk, account, &schnorr_sig_jellyfish)?;
164    Ok(())
165}
166
167/// Errors encountered when processing stake table events
168#[derive(Debug, thiserror::Error)]
169pub enum StakeTableSolError {
170    #[error("Failed to deserialize Schnorr signature")]
171    SchnorrSigDeserializationError(#[from] ark_serialize::SerializationError),
172    #[error("BLS signature invalid")]
173    InvalidBlsSignature,
174    #[error("Schnorr signature invalid")]
175    InvalidSchnorrSignature(#[from] jf_signature::SignatureError),
176}
177
178impl StakeTableV3::ValidatorRegisteredV3 {
179    /// Verify the BLS and Schnorr signatures in the event
180    pub fn authenticate(&self) -> Result<(), StakeTableSolError> {
181        authenticate_stake_table_validator_event(
182            self.account,
183            self.blsVK,
184            self.schnorrVK,
185            self.blsSig.into(),
186            &self.schnorrSig,
187        )
188    }
189}
190
191impl StakeTableV3::ValidatorRegisteredV2 {
192    pub fn authenticate(&self) -> Result<(), StakeTableSolError> {
193        authenticate_stake_table_validator_event(
194            self.account,
195            self.blsVK,
196            self.schnorrVK,
197            self.blsSig.into(),
198            &self.schnorrSig,
199        )
200    }
201}
202
203impl StakeTableV3::ConsensusKeysUpdatedV2 {
204    pub fn authenticate(&self) -> Result<(), StakeTableSolError> {
205        authenticate_stake_table_validator_event(
206            self.account,
207            self.blsVK,
208            self.schnorrVK,
209            self.blsSig.into(),
210            &self.schnorrSig,
211        )
212    }
213}
214
215#[cfg(test)]
216mod test {
217    use alloy::primitives::Address;
218    use hotshot_types::{
219        light_client::StateKeyPair,
220        signature_key::{BLSKeyPair, BLSPrivKey, BLSPubKey},
221    };
222
223    use super::{StateSignatureSol, sign_address_bls, sign_address_schnorr};
224    use crate::sol_types::{
225        G1PointSol, G2PointSol,
226        StakeTableV3::{ConsensusKeysUpdatedV2, ValidatorRegisteredV2},
227    };
228
229    fn check_round_trip(pk: BLSPubKey) {
230        let g2 = G2PointSol::from(pk);
231        let pk2 = BLSPubKey::from(g2);
232        assert_eq!(pk2, pk, "Failed to roundtrip G2PointSol to BLSPubKey: {pk}");
233    }
234
235    #[test]
236    fn test_bls_g2_point_roundtrip() {
237        let mut rng = rand::thread_rng();
238        for _ in 0..100 {
239            let pk = (&BLSPrivKey::generate(&mut rng)).into();
240            check_round_trip(pk);
241        }
242    }
243
244    #[test]
245    fn test_bls_g2_point_alloy_migration_regression() {
246        // This pubkey fails the roundtrip if "serialize_{un,}compressed" are mixed
247        let s = "BLS_VER_KEY~JlRLUrn0T_MltAJXaaojwk_CnCgd0tyPny_IGdseMBLBPv9nWabIPAaS-aHmn0ARu5YZHJ7mfmGQ-alW42tkJM663Lse-Is80fyA1jnRxPsHcJDnO05oW1M1SC5LeE8sXITbuhmtG2JdTAgmLqWOxbMRmVIqS1AQXqvGGXdo5qpd";
248        let pk: BLSPubKey = s.parse().unwrap();
249        check_round_trip(pk);
250    }
251
252    #[test]
253    fn test_validator_registered_event_authentication() {
254        for _ in 0..10 {
255            let bls_key_pair = BLSKeyPair::generate(&mut rand::thread_rng());
256            let schnorr_key_pair = StateKeyPair::generate();
257            let address = Address::random();
258
259            let bls_sig = sign_address_bls(&bls_key_pair, address);
260            let schnorr_sig = sign_address_schnorr(&schnorr_key_pair, address);
261
262            let valid_event = ValidatorRegisteredV2 {
263                account: address,
264                blsVK: bls_key_pair.ver_key().into(),
265                schnorrVK: schnorr_key_pair.ver_key().into(),
266                commission: 1000, // 10%
267                blsSig: G1PointSol::from(bls_sig.clone()).into(),
268                schnorrSig: StateSignatureSol::from(schnorr_sig.clone()).into(),
269                metadataUri: "dummy-meta".to_string(),
270            };
271            assert!(valid_event.authenticate().is_ok());
272
273            let wrong_bls_sig =
274                sign_address_bls(&BLSKeyPair::generate(&mut rand::thread_rng()), address);
275            let mut bad_bls_event = valid_event.clone();
276            bad_bls_event.blsSig = G1PointSol::from(wrong_bls_sig).into();
277            assert!(bad_bls_event.authenticate().is_err());
278
279            let wrong_schnorr_sig = sign_address_schnorr(&StateKeyPair::generate(), address);
280            let mut bad_schnorr_event = valid_event.clone();
281            bad_schnorr_event.schnorrSig = StateSignatureSol::from(wrong_schnorr_sig).into();
282            assert!(bad_schnorr_event.authenticate().is_err());
283        }
284    }
285
286    #[test]
287    fn test_consensus_keys_updated_event_authentication() {
288        for _ in 0..10 {
289            let bls_key_pair = BLSKeyPair::generate(&mut rand::thread_rng());
290            let schnorr_key_pair = StateKeyPair::generate();
291            let address = Address::random();
292
293            let bls_sig = sign_address_bls(&bls_key_pair, address);
294            let schnorr_sig = sign_address_schnorr(&schnorr_key_pair, address);
295
296            let valid_event = ConsensusKeysUpdatedV2 {
297                account: address,
298                blsVK: bls_key_pair.ver_key().into(),
299                schnorrVK: schnorr_key_pair.ver_key().into(),
300                blsSig: G1PointSol::from(bls_sig.clone()).into(),
301                schnorrSig: StateSignatureSol::from(schnorr_sig.clone()).into(),
302            };
303            assert!(valid_event.authenticate().is_ok());
304
305            let wrong_bls_sig =
306                sign_address_bls(&BLSKeyPair::generate(&mut rand::thread_rng()), address);
307            let mut bad_bls_event = valid_event.clone();
308            bad_bls_event.blsSig = G1PointSol::from(wrong_bls_sig).into();
309            assert!(bad_bls_event.authenticate().is_err());
310
311            let wrong_schnorr_sig = sign_address_schnorr(&StateKeyPair::generate(), address);
312            let mut bad_schnorr_event = valid_event.clone();
313            bad_schnorr_event.schnorrSig = StateSignatureSol::from(wrong_schnorr_sig).into();
314            assert!(bad_schnorr_event.authenticate().is_err());
315        }
316    }
317}
318
319// Solidity `validateP2pAddr` and Rust `NetAddr::from_str` both parse `host:port` strings.
320// If Solidity accepts an address that Rust rejects, the validator's p2p address is silently
321// dropped and they become unreachable for cliquenet. This proptest deploys StakeTableV3
322// on anvil and fuzzes the property: Solidity accepts => Rust accepts.
323#[cfg(test)]
324mod proptest_p2p_addr {
325    use alloy::providers::ProviderBuilder;
326    use hotshot_types::addr::NetAddr;
327    use proptest::{
328        prelude::*,
329        test_runner::{Config as ProptestConfig, TestRunner},
330    };
331
332    use crate::sol_types::StakeTableV3;
333
334    fn p2p_addr_strategy() -> impl Strategy<Value = String> {
335        prop_oneof![
336            // Valid IPv4:port
337            (1..255u8, 1..255u8, 1..255u8, 1..255u8, 1..65535u16)
338                .prop_map(|(a, b, c, d, p)| format!("{a}.{b}.{c}.{d}:{p}")),
339            // Valid hostname:port
340            ("[a-z][a-z0-9.-]{0,20}", 1..65535u16).prop_map(|(h, p)| format!("{h}:{p}")),
341            // Edge: missing port
342            "[a-z][a-z0-9.-]{0,20}".prop_map(|h| h.to_string()),
343            // Edge: empty string
344            Just("".to_string()),
345            // Edge: port 0
346            "[a-z]{1,10}".prop_map(|h| format!("{h}:0")),
347            // Edge: leading zero port
348            "[a-z]{1,10}".prop_map(|h| format!("{h}:08080")),
349            // Edge: port too large
350            "[a-z]{1,10}".prop_map(|h| format!("{h}:99999")),
351            // Edge: non-digit port
352            ("[a-z]{1,10}", "[a-z]{1,5}").prop_map(|(h, p)| format!("{h}:{p}")),
353            // Edge: just a colon
354            Just(":".to_string()),
355            // Edge: colon at start
356            (1..65535u16).prop_map(|p| format!(":{p}")),
357            // Multiple colons (IPv6-like)
358            (1..65535u16).prop_map(|p| format!("::1:{p}")),
359            // Bracketed IPv6
360            (1..65535u16).prop_map(|p| format!("[::1]:{p}")),
361            // Two valid addresses concatenated
362            (1..255u8, 1..255u8, 1..65535u16, 1..65535u16)
363                .prop_map(|(a, b, p1, p2)| format!("{a}.{b}.0.1:{p1},{a}.{b}.0.2:{p2}")),
364            // Host with special chars
365            ("[a-z]{1,5}", 1..65535u16).prop_map(|(h, p)| format!("{h}_name:{p}")),
366            // Whitespace in host or port
367            ("[a-z]{1,5}", 1..65535u16).prop_map(|(h, p)| format!(" {h}:{p} ")),
368            // Double colon before port
369            "[a-z]{1,5}".prop_map(|h| format!("{h}::8080")),
370            // Long host (around Solidity 512 byte boundary)
371            (400..600usize, 1..65535u16).prop_map(|(len, p)| format!("{}:{p}", "a".repeat(len))),
372            // Random bytes (UTF-8 lossy, up to Solidity max of 512)
373            prop::collection::vec(any::<u8>(), 0..512)
374                .prop_map(|v| String::from_utf8_lossy(&v).to_string()),
375        ]
376    }
377
378    #[test]
379    fn solidity_rust_p2p_validation_equivalence() {
380        let rt = tokio::runtime::Runtime::new().unwrap();
381
382        let provider = ProviderBuilder::new().connect_anvil_with_wallet();
383        let contract_addr = rt.block_on(async {
384            let contract = StakeTableV3::deploy(&provider).await.unwrap();
385            *contract.address()
386        });
387        let contract = StakeTableV3::new(contract_addr, &provider);
388
389        let cases = std::env::var("PROPTEST_CASES")
390            .ok()
391            .and_then(|v| v.parse().ok())
392            .unwrap_or(512);
393        let mut runner = TestRunner::new(ProptestConfig {
394            cases,
395            ..ProptestConfig::default()
396        });
397
398        runner
399            .run(&p2p_addr_strategy(), |addr_str| {
400                let (sol_valid, rust_valid) = rt.block_on(async {
401                    let sol_valid = contract
402                        .validateP2pAddr(addr_str.clone())
403                        .call()
404                        .await
405                        .is_ok();
406                    let rust_valid = addr_str.parse::<NetAddr>().is_ok();
407                    (sol_valid, rust_valid)
408                });
409
410                if sol_valid {
411                    prop_assert!(
412                        rust_valid,
413                        "Solidity accepted '{}' but Rust rejected it",
414                        addr_str
415                    );
416                }
417                Ok(())
418            })
419            .unwrap();
420    }
421}