node/rpc/
heartbeat.rs

1use ledger::FpExt;
2use mina_p2p_messages::bigint::BigInt;
3use mina_signer::Signature;
4use redux::Timestamp;
5use serde::{Deserialize, Serialize};
6
7use super::{
8    RpcNodeStatus, RpcNodeStatusSnarkPool, RpcNodeStatusTransactionPool,
9    RpcNodeStatusTransitionFrontier,
10};
11use crate::{p2p::PeerId, stats::block_producer::BlockProductionAttempt};
12use openmina_node_account::{AccountPublicKey, AccountSecretKey};
13
14/// Matches the representation used by o1js where each field is a string
15/// containing a decimal representation of the field.
16#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
17pub struct SignatureJson {
18    pub field: String,
19    pub scalar: String,
20}
21
22impl From<Signature> for SignatureJson {
23    fn from(sig: Signature) -> Self {
24        Self {
25            field: sig.rx.to_decimal(),
26            scalar: sig.s.to_decimal(),
27        }
28    }
29}
30
31impl TryInto<Signature> for SignatureJson {
32    type Error = String;
33
34    fn try_into(self) -> Result<Signature, Self::Error> {
35        let rx = BigInt::from_decimal(&self.field)
36            .map_err(|_| "Failed to parse decimals as BigInt")?
37            .try_into()
38            .map_err(|_| "Failed to convert rx BigInt to field element")?;
39        let s = BigInt::from_decimal(&self.scalar)
40            .map_err(|_| "Failed to parse decimals as BigInt")?
41            .try_into()
42            .map_err(|_| "Failed to convert rx BigInt to field element")?;
43
44        Ok(Signature::new(rx, s))
45    }
46}
47
48/// A signed heartbeat message from a node
49#[derive(Serialize, Debug, Clone)]
50pub struct SignedNodeHeartbeat {
51    pub version: u8,
52    /// base64 encoded json of the payload
53    pub payload: String,
54    pub submitter: AccountPublicKey,
55    pub signature: SignatureJson,
56}
57
58impl SignedNodeHeartbeat {
59    /// Verifies that the signature is valid for this heartbeat
60    pub fn verify_signature(&self) -> bool {
61        use blake2::digest::{Update, VariableOutput};
62        use mina_signer::{CompressedPubKey, PubKey, Signer};
63
64        let signature = match self.signature.clone().try_into() {
65            Ok(sig) => sig,
66            Err(_) => return false,
67        };
68
69        let pk: CompressedPubKey = match self.submitter.clone().try_into() {
70            Ok(pk) => pk,
71            Err(_) => return false,
72        };
73
74        let pk = match PubKey::from_address(&pk.into_address()) {
75            Ok(pk) => pk,
76            Err(_) => return false,
77        };
78
79        // Calculate digest from payload
80        let mut hasher = blake2::Blake2bVar::new(32).expect("Invalid Blake2bVar output size");
81        let mut blake2_hash = [0u8; 32];
82        hasher.update(self.payload.as_bytes());
83        hasher.finalize_variable(&mut blake2_hash).unwrap();
84
85        let digest = NodeHeartbeatPayloadDigest(blake2_hash);
86        let mut signer = mina_signer::create_legacy::<NodeHeartbeatPayloadDigest>(
87            mina_signer::NetworkId::TESTNET,
88        );
89
90        signer.verify(&signature, &pk, &digest)
91    }
92}
93
94/// Node heartbeat
95#[derive(Serialize, Deserialize, Debug, Clone)]
96pub struct NodeHeartbeat {
97    pub status: NodeStatus,
98    pub node_timestamp: Timestamp,
99    pub peer_id: PeerId,
100    // binprot+base64 encoded block header
101    pub last_produced_block_info: Option<ProducedBlockInfo>,
102}
103
104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
105pub struct ProducedBlockInfo {
106    pub height: u32,
107    pub global_slot: u32,
108    pub hash: String,
109    pub base64_encoded_header: String,
110}
111
112#[derive(Serialize, Deserialize, Debug, Clone)]
113pub struct NodeStatus {
114    pub chain_id: Option<String>,
115    pub transition_frontier: RpcNodeStatusTransitionFrontier,
116    pub peers_count: u32,
117    pub snark_pool: RpcNodeStatusSnarkPool,
118    pub transaction_pool: RpcNodeStatusTransactionPool,
119    pub current_block_production_attempt: Option<BlockProductionAttempt>,
120}
121
122impl From<RpcNodeStatus> for NodeStatus {
123    fn from(status: RpcNodeStatus) -> Self {
124        Self {
125            chain_id: status.chain_id,
126            transition_frontier: status.transition_frontier,
127            peers_count: status.peers.len() as u32,
128            snark_pool: status.snark_pool,
129            transaction_pool: status.transaction_pool,
130            current_block_production_attempt: status.current_block_production_attempt,
131        }
132    }
133}
134
135/// Blake2b hash of the encoded heartbeat payload
136#[derive(Clone, Debug)]
137pub struct NodeHeartbeatPayloadDigest([u8; 32]);
138
139impl mina_hasher::Hashable for NodeHeartbeatPayloadDigest {
140    type D = mina_signer::NetworkId;
141
142    fn to_roinput(&self) -> mina_hasher::ROInput {
143        let mut hex = [0u8; 64];
144        hex::encode_to_slice(self.0, &mut hex).unwrap();
145
146        // Bits must be reversed to match the JS implementation
147        for b in hex.iter_mut() {
148            *b = b.reverse_bits();
149        }
150
151        mina_hasher::ROInput::new().append_bytes(&hex)
152    }
153
154    fn domain_string(network_id: Self::D) -> Option<String> {
155        match network_id {
156            Self::D::MAINNET => openmina_core::network::mainnet::SIGNATURE_PREFIX,
157            Self::D::TESTNET => openmina_core::network::devnet::SIGNATURE_PREFIX,
158        }
159        .to_string()
160        .into()
161    }
162}
163
164impl NodeHeartbeat {
165    const CURRENT_VERSION: u8 = 1;
166
167    /// Creates base64 encoded payload and its Blake2b digest
168    fn payload_and_digest(&self) -> (String, NodeHeartbeatPayloadDigest) {
169        use base64::{engine::general_purpose::URL_SAFE, Engine as _};
170        use blake2::{
171            digest::{Update, VariableOutput},
172            Blake2bVar,
173        };
174
175        let payload = serde_json::to_string(self).unwrap();
176        let encoded_payload = URL_SAFE.encode(&payload);
177
178        let mut hasher = Blake2bVar::new(32).expect("Invalid Blake2bVar output size");
179        let mut blake2_hash = [0u8; 32];
180
181        hasher.update(encoded_payload.as_bytes());
182        hasher.finalize_variable(&mut blake2_hash).unwrap();
183
184        (encoded_payload, NodeHeartbeatPayloadDigest(blake2_hash))
185    }
186
187    /// Signs the heartbeat using the provided secret key
188    pub fn sign(&self, secret_key: &AccountSecretKey) -> SignedNodeHeartbeat {
189        let (payload, digest) = self.payload_and_digest();
190        let submitter = secret_key.public_key();
191
192        let signature = {
193            use mina_signer::{Keypair, Signer};
194            let mut signer = mina_signer::create_legacy::<NodeHeartbeatPayloadDigest>(
195                mina_signer::NetworkId::TESTNET,
196            );
197            let kp = Keypair::from(secret_key.clone());
198
199            let signature = signer.sign(&kp, &digest);
200            signature.into()
201        };
202
203        SignedNodeHeartbeat {
204            version: Self::CURRENT_VERSION,
205            payload,
206            submitter,
207            signature,
208        }
209    }
210}
211
212#[cfg(test)]
213pub(crate) mod tests {
214
215    use crate::rpc::{
216        RpcNodeStatusSnarkPool, RpcNodeStatusTransactionPool, RpcNodeStatusTransitionFrontier,
217        RpcNodeStatusTransitionFrontierSync,
218    };
219
220    use super::*;
221    use redux::Timestamp;
222
223    #[test]
224    fn test_heartbeat_signing() {
225        let heartbeat = create_test_heartbeat();
226        let secret_key = AccountSecretKey::deterministic(0);
227        let signed = heartbeat.sign(&secret_key);
228
229        println!("Private key: {}", secret_key);
230        println!("Public key: {}", secret_key.public_key());
231        println!("Payload: {}", signed.payload);
232        println!("Signature: {:?}", signed.signature);
233
234        assert_eq!(&signed.payload, "eyJzdGF0dXMiOnsiY2hhaW5faWQiOm51bGwsInRyYW5zaXRpb25fZnJvbnRpZXIiOnsiYmVzdF90aXAiOm51bGwsInN5bmMiOnsidGltZSI6bnVsbCwic3RhdHVzIjoiU3luY2VkIiwicGhhc2UiOiJSdW5uaW5nIiwidGFyZ2V0IjpudWxsfX0sInBlZXJzX2NvdW50IjoxMCwic25hcmtfcG9vbCI6eyJ0b3RhbF9qb2JzIjowLCJzbmFya3MiOjB9LCJ0cmFuc2FjdGlvbl9wb29sIjp7InRyYW5zYWN0aW9ucyI6MCwidHJhbnNhY3Rpb25zX2Zvcl9wcm9wYWdhdGlvbiI6MCwidHJhbnNhY3Rpb25fY2FuZGlkYXRlcyI6MH0sImN1cnJlbnRfYmxvY2tfcHJvZHVjdGlvbl9hdHRlbXB0IjpudWxsfSwibm9kZV90aW1lc3RhbXAiOjAsInBlZXJfaWQiOiIyYkVnQnJQVHpMOHdvdjJENEt6MzRXVkxDeFI0dUNhcnNCbUhZWFdLUUE1d3ZCUXpkOUgiLCJsYXN0X3Byb2R1Y2VkX2Jsb2NrIjpudWxsfQ==");
235        assert_eq!(
236            &signed.signature.field,
237            "9079786479394174309544438559429014966597223472549276883268325308999016287311"
238        );
239        assert_eq!(
240            &signed.signature.scalar,
241            "23390017492020277578751321763314031415515010579676039556553777274088622112706"
242        );
243        assert!(signed.verify_signature());
244    }
245
246    #[test]
247    fn test_heartbeat_signature_deterministic() {
248        let heartbeat = create_test_heartbeat();
249        let secret_key = AccountSecretKey::deterministic(0);
250
251        let signed1 = heartbeat.sign(&secret_key);
252        let signed2 = heartbeat.sign(&secret_key);
253
254        assert_eq!(signed1.payload, signed2.payload);
255        assert_eq!(signed1.signature, signed2.signature);
256    }
257
258    #[test]
259    fn test_heartbeat_different_keys_different_sigs() {
260        let heartbeat = create_test_heartbeat();
261        let sk1 = AccountSecretKey::deterministic(0);
262        let sk2 = AccountSecretKey::deterministic(1);
263
264        let signed1 = heartbeat.sign(&sk1);
265        let signed2 = heartbeat.sign(&sk2);
266
267        assert_eq!(signed1.payload, signed2.payload);
268        assert_ne!(signed1.signature, signed2.signature);
269        assert_ne!(signed1.submitter, signed2.submitter);
270    }
271
272    fn create_test_heartbeat() -> NodeHeartbeat {
273        NodeHeartbeat {
274            status: NodeStatus {
275                chain_id: None,
276                transition_frontier: RpcNodeStatusTransitionFrontier {
277                    best_tip: None,
278                    sync: RpcNodeStatusTransitionFrontierSync {
279                        time: None,
280                        status: "Synced".to_string(),
281                        phase: "Running".to_string(),
282                        target: None,
283                    },
284                },
285                peers_count: 10,
286                snark_pool: RpcNodeStatusSnarkPool::default(),
287                transaction_pool: RpcNodeStatusTransactionPool::default(),
288                current_block_production_attempt: None,
289            },
290            node_timestamp: Timestamp::ZERO,
291            peer_id: "2bEgBrPTzL8wov2D4Kz34WVLCxR4uCarsBmHYXWKQA5wvBQzd9H"
292                .parse()
293                .unwrap(),
294            last_produced_block_info: None,
295        }
296    }
297}