Initialize repository snapshot
This commit is contained in:
18
vaultmesh-treasury/Cargo.toml
Normal file
18
vaultmesh-treasury/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "vaultmesh-treasury"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
vaultmesh-core = { path = "../vaultmesh-core" }
|
||||
vaultmesh-observability = { path = "../vaultmesh-observability", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
metrics = ["vaultmesh-observability"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.8"
|
||||
466
vaultmesh-treasury/src/lib.rs
Normal file
466
vaultmesh-treasury/src/lib.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
//! VaultMesh Treasury Engine - Budget tracking and financial receipts
|
||||
//!
|
||||
//! The Treasury engine manages budgets, tracks spending, and emits receipts
|
||||
//! for all financial operations in the Civilization Ledger.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(feature = "metrics")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "metrics")]
|
||||
use std::time::Instant;
|
||||
|
||||
use vaultmesh_core::{Scroll, VmHash};
|
||||
|
||||
#[cfg(feature = "metrics")]
|
||||
use vaultmesh_observability::ObservabilityEngine;
|
||||
|
||||
/// Schema version for treasury receipts
|
||||
pub const SCHEMA_VERSION: &str = "2.0.0";
|
||||
|
||||
/// A budget allocation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Budget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub currency: String,
|
||||
pub allocated: u64,
|
||||
pub spent: u64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// Remaining balance
|
||||
pub fn remaining(&self) -> u64 {
|
||||
self.allocated.saturating_sub(self.spent)
|
||||
}
|
||||
|
||||
/// Check if a spend would exceed budget
|
||||
pub fn can_spend(&self, amount: u64) -> bool {
|
||||
self.spent + amount <= self.allocated
|
||||
}
|
||||
}
|
||||
|
||||
/// Receipt body for budget creation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BudgetCreateReceipt {
|
||||
pub budget_id: String,
|
||||
pub name: String,
|
||||
pub currency: String,
|
||||
pub allocated: u64,
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
/// Receipt body for debit operations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TreasuryDebitReceipt {
|
||||
pub budget_id: String,
|
||||
pub amount: u64,
|
||||
pub currency: String,
|
||||
pub description: String,
|
||||
pub debited_by: String,
|
||||
pub new_spent: u64,
|
||||
pub new_remaining: u64,
|
||||
}
|
||||
|
||||
/// Receipt body for credit operations (refunds, adjustments)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TreasuryCreditReceipt {
|
||||
pub budget_id: String,
|
||||
pub amount: u64,
|
||||
pub currency: String,
|
||||
pub description: String,
|
||||
pub credited_by: String,
|
||||
pub new_allocated: u64,
|
||||
}
|
||||
|
||||
/// Generic treasury receipt wrapper
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TreasuryReceipt {
|
||||
pub schema_version: String,
|
||||
#[serde(rename = "type")]
|
||||
pub receipt_type: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub scroll: String,
|
||||
pub tags: Vec<String>,
|
||||
pub root_hash: String,
|
||||
pub body: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Treasury engine errors
|
||||
#[derive(Debug)]
|
||||
pub enum TreasuryError {
|
||||
BudgetExists(String),
|
||||
BudgetNotFound(String),
|
||||
InsufficientFunds { budget_id: String, requested: u64, available: u64 },
|
||||
IoError(std::io::Error),
|
||||
SerializationError(serde_json::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TreasuryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TreasuryError::BudgetExists(id) => write!(f, "Budget already exists: {}", id),
|
||||
TreasuryError::BudgetNotFound(id) => write!(f, "Budget not found: {}", id),
|
||||
TreasuryError::InsufficientFunds { budget_id, requested, available } => {
|
||||
write!(f, "Insufficient funds in {}: requested {}, available {}", budget_id, requested, available)
|
||||
}
|
||||
TreasuryError::IoError(e) => write!(f, "IO error: {}", e),
|
||||
TreasuryError::SerializationError(e) => write!(f, "Serialization error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TreasuryError {}
|
||||
|
||||
impl From<std::io::Error> for TreasuryError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
TreasuryError::IoError(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for TreasuryError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
TreasuryError::SerializationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Treasury engine for budget management
|
||||
pub struct TreasuryEngine {
|
||||
/// Path to VaultMesh root
|
||||
pub vaultmesh_root: PathBuf,
|
||||
/// In-memory budget cache
|
||||
budgets: HashMap<String, Budget>,
|
||||
/// Default currency
|
||||
pub default_currency: String,
|
||||
/// Optional observability engine for metrics
|
||||
#[cfg(feature = "metrics")]
|
||||
pub observability: Option<Arc<ObservabilityEngine>>,
|
||||
}
|
||||
|
||||
impl TreasuryEngine {
|
||||
/// Create a new Treasury engine
|
||||
pub fn new(vaultmesh_root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
vaultmesh_root: vaultmesh_root.as_ref().to_path_buf(),
|
||||
budgets: HashMap::new(),
|
||||
default_currency: "EUR".to_string(),
|
||||
#[cfg(feature = "metrics")]
|
||||
observability: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set default currency
|
||||
pub fn with_currency(mut self, currency: &str) -> Self {
|
||||
self.default_currency = currency.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the observability engine for metrics
|
||||
#[cfg(feature = "metrics")]
|
||||
pub fn with_observability(mut self, obs: Arc<ObservabilityEngine>) -> Self {
|
||||
self.observability = Some(obs);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get path to treasury events JSONL
|
||||
fn events_path(&self) -> PathBuf {
|
||||
self.vaultmesh_root.join(Scroll::Treasury.jsonl_path())
|
||||
}
|
||||
|
||||
/// Create a new budget
|
||||
pub fn create_budget(
|
||||
&mut self,
|
||||
id: &str,
|
||||
name: &str,
|
||||
allocated: u64,
|
||||
created_by: &str,
|
||||
) -> Result<Budget, TreasuryError> {
|
||||
self.create_budget_with_currency(id, name, allocated, &self.default_currency.clone(), created_by)
|
||||
}
|
||||
|
||||
/// Create a new budget with specific currency
|
||||
pub fn create_budget_with_currency(
|
||||
&mut self,
|
||||
id: &str,
|
||||
name: &str,
|
||||
allocated: u64,
|
||||
currency: &str,
|
||||
created_by: &str,
|
||||
) -> Result<Budget, TreasuryError> {
|
||||
if self.budgets.contains_key(id) {
|
||||
return Err(TreasuryError::BudgetExists(id.to_string()));
|
||||
}
|
||||
|
||||
let budget = Budget {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
currency: currency.to_string(),
|
||||
allocated,
|
||||
spent: 0,
|
||||
created_at: Utc::now(),
|
||||
created_by: created_by.to_string(),
|
||||
};
|
||||
|
||||
self.budgets.insert(id.to_string(), budget.clone());
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = BudgetCreateReceipt {
|
||||
budget_id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
currency: currency.to_string(),
|
||||
allocated,
|
||||
created_by: created_by.to_string(),
|
||||
};
|
||||
|
||||
self.emit_receipt("treasury_budget_create", &receipt_body, vec![
|
||||
"treasury".to_string(),
|
||||
"budget".to_string(),
|
||||
"create".to_string(),
|
||||
id.to_string(),
|
||||
])?;
|
||||
|
||||
Ok(budget)
|
||||
}
|
||||
|
||||
/// Debit (spend) from a budget
|
||||
pub fn debit(
|
||||
&mut self,
|
||||
budget_id: &str,
|
||||
amount: u64,
|
||||
description: &str,
|
||||
debited_by: &str,
|
||||
) -> Result<Budget, TreasuryError> {
|
||||
let budget = self.budgets.get_mut(budget_id)
|
||||
.ok_or_else(|| TreasuryError::BudgetNotFound(budget_id.to_string()))?;
|
||||
|
||||
if !budget.can_spend(amount) {
|
||||
return Err(TreasuryError::InsufficientFunds {
|
||||
budget_id: budget_id.to_string(),
|
||||
requested: amount,
|
||||
available: budget.remaining(),
|
||||
});
|
||||
}
|
||||
|
||||
budget.spent += amount;
|
||||
let updated = budget.clone();
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = TreasuryDebitReceipt {
|
||||
budget_id: budget_id.to_string(),
|
||||
amount,
|
||||
currency: updated.currency.clone(),
|
||||
description: description.to_string(),
|
||||
debited_by: debited_by.to_string(),
|
||||
new_spent: updated.spent,
|
||||
new_remaining: updated.remaining(),
|
||||
};
|
||||
|
||||
self.emit_receipt("treasury_debit", &receipt_body, vec![
|
||||
"treasury".to_string(),
|
||||
"debit".to_string(),
|
||||
budget_id.to_string(),
|
||||
])?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Credit (add funds) to a budget
|
||||
pub fn credit(
|
||||
&mut self,
|
||||
budget_id: &str,
|
||||
amount: u64,
|
||||
description: &str,
|
||||
credited_by: &str,
|
||||
) -> Result<Budget, TreasuryError> {
|
||||
let budget = self.budgets.get_mut(budget_id)
|
||||
.ok_or_else(|| TreasuryError::BudgetNotFound(budget_id.to_string()))?;
|
||||
|
||||
budget.allocated += amount;
|
||||
let updated = budget.clone();
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = TreasuryCreditReceipt {
|
||||
budget_id: budget_id.to_string(),
|
||||
amount,
|
||||
currency: updated.currency.clone(),
|
||||
description: description.to_string(),
|
||||
credited_by: credited_by.to_string(),
|
||||
new_allocated: updated.allocated,
|
||||
};
|
||||
|
||||
self.emit_receipt("treasury_credit", &receipt_body, vec![
|
||||
"treasury".to_string(),
|
||||
"credit".to_string(),
|
||||
budget_id.to_string(),
|
||||
])?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Get a budget by ID
|
||||
pub fn get_budget(&self, id: &str) -> Option<&Budget> {
|
||||
self.budgets.get(id)
|
||||
}
|
||||
|
||||
/// List all budgets
|
||||
pub fn list_budgets(&self) -> Vec<&Budget> {
|
||||
self.budgets.values().collect()
|
||||
}
|
||||
|
||||
/// Emit a treasury receipt
|
||||
fn emit_receipt<T: Serialize>(
|
||||
&self,
|
||||
receipt_type: &str,
|
||||
body: &T,
|
||||
tags: Vec<String>,
|
||||
) -> Result<(), TreasuryError> {
|
||||
#[cfg(feature = "metrics")]
|
||||
let start = Instant::now();
|
||||
|
||||
let body_json = serde_json::to_value(body)?;
|
||||
let root_hash = VmHash::from_json(&body_json)?;
|
||||
|
||||
let receipt = TreasuryReceipt {
|
||||
schema_version: SCHEMA_VERSION.to_string(),
|
||||
receipt_type: receipt_type.to_string(),
|
||||
timestamp: Utc::now(),
|
||||
scroll: "treasury".to_string(),
|
||||
tags,
|
||||
root_hash: root_hash.as_str().to_string(),
|
||||
body: body_json,
|
||||
};
|
||||
|
||||
// Ensure directory exists
|
||||
let path = self.events_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Append to JSONL
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
|
||||
let json = serde_json::to_string(&receipt)?;
|
||||
writeln!(file, "{}", json)?;
|
||||
|
||||
// Update ROOT file
|
||||
self.update_root_file()?;
|
||||
|
||||
// Record metrics if observability is enabled
|
||||
#[cfg(feature = "metrics")]
|
||||
if let Some(ref obs) = self.observability {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
obs.observe_emitted("treasury", elapsed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update ROOT.treasury.txt with current Merkle root
|
||||
fn update_root_file(&self) -> Result<(), TreasuryError> {
|
||||
let events_path = self.events_path();
|
||||
if !events_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&events_path)?;
|
||||
let hashes: Vec<VmHash> = content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| VmHash::blake3(l.as_bytes()))
|
||||
.collect();
|
||||
|
||||
let root = vaultmesh_core::merkle_root(&hashes);
|
||||
let root_path = self.vaultmesh_root.join(Scroll::Treasury.root_file());
|
||||
fs::write(&root_path, root.as_str())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_test_env() -> (TempDir, TreasuryEngine) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let engine = TreasuryEngine::new(tmp.path());
|
||||
(tmp, engine)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_budget() {
|
||||
let (_tmp, mut engine) = setup_test_env();
|
||||
|
||||
let budget = engine.create_budget(
|
||||
"ops-2025",
|
||||
"Operations Budget 2025",
|
||||
10000,
|
||||
"did:vm:human:karol",
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(budget.id, "ops-2025");
|
||||
assert_eq!(budget.allocated, 10000);
|
||||
assert_eq!(budget.spent, 0);
|
||||
assert_eq!(budget.remaining(), 10000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debit_reduces_balance() {
|
||||
let (_tmp, mut engine) = setup_test_env();
|
||||
|
||||
engine.create_budget("test", "Test", 1000, "did:vm:human:test").unwrap();
|
||||
let budget = engine.debit("test", 300, "Test expense", "did:vm:human:test").unwrap();
|
||||
|
||||
assert_eq!(budget.spent, 300);
|
||||
assert_eq!(budget.remaining(), 700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debit_insufficient_funds() {
|
||||
let (_tmp, mut engine) = setup_test_env();
|
||||
|
||||
engine.create_budget("small", "Small Budget", 100, "did:vm:human:test").unwrap();
|
||||
let result = engine.debit("small", 500, "Too much", "did:vm:human:test");
|
||||
|
||||
assert!(matches!(result, Err(TreasuryError::InsufficientFunds { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credit_increases_allocation() {
|
||||
let (_tmp, mut engine) = setup_test_env();
|
||||
|
||||
engine.create_budget("grow", "Growing Budget", 500, "did:vm:human:test").unwrap();
|
||||
let budget = engine.credit("grow", 200, "Additional funding", "did:vm:human:test").unwrap();
|
||||
|
||||
assert_eq!(budget.allocated, 700);
|
||||
assert_eq!(budget.remaining(), 700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_receipts_emitted() {
|
||||
let (tmp, mut engine) = setup_test_env();
|
||||
|
||||
engine.create_budget("receipt-test", "Receipt Test", 1000, "did:vm:human:test").unwrap();
|
||||
engine.debit("receipt-test", 100, "Test debit", "did:vm:human:test").unwrap();
|
||||
|
||||
let events_path = tmp.path().join("receipts/treasury/treasury_events.jsonl");
|
||||
assert!(events_path.exists());
|
||||
|
||||
let content = fs::read_to_string(&events_path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
assert_eq!(lines.len(), 2); // create + debit
|
||||
assert!(lines[0].contains("treasury_budget_create"));
|
||||
assert!(lines[1].contains("treasury_debit"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user