Skip to main content

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