use pot_o_core::{TribeError, TribeResult};
use pot_o_extensions::pool_strategy::ProofRecord;
use sha2::{Digest, Sha256};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone)]
pub struct PoolCoordinator {
node_id: String,
pool_strategy: String,
primary_validator_url: Option<String>,
min_batch_size: usize,
max_batch_age_secs: u64,
pending_proofs: Vec<ProofRecord>,
batch_start_time: Option<SystemTime>,
}
impl PoolCoordinator {
pub fn new(
node_id: String,
pool_strategy: String,
primary_validator_url: Option<String>,
min_batch_size: usize,
max_batch_age_secs: u64,
) -> TribeResult<Self> {
match pool_strategy.as_str() {
"solo" | "proportional" | "pplns" => {}
_ => {
return Err(TribeError::InvalidOperation(format!(
"invalid pool strategy: {}. must be 'solo', 'proportional', or 'pplns'",
pool_strategy
)));
}
}
if min_batch_size == 0 {
return Err(TribeError::InvalidOperation(
"min_batch_size must be > 0".to_string(),
));
}
if max_batch_age_secs == 0 {
return Err(TribeError::InvalidOperation(
"max_batch_age_secs must be > 0".to_string(),
));
}
Ok(Self {
node_id,
pool_strategy,
primary_validator_url,
min_batch_size,
max_batch_age_secs,
pending_proofs: Vec::new(),
batch_start_time: None,
})
}
pub fn add_proof(&mut self, proof: ProofRecord) -> TribeResult<()> {
if self.pending_proofs.is_empty() && self.batch_start_time.is_none() {
self.batch_start_time = Some(SystemTime::now());
}
self.pending_proofs.push(proof);
Ok(())
}
pub fn get_pending_proofs(&self) -> Vec<ProofRecord> {
self.pending_proofs.clone()
}
pub fn batch_ready(&self) -> bool {
if self.pending_proofs.len() >= self.min_batch_size {
return true;
}
if let Some(start_time) = self.batch_start_time {
if let Ok(elapsed) = start_time.elapsed() {
if elapsed >= Duration::from_secs(self.max_batch_age_secs) {
return true;
}
}
}
false
}
pub fn is_primary_validator(&self) -> bool {
self.primary_validator_url.is_none()
|| self
.primary_validator_url
.as_ref()
.is_some_and(|url| url.contains("localhost") || url.contains("127.0.0.1"))
}
pub async fn submit_batch_to_primary(
&mut self,
mut batch: Vec<ProofRecord>,
) -> TribeResult<String> {
self.sort_proofs_deterministically(&mut batch);
if self.is_primary_validator() {
return self.submit_batch_local(&batch).await;
}
if let Some(ref primary_url) = self.primary_validator_url {
return self.submit_batch_to_url(primary_url, &batch).await;
}
Err(TribeError::NetworkError(
"no primary validator URL configured".to_string(),
))
}
pub fn clear_batch(&mut self) {
self.pending_proofs.clear();
self.batch_start_time = None;
}
fn sort_proofs_deterministically(&self, proofs: &mut [ProofRecord]) {
proofs.sort_by(|a, b| match a.miner_pubkey.cmp(&b.miner_pubkey) {
std::cmp::Ordering::Equal => a.timestamp.cmp(&b.timestamp),
other => other,
});
}
async fn submit_batch_local(&self, batch: &[ProofRecord]) -> TribeResult<String> {
let mut hasher = Sha256::new();
hasher.update(b"pot-o-batch:");
hasher.update(self.node_id.as_bytes());
hasher.update(b":");
for record in batch {
hasher.update(record.miner_pubkey.as_bytes());
hasher.update(b":");
hasher.update(record.challenge_id.as_bytes());
hasher.update(b":");
hasher.update(record.reward.to_le_bytes());
hasher.update(b":");
hasher.update(record.timestamp.timestamp().to_le_bytes());
hasher.update(b"|");
}
let hash = hex::encode(hasher.finalize());
let submission_id = format!("local-batch-{}", &hash[..40]);
tracing::info!(
batch_size = batch.len(),
submission_id = %submission_id,
"Batch submitted locally for pool accounting"
);
Ok(submission_id)
}
async fn submit_batch_to_url(
&self,
primary_url: &str,
batch: &[ProofRecord],
) -> TribeResult<String> {
let client = reqwest::Client::new();
let payload = serde_json::json!({
"node_id": self.node_id,
"pool_strategy": self.pool_strategy,
"batch": batch,
});
let url = format!(
"{}/api/pool/submit-batch",
primary_url.trim_end_matches('/')
);
let response = client.post(&url).json(&payload).send().await.map_err(|e| {
TribeError::NetworkError(format!("failed to submit batch to primary: {}", e))
})?;
if !response.status().is_success() {
return Err(TribeError::NetworkError(format!(
"batch submission failed: {}",
response.status()
)));
}
let resp_body: serde_json::Value = response
.json()
.await
.map_err(|e| TribeError::NetworkError(format!("failed to parse response: {}", e)))?;
resp_body
.get("submission_id")
.and_then(|id| id.as_str())
.map(|id| id.to_string())
.ok_or_else(|| TribeError::NetworkError("no submission_id in response".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn create_proof(miner_pubkey: &str, timestamp: chrono::DateTime<Utc>) -> ProofRecord {
ProofRecord {
miner_pubkey: miner_pubkey.to_string(),
challenge_id: "challenge1".to_string(),
reward: 1000,
timestamp,
}
}
fn create_coordinator(
pool_strategy: &str,
primary_url: Option<String>,
) -> TribeResult<PoolCoordinator> {
PoolCoordinator::new(
"validator-1".to_string(),
pool_strategy.to_string(),
primary_url,
2, 3600, )
}
#[test]
fn test_coordinator_creation_solo() {
let coordinator = create_coordinator("solo", None);
assert!(coordinator.is_ok());
let coord = coordinator.unwrap();
assert_eq!(coord.pool_strategy, "solo");
assert!(coord.is_primary_validator());
}
#[test]
fn test_coordinator_creation_proportional() {
let coordinator =
create_coordinator("proportional", Some("http://primary:8900".to_string()));
assert!(coordinator.is_ok());
let coord = coordinator.unwrap();
assert_eq!(coord.pool_strategy, "proportional");
assert!(!coord.is_primary_validator());
}
#[test]
fn test_coordinator_creation_pplns() {
let coordinator = create_coordinator("pplns", None);
assert!(coordinator.is_ok());
let coord = coordinator.unwrap();
assert_eq!(coord.pool_strategy, "pplns");
}
#[test]
fn test_coordinator_invalid_strategy() {
let result = PoolCoordinator::new(
"validator-1".to_string(),
"invalid-strategy".to_string(),
None,
2,
3600,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("invalid pool strategy"));
}
#[test]
fn test_coordinator_invalid_batch_size() {
let result = PoolCoordinator::new(
"validator-1".to_string(),
"solo".to_string(),
None,
0, 3600,
);
assert!(result.is_err());
}
#[test]
fn test_coordinator_invalid_batch_age() {
let result = PoolCoordinator::new(
"validator-1".to_string(),
"solo".to_string(),
None,
2,
0, );
assert!(result.is_err());
}
#[test]
fn test_add_single_proof() {
let mut coordinator = create_coordinator("solo", None).unwrap();
let proof = create_proof("miner1", Utc::now());
let result = coordinator.add_proof(proof.clone());
assert!(result.is_ok());
let pending = coordinator.get_pending_proofs();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].miner_pubkey, "miner1");
}
#[test]
fn test_add_multiple_proofs() {
let mut coordinator = create_coordinator("solo", None).unwrap();
let now = Utc::now();
for i in 0..5 {
let proof = create_proof(&format!("miner{}", i), now);
let result = coordinator.add_proof(proof);
assert!(result.is_ok());
}
let pending = coordinator.get_pending_proofs();
assert_eq!(pending.len(), 5);
}
#[test]
fn test_get_pending_proofs_empty() {
let coordinator = create_coordinator("solo", None).unwrap();
let pending = coordinator.get_pending_proofs();
assert_eq!(pending.len(), 0);
}
#[test]
fn test_get_pending_proofs_returns_clone() {
let mut coordinator = create_coordinator("solo", None).unwrap();
let proof = create_proof("miner1", Utc::now());
coordinator.add_proof(proof).unwrap();
let pending1 = coordinator.get_pending_proofs();
let pending2 = coordinator.get_pending_proofs();
assert_eq!(pending1.len(), pending2.len());
assert_eq!(pending1[0].miner_pubkey, pending2[0].miner_pubkey);
}
#[test]
fn test_batch_ready_by_size() {
let mut coordinator = create_coordinator("solo", None).unwrap();
let now = Utc::now();
coordinator.add_proof(create_proof("miner1", now)).unwrap();
assert!(!coordinator.batch_ready());
coordinator.add_proof(create_proof("miner2", now)).unwrap();
assert!(coordinator.batch_ready());
}
#[test]
fn test_batch_not_ready_insufficient_size() {
let mut coordinator = create_coordinator("solo", None).unwrap();
let now = Utc::now();
coordinator.add_proof(create_proof("miner1", now)).unwrap();
assert!(!coordinator.batch_ready());
}
#[test]
fn test_batch_ready_empty() {
let coordinator = create_coordinator("solo", None).unwrap();
assert!(!coordinator.batch_ready());
}
#[test]
fn test_batch_start_time_initialized_on_first_proof() {
let mut coordinator = create_coordinator("solo", None).unwrap();
assert!(coordinator.batch_start_time.is_none());
coordinator
.add_proof(create_proof("miner1", Utc::now()))
.unwrap();
assert!(coordinator.batch_start_time.is_some());
}
#[test]
fn test_batch_start_time_not_reset_on_second_proof() {
let mut coordinator = create_coordinator("solo", None).unwrap();
let first_time = Utc::now();
coordinator
.add_proof(create_proof("miner1", first_time))
.unwrap();
let first_batch_time = coordinator.batch_start_time;
coordinator
.add_proof(create_proof("miner2", Utc::now()))
.unwrap();
let second_batch_time = coordinator.batch_start_time;
assert_eq!(first_batch_time, second_batch_time);
}
#[test]
fn test_is_primary_validator_none_url() {
let coordinator = create_coordinator("solo", None).unwrap();
assert!(coordinator.is_primary_validator());
}
#[test]
fn test_is_primary_validator_localhost() {
let coordinator =
create_coordinator("solo", Some("http://localhost:8900".to_string())).unwrap();
assert!(coordinator.is_primary_validator());
}
#[test]
fn test_is_primary_validator_127_0_0_1() {
let coordinator =
create_coordinator("solo", Some("http://127.0.0.1:8900".to_string())).unwrap();
assert!(coordinator.is_primary_validator());
}
#[test]
fn test_is_primary_validator_remote_url() {
let coordinator =
create_coordinator("solo", Some("http://primary.cluster:8900".to_string())).unwrap();
assert!(!coordinator.is_primary_validator());
}
#[test]
fn test_is_primary_validator_remote_ip() {
let coordinator =
create_coordinator("solo", Some("http://192.168.1.100:8900".to_string())).unwrap();
assert!(!coordinator.is_primary_validator());
}
#[test]
fn test_clear_batch() {
let mut coordinator = create_coordinator("solo", None).unwrap();
let now = Utc::now();
coordinator.add_proof(create_proof("miner1", now)).unwrap();
coordinator.add_proof(create_proof("miner2", now)).unwrap();
assert_eq!(coordinator.get_pending_proofs().len(), 2);
assert!(coordinator.batch_start_time.is_some());
coordinator.clear_batch();
assert_eq!(coordinator.get_pending_proofs().len(), 0);
assert!(coordinator.batch_start_time.is_none());
}
#[test]
fn test_clear_batch_twice() {
let mut coordinator = create_coordinator("solo", None).unwrap();
let now = Utc::now();
coordinator.add_proof(create_proof("miner1", now)).unwrap();
coordinator.clear_batch();
coordinator.clear_batch();
assert_eq!(coordinator.get_pending_proofs().len(), 0);
}
#[test]
fn test_deterministic_sorting_by_pubkey() {
let coordinator = create_coordinator("solo", None).unwrap();
let now = Utc::now();
let mut batch = vec![
create_proof("zebra_miner", now),
create_proof("alpha_miner", now),
create_proof("beta_miner", now),
];
coordinator.sort_proofs_deterministically(&mut batch);
assert_eq!(batch[0].miner_pubkey, "alpha_miner");
assert_eq!(batch[1].miner_pubkey, "beta_miner");
assert_eq!(batch[2].miner_pubkey, "zebra_miner");
}
#[test]
fn test_deterministic_sorting_by_timestamp_same_pubkey() {
let coordinator = create_coordinator("solo", None).unwrap();
let base_time = Utc::now();
let mut batch = vec![
create_proof("miner1", base_time + chrono::Duration::seconds(3)),
create_proof("miner1", base_time + chrono::Duration::seconds(1)),
create_proof("miner1", base_time + chrono::Duration::seconds(2)),
];
coordinator.sort_proofs_deterministically(&mut batch);
assert!(batch[0].timestamp <= batch[1].timestamp);
assert!(batch[1].timestamp <= batch[2].timestamp);
}
#[test]
fn test_deterministic_sorting_combined() {
let coordinator = create_coordinator("solo", None).unwrap();
let base_time = Utc::now();
let mut batch = vec![
create_proof("miner2", base_time + chrono::Duration::seconds(1)),
create_proof("miner1", base_time + chrono::Duration::seconds(3)),
create_proof("miner1", base_time + chrono::Duration::seconds(1)),
create_proof("miner2", base_time + chrono::Duration::seconds(2)),
];
coordinator.sort_proofs_deterministically(&mut batch);
assert_eq!(batch[0].miner_pubkey, "miner1");
assert_eq!(batch[1].miner_pubkey, "miner1");
assert_eq!(batch[2].miner_pubkey, "miner2");
assert_eq!(batch[3].miner_pubkey, "miner2");
assert!(batch[0].timestamp <= batch[1].timestamp);
assert!(batch[2].timestamp <= batch[3].timestamp);
}
#[test]
fn test_full_workflow_primary() {
let mut coordinator = create_coordinator("proportional", None).unwrap();
let now = Utc::now();
coordinator.add_proof(create_proof("miner1", now)).unwrap();
assert!(!coordinator.batch_ready());
coordinator.add_proof(create_proof("miner2", now)).unwrap();
assert!(coordinator.batch_ready());
let batch = coordinator.get_pending_proofs();
assert_eq!(batch.len(), 2);
coordinator.clear_batch();
assert_eq!(coordinator.get_pending_proofs().len(), 0);
assert!(!coordinator.batch_ready());
}
#[test]
fn test_full_workflow_secondary() {
let mut coordinator =
create_coordinator("pplns", Some("http://primary.cluster:8900".to_string())).unwrap();
let now = Utc::now();
assert!(!coordinator.is_primary_validator());
coordinator.add_proof(create_proof("miner1", now)).unwrap();
coordinator.add_proof(create_proof("miner2", now)).unwrap();
assert!(coordinator.batch_ready());
let batch = coordinator.get_pending_proofs();
assert_eq!(batch.len(), 2);
coordinator.clear_batch();
assert_eq!(coordinator.get_pending_proofs().len(), 0);
}
}