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#[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#[derive(Serialize, Debug, Clone)]
50pub struct SignedNodeHeartbeat {
51 pub version: u8,
52 pub payload: String,
54 pub submitter: AccountPublicKey,
55 pub signature: SignatureJson,
56}
57
58impl SignedNodeHeartbeat {
59 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 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#[derive(Serialize, Deserialize, Debug, Clone)]
96pub struct NodeHeartbeat {
97 pub status: NodeStatus,
98 pub node_timestamp: Timestamp,
99 pub peer_id: PeerId,
100 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#[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 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 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 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}