guts_node/
consensus_api.rs

1//! Consensus API endpoints.
2//!
3//! This module provides HTTP endpoints for interacting with the consensus layer:
4//!
5//! - **Status**: Get consensus engine state, current view, leader info
6//! - **Transactions**: Submit transactions and query pending transactions
7//! - **Blocks**: Query finalized blocks by height or hash
8//! - **Validators**: Get validator set information
9//!
10//! ## Endpoint Overview
11//!
12//! | Method | Path | Description |
13//! |--------|------|-------------|
14//! | GET | `/api/consensus/status` | Consensus engine status |
15//! | GET | `/api/consensus/blocks` | List recent finalized blocks |
16//! | GET | `/api/consensus/blocks/{height}` | Get block by height |
17//! | GET | `/api/consensus/validators` | Current validator set |
18//! | GET | `/api/consensus/mempool` | Mempool statistics |
19//! | POST | `/api/consensus/transactions` | Submit a transaction |
20
21use axum::{
22    extract::{Path, State},
23    http::StatusCode,
24    response::IntoResponse,
25    routing::{get, post},
26    Json, Router,
27};
28use guts_consensus::{SerializablePublicKey, SerializableSignature, Transaction};
29use serde::{Deserialize, Serialize};
30
31use crate::api::AppState;
32
33/// Consensus status response.
34#[derive(Serialize)]
35pub struct ConsensusStatusResponse {
36    /// Whether consensus is enabled.
37    pub enabled: bool,
38    /// Current engine state.
39    pub state: String,
40    /// Current view number.
41    pub view: u64,
42    /// Latest finalized block height.
43    pub finalized_height: u64,
44    /// Current leader public key (hex).
45    pub current_leader: Option<String>,
46    /// Whether this node is the current leader.
47    pub is_leader: bool,
48    /// Number of pending transactions in mempool.
49    pub pending_transactions: usize,
50}
51
52/// Block info response.
53#[derive(Serialize)]
54pub struct BlockInfoResponse {
55    /// Block height.
56    pub height: u64,
57    /// Block ID (hash) in hex.
58    pub block_id: String,
59    /// Parent block ID in hex.
60    pub parent_id: String,
61    /// Block producer public key in hex.
62    pub producer: String,
63    /// Block timestamp (unix milliseconds).
64    pub timestamp: u64,
65    /// Number of transactions in block.
66    pub tx_count: usize,
67    /// Transaction root hash in hex.
68    pub tx_root: String,
69    /// State root hash in hex.
70    pub state_root: String,
71    /// View number when finalized.
72    pub view: u64,
73    /// Number of validator signatures.
74    pub signature_count: usize,
75}
76
77/// Validator info response.
78#[derive(Serialize)]
79pub struct ValidatorInfoResponse {
80    /// Validator name.
81    pub name: String,
82    /// Public key in hex.
83    pub pubkey: String,
84    /// Voting weight.
85    pub weight: u64,
86    /// Network address.
87    pub addr: String,
88    /// Whether validator is active.
89    pub active: bool,
90}
91
92/// Validator set response.
93#[derive(Serialize)]
94pub struct ValidatorSetResponse {
95    /// Current epoch.
96    pub epoch: u64,
97    /// Total weight.
98    pub total_weight: u64,
99    /// Quorum weight required.
100    pub quorum_weight: u64,
101    /// Number of validators.
102    pub validator_count: usize,
103    /// List of validators.
104    pub validators: Vec<ValidatorInfoResponse>,
105}
106
107/// Mempool statistics response.
108#[derive(Serialize)]
109pub struct MempoolStatsResponse {
110    /// Number of pending transactions.
111    pub transaction_count: usize,
112    /// Oldest transaction age in seconds.
113    pub oldest_age_secs: f64,
114    /// Average number of times transactions have been proposed.
115    pub average_propose_count: f64,
116}
117
118/// Transaction submission request.
119#[derive(Deserialize)]
120#[serde(tag = "type")]
121pub enum SubmitTransactionRequest {
122    /// Create a new repository.
123    CreateRepository {
124        owner: String,
125        name: String,
126        description: String,
127        default_branch: String,
128        visibility: String,
129        creator_pubkey: String,
130        signature: String,
131    },
132    /// Create an issue.
133    CreateIssue {
134        repo_key: String,
135        title: String,
136        body: String,
137        author: String,
138        creator_pubkey: String,
139        signature: String,
140    },
141    /// Create a pull request.
142    CreatePullRequest {
143        repo_key: String,
144        title: String,
145        description: String,
146        author: String,
147        source_branch: String,
148        target_branch: String,
149        source_commit: String,
150        target_commit: String,
151        creator_pubkey: String,
152        signature: String,
153    },
154}
155
156/// Transaction submission response.
157#[derive(Serialize)]
158pub struct SubmitTransactionResponse {
159    /// Transaction ID (hash) in hex.
160    pub transaction_id: String,
161    /// Whether the transaction was accepted.
162    pub accepted: bool,
163    /// Optional error message.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub error: Option<String>,
166}
167
168/// Creates the consensus API router.
169pub fn consensus_routes() -> Router<AppState> {
170    Router::new()
171        .route("/api/consensus/status", get(get_consensus_status))
172        .route("/api/consensus/blocks", get(list_recent_blocks))
173        .route("/api/consensus/blocks/{height}", get(get_block_by_height))
174        .route("/api/consensus/validators", get(get_validators))
175        .route("/api/consensus/mempool", get(get_mempool_stats))
176        .route("/api/consensus/transactions", post(submit_transaction))
177}
178
179/// Get consensus engine status.
180async fn get_consensus_status(State(state): State<AppState>) -> impl IntoResponse {
181    if let Some(ref consensus) = state.consensus {
182        let current_leader = consensus.current_leader().map(|pk| pk.to_hex());
183        let pending_transactions = state.mempool.as_ref().map(|m| m.len()).unwrap_or(0);
184
185        Json(ConsensusStatusResponse {
186            enabled: true,
187            state: format!("{:?}", consensus.state()),
188            view: consensus.view(),
189            finalized_height: consensus.finalized_height(),
190            current_leader,
191            is_leader: consensus.is_leader(),
192            pending_transactions,
193        })
194    } else {
195        Json(ConsensusStatusResponse {
196            enabled: false,
197            state: "Disabled".to_string(),
198            view: 0,
199            finalized_height: 0,
200            current_leader: None,
201            is_leader: false,
202            pending_transactions: 0,
203        })
204    }
205}
206
207/// List recent finalized blocks.
208async fn list_recent_blocks(State(state): State<AppState>) -> impl IntoResponse {
209    if let Some(ref consensus) = state.consensus {
210        let finalized_height = consensus.finalized_height();
211        let start_height = finalized_height.saturating_sub(9); // Last 10 blocks
212
213        let mut blocks = Vec::new();
214        for height in start_height..=finalized_height {
215            if let Some(block) = consensus.get_block(height) {
216                blocks.push(BlockInfoResponse {
217                    height: block.height(),
218                    block_id: block.id().to_hex(),
219                    parent_id: block.block.parent().to_hex(),
220                    producer: block.block.header.producer.to_hex(),
221                    timestamp: block.block.timestamp(),
222                    tx_count: block.block.tx_count(),
223                    tx_root: hex::encode(block.block.header.tx_root),
224                    state_root: hex::encode(block.block.header.state_root),
225                    view: block.view,
226                    signature_count: block.signature_count(),
227                });
228            }
229        }
230
231        (StatusCode::OK, Json(blocks))
232    } else {
233        (StatusCode::OK, Json(Vec::<BlockInfoResponse>::new()))
234    }
235}
236
237/// Get a block by height.
238async fn get_block_by_height(
239    State(state): State<AppState>,
240    Path(height): Path<u64>,
241) -> Result<impl IntoResponse, (StatusCode, String)> {
242    if let Some(ref consensus) = state.consensus {
243        if let Some(block) = consensus.get_block(height) {
244            Ok(Json(BlockInfoResponse {
245                height: block.height(),
246                block_id: block.id().to_hex(),
247                parent_id: block.block.parent().to_hex(),
248                producer: block.block.header.producer.to_hex(),
249                timestamp: block.block.timestamp(),
250                tx_count: block.block.tx_count(),
251                tx_root: hex::encode(block.block.header.tx_root),
252                state_root: hex::encode(block.block.header.state_root),
253                view: block.view,
254                signature_count: block.signature_count(),
255            }))
256        } else {
257            Err((
258                StatusCode::NOT_FOUND,
259                format!("Block at height {} not found", height),
260            ))
261        }
262    } else {
263        Err((
264            StatusCode::SERVICE_UNAVAILABLE,
265            "Consensus is not enabled".to_string(),
266        ))
267    }
268}
269
270/// Get current validator set.
271async fn get_validators(State(state): State<AppState>) -> impl IntoResponse {
272    if let Some(ref consensus) = state.consensus {
273        let validators_lock = consensus.validators();
274        let validators = validators_lock.read();
275
276        let validator_list: Vec<ValidatorInfoResponse> = validators
277            .validators()
278            .iter()
279            .map(|v| ValidatorInfoResponse {
280                name: v.name.clone(),
281                pubkey: v.pubkey.to_hex(),
282                weight: v.weight,
283                addr: v.addr.to_string(),
284                active: v.active,
285            })
286            .collect();
287
288        Json(ValidatorSetResponse {
289            epoch: validators.epoch(),
290            total_weight: validators.total_weight(),
291            quorum_weight: validators.quorum_weight(),
292            validator_count: validators.len(),
293            validators: validator_list,
294        })
295    } else {
296        Json(ValidatorSetResponse {
297            epoch: 0,
298            total_weight: 0,
299            quorum_weight: 0,
300            validator_count: 0,
301            validators: Vec::new(),
302        })
303    }
304}
305
306/// Get mempool statistics.
307async fn get_mempool_stats(State(state): State<AppState>) -> impl IntoResponse {
308    if let Some(ref mempool) = state.mempool {
309        let stats = mempool.stats();
310        Json(MempoolStatsResponse {
311            transaction_count: stats.transaction_count,
312            oldest_age_secs: stats.oldest_transaction_age.as_secs_f64(),
313            average_propose_count: stats.average_propose_count,
314        })
315    } else {
316        Json(MempoolStatsResponse {
317            transaction_count: 0,
318            oldest_age_secs: 0.0,
319            average_propose_count: 0.0,
320        })
321    }
322}
323
324/// Submit a transaction to the mempool.
325async fn submit_transaction(
326    State(state): State<AppState>,
327    Json(req): Json<SubmitTransactionRequest>,
328) -> Result<impl IntoResponse, (StatusCode, Json<SubmitTransactionResponse>)> {
329    // Convert request to transaction
330    let transaction = match req {
331        SubmitTransactionRequest::CreateRepository {
332            owner,
333            name,
334            description,
335            default_branch,
336            visibility,
337            creator_pubkey,
338            signature,
339        } => Transaction::CreateRepository {
340            owner,
341            name,
342            description,
343            default_branch,
344            visibility,
345            creator: SerializablePublicKey::from_hex(&creator_pubkey),
346            signature: SerializableSignature::from_hex(&signature),
347        },
348        SubmitTransactionRequest::CreateIssue {
349            repo_key,
350            title,
351            body,
352            author,
353            creator_pubkey,
354            signature,
355        } => Transaction::CreateIssue {
356            repo_key,
357            title,
358            description: body,
359            author,
360            signer: SerializablePublicKey::from_hex(&creator_pubkey),
361            signature: SerializableSignature::from_hex(&signature),
362        },
363        SubmitTransactionRequest::CreatePullRequest {
364            repo_key,
365            title,
366            description,
367            author,
368            source_branch,
369            target_branch,
370            source_commit,
371            target_commit,
372            creator_pubkey,
373            signature,
374        } => {
375            // Parse source_commit and target_commit as ObjectId (20-byte hex)
376            let source_oid = guts_storage::ObjectId::from_hex(&source_commit).map_err(|_| {
377                (
378                    StatusCode::BAD_REQUEST,
379                    Json(SubmitTransactionResponse {
380                        transaction_id: String::new(),
381                        accepted: false,
382                        error: Some("Invalid source_commit hex".to_string()),
383                    }),
384                )
385            })?;
386            let target_oid = guts_storage::ObjectId::from_hex(&target_commit).map_err(|_| {
387                (
388                    StatusCode::BAD_REQUEST,
389                    Json(SubmitTransactionResponse {
390                        transaction_id: String::new(),
391                        accepted: false,
392                        error: Some("Invalid target_commit hex".to_string()),
393                    }),
394                )
395            })?;
396
397            Transaction::CreatePullRequest {
398                repo_key,
399                title,
400                description,
401                author,
402                source_branch,
403                target_branch,
404                source_commit: source_oid,
405                target_commit: target_oid,
406                signer: SerializablePublicKey::from_hex(&creator_pubkey),
407                signature: SerializableSignature::from_hex(&signature),
408            }
409        }
410    };
411
412    // Submit to consensus or mempool
413    if let Some(ref consensus) = state.consensus {
414        match consensus.submit_transaction(transaction.clone()).await {
415            Ok(id) => Ok((
416                StatusCode::ACCEPTED,
417                Json(SubmitTransactionResponse {
418                    transaction_id: id.to_hex(),
419                    accepted: true,
420                    error: None,
421                }),
422            )),
423            Err(e) => Err((
424                StatusCode::BAD_REQUEST,
425                Json(SubmitTransactionResponse {
426                    transaction_id: String::new(),
427                    accepted: false,
428                    error: Some(e.to_string()),
429                }),
430            )),
431        }
432    } else if let Some(ref mempool) = state.mempool {
433        match mempool.add(transaction) {
434            Ok(id) => Ok((
435                StatusCode::ACCEPTED,
436                Json(SubmitTransactionResponse {
437                    transaction_id: id.to_hex(),
438                    accepted: true,
439                    error: None,
440                }),
441            )),
442            Err(e) => Err((
443                StatusCode::BAD_REQUEST,
444                Json(SubmitTransactionResponse {
445                    transaction_id: String::new(),
446                    accepted: false,
447                    error: Some(e.to_string()),
448                }),
449            )),
450        }
451    } else {
452        Err((
453            StatusCode::SERVICE_UNAVAILABLE,
454            Json(SubmitTransactionResponse {
455                transaction_id: String::new(),
456                accepted: false,
457                error: Some("Consensus and mempool are not enabled".to_string()),
458            }),
459        ))
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_consensus_status_response_serialization() {
469        let response = ConsensusStatusResponse {
470            enabled: true,
471            state: "Active".to_string(),
472            view: 10,
473            finalized_height: 100,
474            current_leader: Some("abc123".to_string()),
475            is_leader: false,
476            pending_transactions: 5,
477        };
478
479        let json = serde_json::to_string(&response).unwrap();
480        assert!(json.contains("\"enabled\":true"));
481        assert!(json.contains("\"view\":10"));
482    }
483
484    #[test]
485    fn test_block_info_response_serialization() {
486        let response = BlockInfoResponse {
487            height: 42,
488            block_id: "abc".to_string(),
489            parent_id: "def".to_string(),
490            producer: "xyz".to_string(),
491            timestamp: 1234567890,
492            tx_count: 10,
493            tx_root: "root".to_string(),
494            state_root: "state".to_string(),
495            view: 5,
496            signature_count: 3,
497        };
498
499        let json = serde_json::to_string(&response).unwrap();
500        assert!(json.contains("\"height\":42"));
501        assert!(json.contains("\"tx_count\":10"));
502    }
503}