use thiserror::Error;
#[derive(Debug, Error)]
pub enum LedgerError {
#[error("Participant {0} not found")]
ParticipantNotFound(usize),
#[error("Invalid transaction: {0}")]
InvalidTransaction(String),
#[error("No pending transactions to settle")]
NoPendingTransactions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EnergyType {
Conventional,
Solar,
Wind,
Hydro,
Storage,
Ev,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TxStatus {
Pending,
Confirmed,
Settled,
Rejected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParticipantRole {
Producer,
Consumer,
Prosumer,
Aggregator,
GridOperator,
}
#[derive(Debug, Clone)]
pub struct Participant {
pub id: usize,
pub name: String,
pub role: ParticipantRole,
pub balance_usd: f64,
pub energy_balance_mwh: f64,
}
#[derive(Debug, Clone)]
pub struct EnergyTransaction {
pub tx_id: u64,
pub seller_id: usize,
pub buyer_id: usize,
pub energy_mwh: f64,
pub price_usd_per_mwh: f64,
pub delivery_hour: usize,
pub energy_type: EnergyType,
pub timestamp: u64,
pub status: TxStatus,
}
#[derive(Debug, Clone)]
pub struct EnergyBlock {
pub block_id: u64,
pub timestamp: u64,
pub transactions: Vec<EnergyTransaction>,
pub prev_block_hash: u64,
pub block_hash: u64,
pub settlement_price_usd_per_mwh: f64,
pub total_energy_mwh: f64,
}
#[derive(Debug, Default)]
pub struct EnergyLedger {
pub chain: Vec<EnergyBlock>,
pub pending_transactions: Vec<EnergyTransaction>,
pub participants: Vec<Participant>,
next_tx_id: u64,
}
impl EnergyLedger {
pub fn new() -> Self {
Self::default()
}
pub fn register_participant(&mut self, participant: Participant) {
if let Some(existing) = self
.participants
.iter_mut()
.find(|p| p.id == participant.id)
{
*existing = participant;
} else {
self.participants.push(participant);
}
}
pub fn submit_transaction(&mut self, mut tx: EnergyTransaction) -> Result<u64, LedgerError> {
if tx.energy_mwh <= 0.0 {
return Err(LedgerError::InvalidTransaction(
"energy_mwh must be positive".to_string(),
));
}
if tx.price_usd_per_mwh < 0.0 {
return Err(LedgerError::InvalidTransaction(
"price_usd_per_mwh must be non-negative".to_string(),
));
}
if !self.participants.iter().any(|p| p.id == tx.seller_id) {
return Err(LedgerError::ParticipantNotFound(tx.seller_id));
}
if !self.participants.iter().any(|p| p.id == tx.buyer_id) {
return Err(LedgerError::ParticipantNotFound(tx.buyer_id));
}
tx.tx_id = self.next_tx_id;
tx.status = TxStatus::Pending;
self.next_tx_id += 1;
let id = tx.tx_id;
self.pending_transactions.push(tx);
Ok(id)
}
pub fn settle_block(
&mut self,
settlement_price_usd_per_mwh: f64,
) -> Result<EnergyBlock, LedgerError> {
if self.pending_transactions.is_empty() {
return Err(LedgerError::NoPendingTransactions);
}
let block_id = self.chain.len() as u64;
let timestamp = 1_700_000_000u64 + block_id * 3600;
let mut settled: Vec<EnergyTransaction> = self.pending_transactions.drain(..).collect();
let mut total_energy = 0.0f64;
for tx in settled.iter_mut() {
tx.status = TxStatus::Settled;
let cost = tx.energy_mwh * tx.price_usd_per_mwh;
if let Some(seller) = self.participants.iter_mut().find(|p| p.id == tx.seller_id) {
seller.balance_usd += cost;
seller.energy_balance_mwh -= tx.energy_mwh;
}
if let Some(buyer) = self.participants.iter_mut().find(|p| p.id == tx.buyer_id) {
buyer.balance_usd -= cost;
buyer.energy_balance_mwh += tx.energy_mwh;
}
total_energy += tx.energy_mwh;
}
let prev_block_hash = self.chain.last().map(|b| b.block_hash).unwrap_or(0u64);
let block_hash = Self::compute_block_hash(
block_id,
timestamp,
&settled,
prev_block_hash,
settlement_price_usd_per_mwh,
total_energy,
);
let block = EnergyBlock {
block_id,
timestamp,
transactions: settled,
prev_block_hash,
block_hash,
settlement_price_usd_per_mwh,
total_energy_mwh: total_energy,
};
self.chain.push(block.clone());
Ok(block)
}
pub fn validate_chain(&self) -> bool {
for (i, block) in self.chain.iter().enumerate() {
let expected_prev = if i == 0 {
0u64
} else {
self.chain[i - 1].block_hash
};
if block.prev_block_hash != expected_prev {
return false;
}
let recomputed = Self::compute_block_hash(
block.block_id,
block.timestamp,
&block.transactions,
block.prev_block_hash,
block.settlement_price_usd_per_mwh,
block.total_energy_mwh,
);
if recomputed != block.block_hash {
return false;
}
}
true
}
pub fn fnv1a_hash(data: &[u8]) -> u64 {
const FNV_PRIME: u64 = 1_099_511_628_211;
const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
let mut hash = FNV_OFFSET;
for &byte in data {
hash ^= byte as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
pub fn participant_history(&self, id: usize) -> Vec<&EnergyTransaction> {
let mut result: Vec<&EnergyTransaction> = Vec::new();
for block in &self.chain {
for tx in &block.transactions {
if tx.seller_id == id || tx.buyer_id == id {
result.push(tx);
}
}
}
for tx in &self.pending_transactions {
if tx.seller_id == id || tx.buyer_id == id {
result.push(tx);
}
}
result
}
pub fn block_statistics(&self, block_id: u64) -> Option<(f64, f64, f64)> {
let block = self.chain.iter().find(|b| b.block_id == block_id)?;
if block.transactions.is_empty() {
return None;
}
let mut min_p = f64::MAX;
let mut max_p = f64::MIN;
let mut total_mwh = 0.0f64;
let mut total_value = 0.0f64;
for tx in &block.transactions {
if tx.price_usd_per_mwh < min_p {
min_p = tx.price_usd_per_mwh;
}
if tx.price_usd_per_mwh > max_p {
max_p = tx.price_usd_per_mwh;
}
total_mwh += tx.energy_mwh;
total_value += tx.energy_mwh * tx.price_usd_per_mwh;
}
let avg = if total_mwh > 1e-12 {
total_value / total_mwh
} else {
0.0
};
Some((min_p, max_p, avg))
}
pub fn participant_balance(&self, id: usize) -> Option<f64> {
self.participants
.iter()
.find(|p| p.id == id)
.map(|p| p.balance_usd)
}
pub fn total_traded_mwh(&self) -> f64 {
self.chain.iter().map(|b| b.total_energy_mwh).sum()
}
fn compute_block_hash(
block_id: u64,
timestamp: u64,
transactions: &[EnergyTransaction],
prev_hash: u64,
settlement_price: f64,
total_energy: f64,
) -> u64 {
let mut data: Vec<u8> = Vec::with_capacity(128 + transactions.len() * 48);
data.extend_from_slice(&block_id.to_le_bytes());
data.extend_from_slice(×tamp.to_le_bytes());
data.extend_from_slice(&prev_hash.to_le_bytes());
data.extend_from_slice(&settlement_price.to_bits().to_le_bytes());
data.extend_from_slice(&total_energy.to_bits().to_le_bytes());
for tx in transactions {
data.extend_from_slice(&tx.tx_id.to_le_bytes());
data.extend_from_slice(&(tx.seller_id as u64).to_le_bytes());
data.extend_from_slice(&(tx.buyer_id as u64).to_le_bytes());
data.extend_from_slice(&tx.energy_mwh.to_bits().to_le_bytes());
data.extend_from_slice(&tx.price_usd_per_mwh.to_bits().to_le_bytes());
data.extend_from_slice(&(tx.delivery_hour as u64).to_le_bytes());
data.extend_from_slice(&tx.timestamp.to_le_bytes());
}
Self::fnv1a_hash(&data)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_participant(id: usize, role: ParticipantRole) -> Participant {
Participant {
id,
name: format!("P{id}"),
role,
balance_usd: 10_000.0,
energy_balance_mwh: 0.0,
}
}
fn make_tx(seller: usize, buyer: usize, mwh: f64, price: f64) -> EnergyTransaction {
EnergyTransaction {
tx_id: 0,
seller_id: seller,
buyer_id: buyer,
energy_mwh: mwh,
price_usd_per_mwh: price,
delivery_hour: 10,
energy_type: EnergyType::Solar,
timestamp: 1_700_000_000,
status: TxStatus::Pending,
}
}
#[test]
fn test_submit_and_confirm_transaction() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
ledger.register_participant(make_participant(1, ParticipantRole::Consumer));
let tx = make_tx(0, 1, 10.0, 50.0);
let id = ledger.submit_transaction(tx).expect("submit ok");
assert_eq!(id, 0);
assert_eq!(ledger.pending_transactions.len(), 1);
assert_eq!(ledger.pending_transactions[0].status, TxStatus::Pending);
}
#[test]
fn test_block_settlement_confirms_transactions() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
ledger.register_participant(make_participant(1, ParticipantRole::Consumer));
ledger
.submit_transaction(make_tx(0, 1, 5.0, 60.0))
.expect("ok");
ledger
.submit_transaction(make_tx(0, 1, 10.0, 55.0))
.expect("ok");
let block = ledger.settle_block(57.5).expect("settle ok");
assert_eq!(block.transactions.len(), 2);
for tx in &block.transactions {
assert_eq!(tx.status, TxStatus::Settled);
}
assert!(ledger.pending_transactions.is_empty());
let seller_bal = ledger.participant_balance(0).expect("seller found");
assert!(
(seller_bal - 10_850.0).abs() < 1e-9,
"seller balance wrong: {seller_bal}"
);
}
#[test]
fn test_chain_validation_valid_chain() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
ledger.register_participant(make_participant(1, ParticipantRole::Consumer));
for i in 0..3u64 {
ledger
.submit_transaction(make_tx(0, 1, (i + 1) as f64, 50.0))
.expect("ok");
ledger.settle_block(50.0).expect("settle");
}
assert!(ledger.validate_chain(), "Valid chain must pass");
}
#[test]
fn test_chain_tamper_detected() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
ledger.register_participant(make_participant(1, ParticipantRole::Consumer));
ledger
.submit_transaction(make_tx(0, 1, 5.0, 50.0))
.expect("ok");
ledger.settle_block(50.0).expect("settle");
ledger
.submit_transaction(make_tx(0, 1, 5.0, 60.0))
.expect("ok");
ledger.settle_block(60.0).expect("settle");
ledger.chain[0].transactions[0].energy_mwh = 999.0;
assert!(!ledger.validate_chain(), "Tampered chain must fail");
}
#[test]
fn test_block_statistics_min_max_avg() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
ledger.register_participant(make_participant(1, ParticipantRole::Consumer));
ledger
.submit_transaction(make_tx(0, 1, 10.0, 40.0))
.expect("ok");
ledger
.submit_transaction(make_tx(0, 1, 10.0, 60.0))
.expect("ok");
ledger.settle_block(50.0).expect("settle");
let (min_p, max_p, avg_p) = ledger.block_statistics(0).expect("stats");
assert!((min_p - 40.0).abs() < 1e-9, "min={min_p}");
assert!((max_p - 60.0).abs() < 1e-9, "max={max_p}");
assert!(
(avg_p - 50.0).abs() < 1e-9,
"avg should be 50.0, got {avg_p}"
);
}
#[test]
fn test_participant_history_across_blocks() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
ledger.register_participant(make_participant(1, ParticipantRole::Consumer));
ledger.register_participant(make_participant(2, ParticipantRole::Consumer));
ledger
.submit_transaction(make_tx(0, 1, 5.0, 50.0))
.expect("ok");
ledger.settle_block(50.0).expect("settle");
ledger
.submit_transaction(make_tx(0, 2, 8.0, 55.0))
.expect("ok");
ledger
.submit_transaction(make_tx(0, 1, 3.0, 45.0))
.expect("ok");
ledger.settle_block(50.0).expect("settle");
let hist_0 = ledger.participant_history(0);
assert_eq!(hist_0.len(), 3, "Producer in 3 tx");
let hist_1 = ledger.participant_history(1);
assert_eq!(hist_1.len(), 2, "Consumer 1 in 2 tx");
let hist_2 = ledger.participant_history(2);
assert_eq!(hist_2.len(), 1, "Consumer 2 in 1 tx");
}
#[test]
fn test_total_traded_mwh() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
ledger.register_participant(make_participant(1, ParticipantRole::Consumer));
ledger
.submit_transaction(make_tx(0, 1, 5.0, 50.0))
.expect("ok");
ledger
.submit_transaction(make_tx(0, 1, 15.0, 55.0))
.expect("ok");
ledger.settle_block(52.0).expect("settle");
assert!(
(ledger.total_traded_mwh() - 20.0).abs() < 1e-9,
"total should be 20 MWh"
);
}
#[test]
fn test_submit_unknown_participant_errors() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
let tx = make_tx(0, 99, 5.0, 50.0); assert!(
ledger.submit_transaction(tx).is_err(),
"Unknown buyer must error"
);
}
#[test]
fn test_invalid_energy_rejected() {
let mut ledger = EnergyLedger::new();
ledger.register_participant(make_participant(0, ParticipantRole::Producer));
ledger.register_participant(make_participant(1, ParticipantRole::Consumer));
let tx = make_tx(0, 1, -5.0, 50.0); assert!(ledger.submit_transaction(tx).is_err());
}
}