feat(economics): add Phase 12 - Economics & Billing infrastructure

Complete economics service implementation with:

- Price Oracle with TWAP (Time-Weighted Average Price)
  - Multi-source price aggregation
  - Configurable staleness thresholds
  - Exponential, SMA, and standard TWAP strategies

- Metering Service for L2 usage tracking
  - Storage: GB-months, retrieval bandwidth
  - Hosting: bandwidth, custom domains
  - Database: queries, vector searches
  - Compute: CPU core-hours, GPU hours, memory
  - Network: bandwidth, requests

- Billing Engine
  - Invoice generation with line items
  - Payment processing (crypto/fiat)
  - Credit management with expiration
  - Auto-pay from prepaid balance

- Pricing Tiers: Free, Standard, Premium, Enterprise
  - 0%, 10%, 20%, 30% usage discounts
  - SLA guarantees: 95%, 99%, 99.9%, 99.99%

- Cost Calculator & Estimator
  - Usage projections
  - Tier comparison recommendations
  - ROI analysis

- Docker deployment with PostgreSQL schema

All 61 tests passing.
This commit is contained in:
Gulshan Yadav 2026-01-19 21:51:26 +05:30
parent 8b152a5a23
commit 17f0b4ce4b
26 changed files with 6826 additions and 0 deletions

View file

@ -19,6 +19,7 @@ members = [
"crates/synor-privacy",
"crates/synor-sharding",
"crates/synor-verifier",
"crates/synor-economics",
"crates/synor-sdk",
"crates/synor-contract-test",
"crates/synor-compiler",

View file

@ -0,0 +1,58 @@
[package]
name = "synor-economics"
version = "0.1.0"
edition = "2021"
description = "Economics, pricing, metering, and billing for Synor L2 services"
license = "MIT OR Apache-2.0"
repository = "https://github.com/synor/blockchain"
[dependencies]
# Internal crates
synor-types = { path = "../synor-types" }
# Async runtime
tokio = { version = "1.40", features = ["full"] }
futures = "0.3"
async-trait = "0.1"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Time handling
chrono = { version = "0.4", features = ["serde"] }
# Decimal math for financial precision
rust_decimal = { version = "1.33", features = ["serde"] }
rust_decimal_macros = "1.33"
# Cryptographic hashing
sha2 = "0.10"
blake3 = "1.5"
# Error handling
thiserror = "1.0"
anyhow = "1.0"
# Logging
tracing = "0.1"
# Database/storage
rocksdb = { version = "0.22", optional = true }
# HTTP client for external price feeds
reqwest = { version = "0.11", features = ["json"], optional = true }
# Event streaming
async-channel = "2.1"
[dev-dependencies]
tokio-test = "0.4"
criterion = "0.5"
tempfile = "3.10"
[features]
default = ["http-feeds"]
http-feeds = ["reqwest"]
persistent = ["rocksdb"]

View file

@ -0,0 +1,338 @@
//! Credit Management
//!
//! Promotional credits, SLA credits, and referral rewards.
use crate::{AccountId, SynorDecimal};
use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Credit type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CreditType {
/// Welcome bonus for new accounts
Welcome,
/// Promotional campaign
Promotional,
/// Referral reward
Referral,
/// SLA violation compensation
SlaCompensation,
/// Support ticket resolution
Support,
/// Bug bounty reward
BugBounty,
/// Manual adjustment
Manual,
}
impl std::fmt::Display for CreditType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CreditType::Welcome => write!(f, "Welcome Bonus"),
CreditType::Promotional => write!(f, "Promotional"),
CreditType::Referral => write!(f, "Referral Reward"),
CreditType::SlaCompensation => write!(f, "SLA Compensation"),
CreditType::Support => write!(f, "Support Credit"),
CreditType::BugBounty => write!(f, "Bug Bounty"),
CreditType::Manual => write!(f, "Manual Adjustment"),
}
}
}
/// Account credit record
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credit {
/// Unique credit ID
pub id: String,
/// Account receiving the credit
pub account_id: AccountId,
/// Credit amount in SYNOR
pub amount: SynorDecimal,
/// Original amount (before any usage)
pub original_amount: SynorDecimal,
/// Amount used
pub used_amount: SynorDecimal,
/// Credit type
pub credit_type: CreditType,
/// Reason/description
pub reason: String,
/// Date created
pub created_at: DateTime<Utc>,
/// Expiration date (optional)
pub expires_at: Option<DateTime<Utc>>,
/// Whether the credit has been fully used or expired
pub is_active: bool,
/// Reference ID (e.g., referral code, ticket number)
pub reference_id: Option<String>,
/// Approved by (admin user)
pub approved_by: Option<String>,
}
impl Credit {
/// Create a new credit
pub fn new(
account_id: impl Into<String>,
amount: SynorDecimal,
reason: impl Into<String>,
) -> Self {
Self {
id: generate_credit_id(),
account_id: account_id.into(),
amount,
original_amount: amount,
used_amount: Decimal::ZERO,
credit_type: CreditType::Manual,
reason: reason.into(),
created_at: Utc::now(),
expires_at: None,
is_active: true,
reference_id: None,
approved_by: None,
}
}
/// Create welcome credit
pub fn welcome(account_id: impl Into<String>, amount: SynorDecimal) -> Self {
Self::new(account_id, amount, "Welcome to Synor!")
.with_type(CreditType::Welcome)
.with_expiry_days(90) // 90 day expiry
}
/// Create referral credit
pub fn referral(
account_id: impl Into<String>,
amount: SynorDecimal,
referrer_id: impl Into<String>,
) -> Self {
Self::new(account_id, amount, "Referral reward")
.with_type(CreditType::Referral)
.with_reference(referrer_id)
}
/// Create SLA compensation credit
pub fn sla_compensation(
account_id: impl Into<String>,
amount: SynorDecimal,
incident_id: impl Into<String>,
) -> Self {
Self::new(account_id, amount, "SLA violation compensation")
.with_type(CreditType::SlaCompensation)
.with_reference(incident_id)
}
/// Set credit type
pub fn with_type(mut self, credit_type: CreditType) -> Self {
self.credit_type = credit_type;
self
}
/// Set expiration date
pub fn with_expiry(mut self, expires_at: DateTime<Utc>) -> Self {
self.expires_at = Some(expires_at);
self
}
/// Set expiration days from now
pub fn with_expiry_days(mut self, days: i64) -> Self {
self.expires_at = Some(Utc::now() + Duration::days(days));
self
}
/// Set reference ID
pub fn with_reference(mut self, reference_id: impl Into<String>) -> Self {
self.reference_id = Some(reference_id.into());
self
}
/// Set approver
pub fn with_approver(mut self, approver: impl Into<String>) -> Self {
self.approved_by = Some(approver.into());
self
}
/// Use some of the credit
pub fn use_credit(&mut self, amount: SynorDecimal) -> Result<SynorDecimal, CreditError> {
if !self.is_active {
return Err(CreditError::CreditInactive);
}
if self.is_expired() {
self.is_active = false;
return Err(CreditError::CreditExpired);
}
let available = self.amount;
if amount > available {
return Err(CreditError::InsufficientCredit {
requested: amount,
available,
});
}
self.amount -= amount;
self.used_amount += amount;
if self.amount == Decimal::ZERO {
self.is_active = false;
}
Ok(amount)
}
/// Check if credit is expired
pub fn is_expired(&self) -> bool {
self.expires_at
.map(|exp| Utc::now() > exp)
.unwrap_or(false)
}
/// Get remaining amount
pub fn remaining(&self) -> SynorDecimal {
if self.is_expired() || !self.is_active {
Decimal::ZERO
} else {
self.amount
}
}
/// Get days until expiration
pub fn days_until_expiry(&self) -> Option<i64> {
self.expires_at.map(|exp| {
let diff = exp - Utc::now();
diff.num_days()
})
}
}
/// Generate unique credit ID
fn generate_credit_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("crd_{:x}", nanos)
}
/// Credit errors
#[derive(Debug, Clone, PartialEq)]
pub enum CreditError {
/// Credit is no longer active
CreditInactive,
/// Credit has expired
CreditExpired,
/// Insufficient credit balance
InsufficientCredit {
requested: SynorDecimal,
available: SynorDecimal,
},
}
impl std::fmt::Display for CreditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CreditError::CreditInactive => write!(f, "Credit is no longer active"),
CreditError::CreditExpired => write!(f, "Credit has expired"),
CreditError::InsufficientCredit { requested, available } => {
write!(
f,
"Insufficient credit: requested {}, available {}",
requested, available
)
}
}
}
}
impl std::error::Error for CreditError {}
/// Credit policy configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditPolicy {
/// Welcome credit amount
pub welcome_amount: SynorDecimal,
/// Referral credit for referrer
pub referral_referrer_amount: SynorDecimal,
/// Referral credit for referee
pub referral_referee_amount: SynorDecimal,
/// Maximum credit per account
pub max_credit_per_account: SynorDecimal,
/// Default expiry days
pub default_expiry_days: i64,
}
impl Default for CreditPolicy {
fn default() -> Self {
Self {
welcome_amount: Decimal::new(10, 0), // 10 SYNOR
referral_referrer_amount: Decimal::new(25, 0), // 25 SYNOR
referral_referee_amount: Decimal::new(10, 0), // 10 SYNOR
max_credit_per_account: Decimal::new(1000, 0), // 1000 SYNOR
default_expiry_days: 365,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_credit_creation() {
let credit = Credit::new("account_123", dec!(50), "Test credit");
assert_eq!(credit.account_id, "account_123");
assert_eq!(credit.amount, dec!(50));
assert_eq!(credit.original_amount, dec!(50));
assert!(credit.is_active);
}
#[test]
fn test_welcome_credit() {
let credit = Credit::welcome("new_user", dec!(10));
assert_eq!(credit.credit_type, CreditType::Welcome);
assert!(credit.expires_at.is_some());
assert!(credit.days_until_expiry().unwrap() <= 90);
}
#[test]
fn test_use_credit() {
let mut credit = Credit::new("test", dec!(100), "Test");
// Use partial
let used = credit.use_credit(dec!(30)).unwrap();
assert_eq!(used, dec!(30));
assert_eq!(credit.amount, dec!(70));
assert_eq!(credit.used_amount, dec!(30));
assert!(credit.is_active);
// Use remaining
let used = credit.use_credit(dec!(70)).unwrap();
assert_eq!(used, dec!(70));
assert_eq!(credit.amount, dec!(0));
assert!(!credit.is_active);
}
#[test]
fn test_insufficient_credit() {
let mut credit = Credit::new("test", dec!(50), "Test");
let result = credit.use_credit(dec!(100));
assert!(matches!(
result,
Err(CreditError::InsufficientCredit { .. })
));
}
#[test]
fn test_referral_credit() {
let credit = Credit::referral("new_user", dec!(25), "referrer_123");
assert_eq!(credit.credit_type, CreditType::Referral);
assert_eq!(credit.reference_id, Some("referrer_123".to_string()));
}
}

View file

@ -0,0 +1,344 @@
//! Invoice Management
use crate::{AccountId, ServiceType, SynorDecimal};
use chrono::{DateTime, Datelike, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Invoice status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InvoiceStatus {
/// Invoice created, awaiting payment
Draft,
/// Invoice finalized and sent
Pending,
/// Payment received
Paid,
/// Payment failed or declined
Failed,
/// Invoice voided/cancelled
Voided,
/// Payment overdue
Overdue,
}
impl InvoiceStatus {
/// Check if this status represents a completed invoice
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Paid | Self::Voided)
}
}
impl std::fmt::Display for InvoiceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InvoiceStatus::Draft => write!(f, "draft"),
InvoiceStatus::Pending => write!(f, "pending"),
InvoiceStatus::Paid => write!(f, "paid"),
InvoiceStatus::Failed => write!(f, "failed"),
InvoiceStatus::Voided => write!(f, "voided"),
InvoiceStatus::Overdue => write!(f, "overdue"),
}
}
}
/// Invoice line item
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvoiceLineItem {
/// Description of the service/charge
pub description: String,
/// Service type
pub service_type: ServiceType,
/// Quantity
pub quantity: SynorDecimal,
/// Unit price in SYNOR
pub unit_price: SynorDecimal,
/// Line item total (quantity * unit_price)
pub amount: SynorDecimal,
}
impl InvoiceLineItem {
/// Create a new line item
pub fn new(
description: impl Into<String>,
service_type: ServiceType,
quantity: SynorDecimal,
unit_price: SynorDecimal,
) -> Self {
Self {
description: description.into(),
service_type,
quantity,
unit_price,
amount: quantity * unit_price,
}
}
}
/// Invoice document
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invoice {
/// Unique invoice ID
pub id: String,
/// Invoice number (human-readable)
pub number: String,
/// Account being billed
pub account_id: AccountId,
/// Invoice status
pub status: InvoiceStatus,
/// Billing period start
pub period_start: DateTime<Utc>,
/// Billing period end
pub period_end: DateTime<Utc>,
/// Line items
pub line_items: Vec<InvoiceLineItem>,
/// Subtotal before discounts
pub subtotal: SynorDecimal,
/// Discount amount
pub discount: SynorDecimal,
/// Discount description
pub discount_description: Option<String>,
/// Tax amount (if applicable)
pub tax: SynorDecimal,
/// Total amount due
pub total: SynorDecimal,
/// Currency (always SYNOR)
pub currency: String,
/// Due date
pub due_date: DateTime<Utc>,
/// Date created
pub created_at: DateTime<Utc>,
/// Date paid (if paid)
pub paid_at: Option<DateTime<Utc>>,
/// Payment ID (if paid)
pub payment_id: Option<String>,
/// Notes/memo
pub notes: Option<String>,
}
impl Invoice {
/// Create a new invoice
pub fn new(account_id: impl Into<String>) -> Self {
let id = generate_invoice_id();
let number = generate_invoice_number();
Self {
id,
number,
account_id: account_id.into(),
status: InvoiceStatus::Draft,
period_start: Utc::now(),
period_end: Utc::now(),
line_items: Vec::new(),
subtotal: Decimal::ZERO,
discount: Decimal::ZERO,
discount_description: None,
tax: Decimal::ZERO,
total: Decimal::ZERO,
currency: "SYNOR".to_string(),
due_date: Utc::now(),
created_at: Utc::now(),
paid_at: None,
payment_id: None,
notes: None,
}
}
/// Set billing period
pub fn with_period(mut self, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
self.period_start = start;
self.period_end = end;
self
}
/// Add line items
pub fn with_line_items(mut self, items: Vec<InvoiceLineItem>) -> Self {
self.line_items = items;
self
}
/// Add a single line item
pub fn add_line_item(mut self, item: InvoiceLineItem) -> Self {
self.line_items.push(item);
self
}
/// Set subtotal
pub fn with_subtotal(mut self, subtotal: SynorDecimal) -> Self {
self.subtotal = subtotal;
self
}
/// Set discount
pub fn with_discount(mut self, discount: SynorDecimal) -> Self {
self.discount = discount;
self
}
/// Set discount with description
pub fn with_discount_desc(
mut self,
discount: SynorDecimal,
description: impl Into<String>,
) -> Self {
self.discount = discount;
self.discount_description = Some(description.into());
self
}
/// Set total
pub fn with_total(mut self, total: SynorDecimal) -> Self {
self.total = total;
self
}
/// Set due date
pub fn with_due_date(mut self, due_date: DateTime<Utc>) -> Self {
self.due_date = due_date;
self
}
/// Set notes
pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
self.notes = Some(notes.into());
self
}
/// Finalize invoice (move from draft to pending)
pub fn finalize(&mut self) {
if self.status == InvoiceStatus::Draft {
self.status = InvoiceStatus::Pending;
}
}
/// Mark invoice as paid
pub fn mark_paid(&mut self, payment_id: String) {
self.status = InvoiceStatus::Paid;
self.paid_at = Some(Utc::now());
self.payment_id = Some(payment_id);
}
/// Mark invoice as void
pub fn void(&mut self) {
self.status = InvoiceStatus::Voided;
}
/// Check if overdue and update status
pub fn check_overdue(&mut self) {
if self.status == InvoiceStatus::Pending && Utc::now() > self.due_date {
self.status = InvoiceStatus::Overdue;
}
}
/// Check if invoice is paid
pub fn is_paid(&self) -> bool {
self.status == InvoiceStatus::Paid
}
/// Check if invoice is overdue
pub fn is_overdue(&self) -> bool {
self.status == InvoiceStatus::Overdue
|| (self.status == InvoiceStatus::Pending && Utc::now() > self.due_date)
}
/// Calculate invoice from line items
pub fn calculate(&mut self) {
self.subtotal = self.line_items.iter().map(|item| item.amount).sum();
self.total = self.subtotal - self.discount + self.tax;
}
/// Get amount remaining to be paid
pub fn amount_due(&self) -> SynorDecimal {
if self.is_paid() {
Decimal::ZERO
} else {
self.total
}
}
}
/// Generate unique invoice ID
fn generate_invoice_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("inv_{:x}", nanos)
}
/// Generate human-readable invoice number
fn generate_invoice_number() -> String {
let now = Utc::now();
let seq = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.subsec_micros();
format!("SYN-{:04}{:02}-{:06}", now.year(), now.month(), seq)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_invoice_creation() {
let invoice = Invoice::new("account_123")
.with_subtotal(dec!(100))
.with_discount(dec!(10))
.with_total(dec!(90));
assert_eq!(invoice.account_id, "account_123");
assert_eq!(invoice.subtotal, dec!(100));
assert_eq!(invoice.discount, dec!(10));
assert_eq!(invoice.total, dec!(90));
assert_eq!(invoice.status, InvoiceStatus::Draft);
}
#[test]
fn test_invoice_finalization() {
let mut invoice = Invoice::new("account_123");
assert_eq!(invoice.status, InvoiceStatus::Draft);
invoice.finalize();
assert_eq!(invoice.status, InvoiceStatus::Pending);
}
#[test]
fn test_invoice_payment() {
let mut invoice = Invoice::new("account_123").with_total(dec!(50));
invoice.finalize();
invoice.mark_paid("pay_123".to_string());
assert!(invoice.is_paid());
assert_eq!(invoice.payment_id, Some("pay_123".to_string()));
assert!(invoice.paid_at.is_some());
}
#[test]
fn test_line_item() {
let item = InvoiceLineItem::new(
"Storage L2",
ServiceType::Storage,
dec!(10),
dec!(0.02),
);
assert_eq!(item.amount, dec!(0.20));
}
#[test]
fn test_invoice_calculate() {
let mut invoice = Invoice::new("test")
.add_line_item(InvoiceLineItem::new("Storage", ServiceType::Storage, dec!(100), dec!(0.02)))
.add_line_item(InvoiceLineItem::new("Compute", ServiceType::Compute, dec!(10), dec!(0.50)));
invoice.discount = dec!(1);
invoice.calculate();
assert_eq!(invoice.subtotal, dec!(7)); // 2 + 5
assert_eq!(invoice.total, dec!(6)); // 7 - 1
}
}

View file

@ -0,0 +1,576 @@
//! Billing Engine
//!
//! Invoice generation, payment processing, and credit management for Synor services.
mod credit;
mod invoice;
mod payment;
pub use credit::Credit;
pub use invoice::{Invoice, InvoiceLineItem, InvoiceStatus};
pub use payment::{Payment, PaymentMethod, PaymentStatus};
use crate::error::{EconomicsError, Result};
use crate::metering::MeteringService;
use crate::pricing::PricingEngine;
use crate::{AccountId, FeeDistribution, ServiceType, SynorDecimal};
use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Billing engine configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillingConfig {
/// Billing cycle in days (default: 30)
pub billing_cycle_days: u32,
/// Grace period for late payments in days
pub grace_period_days: u32,
/// Minimum invoice amount (skip if below)
pub minimum_invoice_amount: SynorDecimal,
/// Auto-pay threshold (if prepaid balance allows)
pub auto_pay_enabled: bool,
/// Send invoice reminders
pub reminders_enabled: bool,
/// Days before due date to send reminder
pub reminder_days_before: u32,
}
impl Default for BillingConfig {
fn default() -> Self {
Self {
billing_cycle_days: 30,
grace_period_days: 7,
minimum_invoice_amount: Decimal::new(1, 2), // 0.01 SYNOR
auto_pay_enabled: true,
reminders_enabled: true,
reminder_days_before: 3,
}
}
}
/// Account billing information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountBillingInfo {
pub account_id: AccountId,
pub balance: SynorDecimal,
pub credit_balance: SynorDecimal,
pub prepaid_balance: SynorDecimal,
pub tier_name: String,
pub last_payment: Option<DateTime<Utc>>,
pub next_invoice: Option<DateTime<Utc>>,
pub outstanding_invoices: Vec<String>,
}
/// Billing engine for managing invoices and payments
pub struct BillingEngine {
config: BillingConfig,
metering: Arc<MeteringService>,
pricing: Arc<PricingEngine>,
/// Account billing data
accounts: RwLock<HashMap<AccountId, AccountData>>,
/// Invoices by ID
invoices: RwLock<HashMap<String, Invoice>>,
/// Payments by ID
payments: RwLock<HashMap<String, Payment>>,
/// Credits by ID
credits: RwLock<HashMap<String, Credit>>,
}
/// Internal account data
#[derive(Debug, Clone)]
struct AccountData {
account_id: AccountId,
prepaid_balance: SynorDecimal,
credit_balance: SynorDecimal,
tier: String,
billing_cycle_start: DateTime<Utc>,
created_at: DateTime<Utc>,
last_payment: Option<DateTime<Utc>>,
invoice_ids: Vec<String>,
}
impl BillingEngine {
/// Create a new billing engine
pub fn new(metering: Arc<MeteringService>, pricing: Arc<PricingEngine>) -> Self {
Self {
config: BillingConfig::default(),
metering,
pricing,
accounts: RwLock::new(HashMap::new()),
invoices: RwLock::new(HashMap::new()),
payments: RwLock::new(HashMap::new()),
credits: RwLock::new(HashMap::new()),
}
}
/// Create with custom configuration
pub fn with_config(
config: BillingConfig,
metering: Arc<MeteringService>,
pricing: Arc<PricingEngine>,
) -> Self {
Self {
config,
metering,
pricing,
accounts: RwLock::new(HashMap::new()),
invoices: RwLock::new(HashMap::new()),
payments: RwLock::new(HashMap::new()),
credits: RwLock::new(HashMap::new()),
}
}
/// Register a new account
pub async fn register_account(&self, account_id: &str, tier: &str) -> Result<()> {
let mut accounts = self.accounts.write().await;
if accounts.contains_key(account_id) {
return Err(EconomicsError::Internal(format!(
"Account {} already registered",
account_id
)));
}
let now = Utc::now();
accounts.insert(
account_id.to_string(),
AccountData {
account_id: account_id.to_string(),
prepaid_balance: Decimal::ZERO,
credit_balance: Decimal::ZERO,
tier: tier.to_string(),
billing_cycle_start: now,
created_at: now,
last_payment: None,
invoice_ids: Vec::new(),
},
);
Ok(())
}
/// Get account billing information
pub async fn get_account_info(&self, account_id: &str) -> Result<AccountBillingInfo> {
let accounts = self.accounts.read().await;
let invoices = self.invoices.read().await;
let account = accounts
.get(account_id)
.ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?;
let outstanding: Vec<_> = account
.invoice_ids
.iter()
.filter(|id| {
invoices
.get(*id)
.map(|inv| !inv.is_paid())
.unwrap_or(false)
})
.cloned()
.collect();
let next_invoice = account.billing_cycle_start
+ Duration::days(self.config.billing_cycle_days as i64);
Ok(AccountBillingInfo {
account_id: account_id.to_string(),
balance: account.prepaid_balance + account.credit_balance,
credit_balance: account.credit_balance,
prepaid_balance: account.prepaid_balance,
tier_name: account.tier.clone(),
last_payment: account.last_payment,
next_invoice: Some(next_invoice),
outstanding_invoices: outstanding,
})
}
/// Add prepaid balance to account
pub async fn add_prepaid(&self, account_id: &str, amount: SynorDecimal) -> Result<()> {
let mut accounts = self.accounts.write().await;
let account = accounts
.get_mut(account_id)
.ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?;
account.prepaid_balance += amount;
tracing::info!(
"Added {} SYNOR prepaid to account {}",
amount,
account_id
);
Ok(())
}
/// Add credit to account
pub async fn add_credit(&self, account_id: &str, credit: Credit) -> Result<()> {
let mut accounts = self.accounts.write().await;
let mut credits = self.credits.write().await;
let account = accounts
.get_mut(account_id)
.ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?;
account.credit_balance += credit.amount;
credits.insert(credit.id.clone(), credit.clone());
tracing::info!(
"Added {} SYNOR credit to account {}: {}",
credit.amount,
account_id,
credit.reason
);
Ok(())
}
/// Generate invoice for an account
pub async fn generate_invoice(&self, account_id: &str) -> Result<Invoice> {
let mut accounts = self.accounts.write().await;
let mut invoices = self.invoices.write().await;
let account = accounts
.get_mut(account_id)
.ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?;
let period_start = account.billing_cycle_start;
let period_end = Utc::now();
// Get usage for the period
let usage = self
.metering
.get_period_usage(account_id, period_start, period_end)
.await?;
// Generate line items
let mut line_items = Vec::new();
let mut subtotal = Decimal::ZERO;
for (service_type, cost) in usage.by_service.iter() {
let description = match service_type {
ServiceType::Storage => "Storage L2 - Data storage and retrieval",
ServiceType::Hosting => "Hosting - Web hosting and bandwidth",
ServiceType::Database => "Database L2 - Queries and storage",
ServiceType::Compute => "Compute L2 - CPU/GPU processing",
ServiceType::Network => "Network - Bandwidth usage",
ServiceType::Contract => "Smart Contracts - Gas fees",
};
line_items.push(InvoiceLineItem {
description: description.to_string(),
service_type: *service_type,
quantity: Decimal::ONE,
unit_price: *cost,
amount: *cost,
});
subtotal += *cost;
}
// Apply discounts from tier
let discount = self
.pricing
.calculate_tier_discount(&account.tier, subtotal)?;
let discounted_total = subtotal - discount;
// Calculate fee distribution
let distribution = FeeDistribution::l2_service_fees();
let _fees = distribution.distribute(discounted_total);
// Skip if below minimum
if discounted_total < self.config.minimum_invoice_amount {
return Err(EconomicsError::Internal(format!(
"Invoice amount {} below minimum {}",
discounted_total, self.config.minimum_invoice_amount
)));
}
// Create invoice
let due_date = Utc::now() + Duration::days(self.config.billing_cycle_days as i64);
let invoice = Invoice::new(account_id)
.with_period(period_start, period_end)
.with_line_items(line_items)
.with_subtotal(subtotal)
.with_discount(discount)
.with_total(discounted_total)
.with_due_date(due_date);
// Store invoice
invoices.insert(invoice.id.clone(), invoice.clone());
account.invoice_ids.push(invoice.id.clone());
// Reset billing cycle
account.billing_cycle_start = Utc::now();
// Reset metering for new period
self.metering.reset_period(account_id).await?;
tracing::info!(
"Generated invoice {} for account {}: {} SYNOR",
invoice.id,
account_id,
discounted_total
);
Ok(invoice)
}
/// Process a payment
pub async fn process_payment(&self, payment: Payment) -> Result<()> {
let mut accounts = self.accounts.write().await;
let mut invoices = self.invoices.write().await;
let mut payments = self.payments.write().await;
let account = accounts
.get_mut(&payment.account_id)
.ok_or_else(|| EconomicsError::AccountNotFound(payment.account_id.clone()))?;
// Validate invoice if specified
if let Some(ref invoice_id) = payment.invoice_id {
let invoice = invoices
.get_mut(invoice_id)
.ok_or_else(|| EconomicsError::InvoiceNotFound(invoice_id.clone()))?;
if invoice.is_paid() {
return Err(EconomicsError::InvoiceAlreadyPaid(invoice_id.clone()));
}
if payment.amount < invoice.total {
return Err(EconomicsError::PaymentFailed(format!(
"Payment amount {} less than invoice total {}",
payment.amount, invoice.total
)));
}
// Mark invoice as paid
invoice.mark_paid(payment.id.clone());
}
// Update account
account.last_payment = Some(Utc::now());
// Store payment
payments.insert(payment.id.clone(), payment.clone());
tracing::info!(
"Processed payment {} for account {}: {} SYNOR",
payment.id,
payment.account_id,
payment.amount
);
Ok(())
}
/// Process payment from prepaid balance
pub async fn pay_from_prepaid(&self, account_id: &str, invoice_id: &str) -> Result<()> {
// Get invoice total first
let invoice_total = {
let invoices = self.invoices.read().await;
let invoice = invoices
.get(invoice_id)
.ok_or_else(|| EconomicsError::InvoiceNotFound(invoice_id.to_string()))?;
invoice.total
};
// Update account balances
{
let mut accounts = self.accounts.write().await;
let account = accounts
.get_mut(account_id)
.ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?;
let total_balance = account.prepaid_balance + account.credit_balance;
if total_balance < invoice_total {
return Err(EconomicsError::InsufficientBalance {
required: invoice_total.to_string(),
available: total_balance.to_string(),
});
}
// Use credits first, then prepaid
let mut remaining = invoice_total;
if account.credit_balance > Decimal::ZERO {
let credit_used = remaining.min(account.credit_balance);
account.credit_balance -= credit_used;
remaining -= credit_used;
}
if remaining > Decimal::ZERO {
account.prepaid_balance -= remaining;
}
}
// Create and process payment
let payment = Payment::new(account_id, invoice_total, PaymentMethod::PrepaidBalance)
.with_invoice(invoice_id);
self.process_payment(payment).await
}
/// Get invoice by ID
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Invoice> {
let invoices = self.invoices.read().await;
invoices
.get(invoice_id)
.cloned()
.ok_or_else(|| EconomicsError::InvoiceNotFound(invoice_id.to_string()))
}
/// Get all invoices for an account
pub async fn get_account_invoices(&self, account_id: &str) -> Result<Vec<Invoice>> {
let accounts = self.accounts.read().await;
let invoices = self.invoices.read().await;
let account = accounts
.get(account_id)
.ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?;
let result: Vec<_> = account
.invoice_ids
.iter()
.filter_map(|id| invoices.get(id).cloned())
.collect();
Ok(result)
}
/// Get unpaid invoices for an account
pub async fn get_unpaid_invoices(&self, account_id: &str) -> Result<Vec<Invoice>> {
let all_invoices = self.get_account_invoices(account_id).await?;
Ok(all_invoices.into_iter().filter(|inv| !inv.is_paid()).collect())
}
/// Generate all pending invoices (for batch processing)
pub async fn generate_all_pending_invoices(&self) -> Result<Vec<Invoice>> {
let account_ids: Vec<_> = {
let accounts = self.accounts.read().await;
accounts.keys().cloned().collect()
};
let mut generated = Vec::new();
let now = Utc::now();
for account_id in account_ids {
// Check if billing cycle has ended
let should_invoice = {
let accounts = self.accounts.read().await;
if let Some(account) = accounts.get(&account_id) {
let cycle_end = account.billing_cycle_start
+ Duration::days(self.config.billing_cycle_days as i64);
now >= cycle_end
} else {
false
}
};
if should_invoice {
match self.generate_invoice(&account_id).await {
Ok(invoice) => generated.push(invoice),
Err(e) => {
tracing::warn!("Failed to generate invoice for {}: {}", account_id, e);
}
}
}
}
Ok(generated)
}
/// Get billing statistics
pub async fn stats(&self) -> BillingStats {
let accounts = self.accounts.read().await;
let invoices = self.invoices.read().await;
let payments = self.payments.read().await;
let total_prepaid: Decimal = accounts.values().map(|a| a.prepaid_balance).sum();
let total_credits: Decimal = accounts.values().map(|a| a.credit_balance).sum();
let total_invoiced: Decimal = invoices.values().map(|i| i.total).sum();
let total_paid: Decimal = payments.values().map(|p| p.amount).sum();
let unpaid_count = invoices.values().filter(|i| !i.is_paid()).count();
BillingStats {
total_accounts: accounts.len(),
total_invoices: invoices.len(),
unpaid_invoices: unpaid_count,
total_prepaid_balance: total_prepaid,
total_credit_balance: total_credits,
total_invoiced,
total_paid,
outstanding_amount: total_invoiced - total_paid,
}
}
}
/// Billing statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillingStats {
pub total_accounts: usize,
pub total_invoices: usize,
pub unpaid_invoices: usize,
pub total_prepaid_balance: SynorDecimal,
pub total_credit_balance: SynorDecimal,
pub total_invoiced: SynorDecimal,
pub total_paid: SynorDecimal,
pub outstanding_amount: SynorDecimal,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
async fn setup_engine() -> BillingEngine {
let pricing = Arc::new(PricingEngine::new());
let metering = Arc::new(MeteringService::new(pricing.clone()));
BillingEngine::new(metering, pricing)
}
#[tokio::test]
async fn test_register_account() {
let engine = setup_engine().await;
engine.register_account("test_account", "standard").await.unwrap();
let info = engine.get_account_info("test_account").await.unwrap();
assert_eq!(info.account_id, "test_account");
assert_eq!(info.tier_name, "standard");
}
#[tokio::test]
async fn test_add_prepaid() {
let engine = setup_engine().await;
engine.register_account("prepaid_test", "standard").await.unwrap();
engine.add_prepaid("prepaid_test", dec!(100)).await.unwrap();
let info = engine.get_account_info("prepaid_test").await.unwrap();
assert_eq!(info.prepaid_balance, dec!(100));
}
#[tokio::test]
async fn test_add_credit() {
let engine = setup_engine().await;
engine.register_account("credit_test", "standard").await.unwrap();
let credit = Credit::new("credit_test", dec!(50), "Welcome bonus");
engine.add_credit("credit_test", credit).await.unwrap();
let info = engine.get_account_info("credit_test").await.unwrap();
assert_eq!(info.credit_balance, dec!(50));
}
#[tokio::test]
async fn test_account_not_found() {
let engine = setup_engine().await;
let result = engine.get_account_info("nonexistent").await;
assert!(matches!(result, Err(EconomicsError::AccountNotFound(_))));
}
}

View file

@ -0,0 +1,345 @@
//! Payment Processing
use crate::{AccountId, SynorDecimal};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Payment method
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaymentMethod {
/// Direct SYNOR transfer
SynorTransfer,
/// Prepaid account balance
PrepaidBalance,
/// Credit/promotional balance
CreditBalance,
/// Smart contract escrow
SmartContract,
/// Cross-chain bridge payment
CrossChain,
/// Fiat on-ramp (converted to SYNOR)
FiatOnRamp,
}
impl std::fmt::Display for PaymentMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PaymentMethod::SynorTransfer => write!(f, "SYNOR Transfer"),
PaymentMethod::PrepaidBalance => write!(f, "Prepaid Balance"),
PaymentMethod::CreditBalance => write!(f, "Credit Balance"),
PaymentMethod::SmartContract => write!(f, "Smart Contract"),
PaymentMethod::CrossChain => write!(f, "Cross-Chain"),
PaymentMethod::FiatOnRamp => write!(f, "Fiat On-Ramp"),
}
}
}
/// Payment status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaymentStatus {
/// Payment initiated
Pending,
/// Payment being processed
Processing,
/// Payment confirmed
Confirmed,
/// Payment completed successfully
Completed,
/// Payment failed
Failed,
/// Payment refunded
Refunded,
}
impl PaymentStatus {
/// Check if this is a terminal status
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Refunded)
}
/// Check if payment was successful
pub fn is_success(&self) -> bool {
matches!(self, Self::Completed)
}
}
/// Payment record
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Payment {
/// Unique payment ID
pub id: String,
/// Account making the payment
pub account_id: AccountId,
/// Invoice being paid (optional)
pub invoice_id: Option<String>,
/// Payment amount in SYNOR
pub amount: SynorDecimal,
/// Payment method used
pub method: PaymentMethod,
/// Payment status
pub status: PaymentStatus,
/// Transaction hash (for on-chain payments)
pub transaction_hash: Option<String>,
/// Block number (for on-chain payments)
pub block_number: Option<u64>,
/// Date created
pub created_at: DateTime<Utc>,
/// Date completed
pub completed_at: Option<DateTime<Utc>>,
/// Failure reason (if failed)
pub failure_reason: Option<String>,
/// Additional metadata
pub metadata: std::collections::HashMap<String, String>,
}
impl Payment {
/// Create a new payment
pub fn new(account_id: impl Into<String>, amount: SynorDecimal, method: PaymentMethod) -> Self {
Self {
id: generate_payment_id(),
account_id: account_id.into(),
invoice_id: None,
amount,
method,
status: PaymentStatus::Pending,
transaction_hash: None,
block_number: None,
created_at: Utc::now(),
completed_at: None,
failure_reason: None,
metadata: std::collections::HashMap::new(),
}
}
/// Associate with invoice
pub fn with_invoice(mut self, invoice_id: impl Into<String>) -> Self {
self.invoice_id = Some(invoice_id.into());
self
}
/// Add transaction hash
pub fn with_transaction(mut self, hash: impl Into<String>) -> Self {
self.transaction_hash = Some(hash.into());
self
}
/// Add block number
pub fn with_block(mut self, block: u64) -> Self {
self.block_number = Some(block);
self
}
/// Add metadata
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
/// Mark payment as processing
pub fn mark_processing(&mut self) {
self.status = PaymentStatus::Processing;
}
/// Mark payment as confirmed (awaiting finality)
pub fn mark_confirmed(&mut self, tx_hash: String) {
self.status = PaymentStatus::Confirmed;
self.transaction_hash = Some(tx_hash);
}
/// Mark payment as completed
pub fn mark_completed(&mut self) {
self.status = PaymentStatus::Completed;
self.completed_at = Some(Utc::now());
}
/// Mark payment as failed
pub fn mark_failed(&mut self, reason: impl Into<String>) {
self.status = PaymentStatus::Failed;
self.failure_reason = Some(reason.into());
self.completed_at = Some(Utc::now());
}
/// Mark payment as refunded
pub fn mark_refunded(&mut self) {
self.status = PaymentStatus::Refunded;
self.completed_at = Some(Utc::now());
}
/// Check if payment is complete
pub fn is_complete(&self) -> bool {
self.status == PaymentStatus::Completed
}
/// Check if payment is pending
pub fn is_pending(&self) -> bool {
matches!(
self.status,
PaymentStatus::Pending | PaymentStatus::Processing | PaymentStatus::Confirmed
)
}
}
/// Generate unique payment ID
fn generate_payment_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("pay_{:x}", nanos)
}
/// Payment processor for handling different payment methods
pub struct PaymentProcessor;
impl PaymentProcessor {
/// Process a SYNOR transfer payment
pub async fn process_synor_transfer(
payment: &mut Payment,
from_address: &str,
to_address: &str,
) -> Result<(), PaymentError> {
// In production, this would:
// 1. Create and broadcast a transaction
// 2. Wait for confirmation
// 3. Update payment status
payment.mark_processing();
// Simulate transaction
let tx_hash = format!("0x{:x}000000000000000000000000000000000000000000000000000000000000",
std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs());
payment.mark_confirmed(tx_hash);
// Add addresses to metadata
payment.metadata.insert("from".to_string(), from_address.to_string());
payment.metadata.insert("to".to_string(), to_address.to_string());
payment.mark_completed();
Ok(())
}
/// Process prepaid balance payment
pub async fn process_prepaid(
payment: &mut Payment,
available_balance: SynorDecimal,
) -> Result<(), PaymentError> {
if available_balance < payment.amount {
payment.mark_failed("Insufficient prepaid balance");
return Err(PaymentError::InsufficientBalance);
}
payment.mark_processing();
payment.mark_completed();
Ok(())
}
/// Validate payment request
pub fn validate(payment: &Payment) -> Result<(), PaymentError> {
if payment.amount <= Decimal::ZERO {
return Err(PaymentError::InvalidAmount);
}
if payment.account_id.is_empty() {
return Err(PaymentError::InvalidAccount);
}
Ok(())
}
}
/// Payment processing errors
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaymentError {
/// Invalid payment amount
InvalidAmount,
/// Invalid account
InvalidAccount,
/// Insufficient balance
InsufficientBalance,
/// Transaction failed
TransactionFailed(String),
/// Network error
NetworkError(String),
}
impl std::fmt::Display for PaymentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PaymentError::InvalidAmount => write!(f, "Invalid payment amount"),
PaymentError::InvalidAccount => write!(f, "Invalid account"),
PaymentError::InsufficientBalance => write!(f, "Insufficient balance"),
PaymentError::TransactionFailed(msg) => write!(f, "Transaction failed: {}", msg),
PaymentError::NetworkError(msg) => write!(f, "Network error: {}", msg),
}
}
}
impl std::error::Error for PaymentError {}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_payment_creation() {
let payment = Payment::new("account_123", dec!(50), PaymentMethod::SynorTransfer)
.with_invoice("inv_456");
assert_eq!(payment.account_id, "account_123");
assert_eq!(payment.amount, dec!(50));
assert_eq!(payment.invoice_id, Some("inv_456".to_string()));
assert_eq!(payment.status, PaymentStatus::Pending);
}
#[test]
fn test_payment_flow() {
let mut payment = Payment::new("test", dec!(100), PaymentMethod::SynorTransfer);
assert!(payment.is_pending());
payment.mark_processing();
assert!(payment.is_pending());
payment.mark_confirmed("0x123".to_string());
assert!(payment.is_pending());
payment.mark_completed();
assert!(payment.is_complete());
assert!(payment.completed_at.is_some());
}
#[test]
fn test_payment_failure() {
let mut payment = Payment::new("test", dec!(100), PaymentMethod::SynorTransfer);
payment.mark_failed("Insufficient funds");
assert!(!payment.is_complete());
assert_eq!(payment.failure_reason, Some("Insufficient funds".to_string()));
}
#[tokio::test]
async fn test_prepaid_processing() {
let mut payment = Payment::new("test", dec!(50), PaymentMethod::PrepaidBalance);
// Sufficient balance
let result = PaymentProcessor::process_prepaid(&mut payment, dec!(100)).await;
assert!(result.is_ok());
assert!(payment.is_complete());
// Insufficient balance
let mut payment2 = Payment::new("test", dec!(150), PaymentMethod::PrepaidBalance);
let result = PaymentProcessor::process_prepaid(&mut payment2, dec!(100)).await;
assert!(matches!(result, Err(PaymentError::InsufficientBalance)));
}
}

View file

@ -0,0 +1,649 @@
//! Cost Estimator
//!
//! Estimate costs for projected resource usage.
use crate::error::Result;
use crate::pricing::PricingEngine;
use crate::{ResourceUnit, ServiceType, SynorDecimal};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
/// Usage projection for cost estimation
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UsageProjection {
/// Projected storage in GB
pub storage_gb: Decimal,
/// Projected storage retrieval in GB
pub storage_retrieval_gb: Decimal,
/// Projected hosting bandwidth in GB
pub hosting_bandwidth_gb: Decimal,
/// Projected custom domains
pub custom_domains: u32,
/// Projected database storage in GB
pub database_storage_gb: Decimal,
/// Projected database queries (in millions)
pub database_queries_millions: Decimal,
/// Projected vector searches (in millions)
pub vector_searches_millions: Decimal,
/// Projected CPU core-hours
pub cpu_core_hours: Decimal,
/// Projected GPU hours
pub gpu_hours: Decimal,
/// Projected memory GB-hours
pub memory_gb_hours: Decimal,
/// Projected serverless invocations (in millions)
pub invocations_millions: Decimal,
/// Projected network bandwidth in GB
pub network_bandwidth_gb: Decimal,
/// Duration in months (for monthly costs)
pub duration_months: u32,
/// Pricing tier to apply
pub tier: Option<String>,
}
impl UsageProjection {
/// Create a new empty projection
pub fn new() -> Self {
Self {
duration_months: 1,
..Default::default()
}
}
/// Set storage usage
pub fn with_storage(mut self, storage_gb: Decimal, retrieval_gb: Decimal) -> Self {
self.storage_gb = storage_gb;
self.storage_retrieval_gb = retrieval_gb;
self
}
/// Set hosting usage
pub fn with_hosting(mut self, bandwidth_gb: Decimal, domains: u32) -> Self {
self.hosting_bandwidth_gb = bandwidth_gb;
self.custom_domains = domains;
self
}
/// Set database usage
pub fn with_database(
mut self,
storage_gb: Decimal,
queries_millions: Decimal,
vector_searches_millions: Decimal,
) -> Self {
self.database_storage_gb = storage_gb;
self.database_queries_millions = queries_millions;
self.vector_searches_millions = vector_searches_millions;
self
}
/// Set compute usage
pub fn with_compute(
mut self,
cpu_hours: Decimal,
gpu_hours: Decimal,
memory_gb_hours: Decimal,
invocations_millions: Decimal,
) -> Self {
self.cpu_core_hours = cpu_hours;
self.gpu_hours = gpu_hours;
self.memory_gb_hours = memory_gb_hours;
self.invocations_millions = invocations_millions;
self
}
/// Set network usage
pub fn with_network(mut self, bandwidth_gb: Decimal) -> Self {
self.network_bandwidth_gb = bandwidth_gb;
self
}
/// Set duration
pub fn for_months(mut self, months: u32) -> Self {
self.duration_months = months.max(1);
self
}
/// Set pricing tier
pub fn with_tier(mut self, tier: impl Into<String>) -> Self {
self.tier = Some(tier.into());
self
}
}
/// Detailed cost estimate
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostEstimate {
/// Total estimated cost
pub total: SynorDecimal,
/// Monthly cost (if duration > 1)
pub monthly: SynorDecimal,
/// Breakdown by service
pub by_service: HashMap<ServiceType, ServiceCostBreakdown>,
/// Free tier savings
pub free_tier_savings: SynorDecimal,
/// Tier discount applied
pub tier_discount: SynorDecimal,
/// Tier name used
pub tier: String,
/// Duration in months
pub duration_months: u32,
/// USD equivalent (if oracle available)
pub usd_equivalent: Option<Decimal>,
}
/// Cost breakdown for a single service
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceCostBreakdown {
/// Service type
pub service: ServiceType,
/// Line items
pub items: Vec<CostLineItem>,
/// Subtotal before discounts
pub subtotal: SynorDecimal,
/// Free tier credit applied
pub free_tier_credit: SynorDecimal,
/// Net cost
pub net_cost: SynorDecimal,
}
/// Individual cost line item
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostLineItem {
/// Resource description
pub description: String,
/// Resource unit
pub unit: ResourceUnit,
/// Quantity
pub quantity: Decimal,
/// Unit price
pub unit_price: SynorDecimal,
/// Line total
pub total: SynorDecimal,
}
/// Cost estimator service
pub struct CostEstimator {
pricing: Arc<PricingEngine>,
}
impl CostEstimator {
/// Create a new cost estimator
pub fn new(pricing: Arc<PricingEngine>) -> Self {
Self { pricing }
}
/// Estimate cost for a usage projection
pub async fn estimate(&self, projection: UsageProjection) -> Result<CostEstimate> {
let tier_name = projection.tier.clone().unwrap_or_else(|| "free".to_string());
let months = projection.duration_months.max(1);
let mut by_service = HashMap::new();
let mut total_free_savings = Decimal::ZERO;
// Storage costs
let storage_breakdown = self.estimate_storage(&projection)?;
total_free_savings += storage_breakdown.free_tier_credit;
by_service.insert(ServiceType::Storage, storage_breakdown);
// Hosting costs
let hosting_breakdown = self.estimate_hosting(&projection)?;
total_free_savings += hosting_breakdown.free_tier_credit;
by_service.insert(ServiceType::Hosting, hosting_breakdown);
// Database costs
let database_breakdown = self.estimate_database(&projection)?;
total_free_savings += database_breakdown.free_tier_credit;
by_service.insert(ServiceType::Database, database_breakdown);
// Compute costs
let compute_breakdown = self.estimate_compute(&projection)?;
total_free_savings += compute_breakdown.free_tier_credit;
by_service.insert(ServiceType::Compute, compute_breakdown);
// Network costs
let network_breakdown = self.estimate_network(&projection)?;
total_free_savings += network_breakdown.free_tier_credit;
by_service.insert(ServiceType::Network, network_breakdown);
// Calculate subtotal
let subtotal: Decimal = by_service.values().map(|b| b.net_cost).sum();
// Apply tier discount
let tier_discount = self.pricing.calculate_tier_discount(&tier_name, subtotal)?;
let total = subtotal - tier_discount;
// Monthly cost
let monthly = total / Decimal::from(months);
Ok(CostEstimate {
total,
monthly,
by_service,
free_tier_savings: total_free_savings,
tier_discount,
tier: tier_name,
duration_months: months,
usd_equivalent: None, // Would come from oracle
})
}
/// Estimate storage costs
fn estimate_storage(&self, projection: &UsageProjection) -> Result<ServiceCostBreakdown> {
let mut items = Vec::new();
let mut subtotal = Decimal::ZERO;
// Storage
if projection.storage_gb > Decimal::ZERO {
let price = self
.pricing
.get_base_price(ServiceType::Storage, ResourceUnit::GbMonth)
.unwrap_or(Decimal::new(2, 2));
let months = Decimal::from(projection.duration_months.max(1));
let total = projection.storage_gb * price * months;
items.push(CostLineItem {
description: "Data Storage".to_string(),
unit: ResourceUnit::GbMonth,
quantity: projection.storage_gb * months,
unit_price: price,
total,
});
subtotal += total;
}
// Retrieval
if projection.storage_retrieval_gb > Decimal::ZERO {
let price = self
.pricing
.get_base_price(ServiceType::Storage, ResourceUnit::BandwidthGb)
.unwrap_or(Decimal::new(1, 2));
let total = projection.storage_retrieval_gb * price;
items.push(CostLineItem {
description: "Data Retrieval".to_string(),
unit: ResourceUnit::BandwidthGb,
quantity: projection.storage_retrieval_gb,
unit_price: price,
total,
});
subtotal += total;
}
// Free tier
let free_storage = self
.pricing
.get_free_allocation(ServiceType::Storage, ResourceUnit::GbMonth);
let free_credit = (free_storage * Decimal::new(2, 2)).min(subtotal);
Ok(ServiceCostBreakdown {
service: ServiceType::Storage,
items,
subtotal,
free_tier_credit: free_credit,
net_cost: subtotal - free_credit,
})
}
/// Estimate hosting costs
fn estimate_hosting(&self, projection: &UsageProjection) -> Result<ServiceCostBreakdown> {
let mut items = Vec::new();
let mut subtotal = Decimal::ZERO;
// Bandwidth
if projection.hosting_bandwidth_gb > Decimal::ZERO {
let price = self
.pricing
.get_base_price(ServiceType::Hosting, ResourceUnit::BandwidthGb)
.unwrap_or(Decimal::new(5, 2));
let total = projection.hosting_bandwidth_gb * price;
items.push(CostLineItem {
description: "Hosting Bandwidth".to_string(),
unit: ResourceUnit::BandwidthGb,
quantity: projection.hosting_bandwidth_gb,
unit_price: price,
total,
});
subtotal += total;
}
// Custom domains
if projection.custom_domains > 0 {
let price = self
.pricing
.get_base_price(ServiceType::Hosting, ResourceUnit::Domains)
.unwrap_or(Decimal::new(50, 2));
let months = Decimal::from(projection.duration_months.max(1));
let total = Decimal::from(projection.custom_domains) * price * months;
items.push(CostLineItem {
description: "Custom Domains".to_string(),
unit: ResourceUnit::Domains,
quantity: Decimal::from(projection.custom_domains),
unit_price: price,
total,
});
subtotal += total;
}
// Free tier
let free_bandwidth = self
.pricing
.get_free_allocation(ServiceType::Hosting, ResourceUnit::BandwidthGb);
let free_credit = (free_bandwidth * Decimal::new(5, 2)).min(subtotal);
Ok(ServiceCostBreakdown {
service: ServiceType::Hosting,
items,
subtotal,
free_tier_credit: free_credit,
net_cost: subtotal - free_credit,
})
}
/// Estimate database costs
fn estimate_database(&self, projection: &UsageProjection) -> Result<ServiceCostBreakdown> {
let mut items = Vec::new();
let mut subtotal = Decimal::ZERO;
// Storage
if projection.database_storage_gb > Decimal::ZERO {
let price = self
.pricing
.get_base_price(ServiceType::Database, ResourceUnit::GbMonth)
.unwrap_or(Decimal::new(10, 2));
let months = Decimal::from(projection.duration_months.max(1));
let total = projection.database_storage_gb * price * months;
items.push(CostLineItem {
description: "Database Storage".to_string(),
unit: ResourceUnit::GbMonth,
quantity: projection.database_storage_gb * months,
unit_price: price,
total,
});
subtotal += total;
}
// Queries
if projection.database_queries_millions > Decimal::ZERO {
let price_per_query = self
.pricing
.get_base_price(ServiceType::Database, ResourceUnit::Queries)
.unwrap_or(Decimal::new(1, 8));
let queries = projection.database_queries_millions * Decimal::from(1_000_000);
let total = queries * price_per_query;
items.push(CostLineItem {
description: "Database Queries".to_string(),
unit: ResourceUnit::Queries,
quantity: queries,
unit_price: price_per_query,
total,
});
subtotal += total;
}
// Vector searches
if projection.vector_searches_millions > Decimal::ZERO {
let price_per_search = self
.pricing
.get_base_price(ServiceType::Database, ResourceUnit::VectorSearches)
.unwrap_or(Decimal::new(5, 8));
let searches = projection.vector_searches_millions * Decimal::from(1_000_000);
let total = searches * price_per_search;
items.push(CostLineItem {
description: "Vector Searches".to_string(),
unit: ResourceUnit::VectorSearches,
quantity: searches,
unit_price: price_per_search,
total,
});
subtotal += total;
}
// Free tier
let free_queries = self
.pricing
.get_free_allocation(ServiceType::Database, ResourceUnit::Queries);
let free_credit = (free_queries * Decimal::new(1, 8)).min(subtotal);
Ok(ServiceCostBreakdown {
service: ServiceType::Database,
items,
subtotal,
free_tier_credit: free_credit,
net_cost: subtotal - free_credit,
})
}
/// Estimate compute costs
fn estimate_compute(&self, projection: &UsageProjection) -> Result<ServiceCostBreakdown> {
let mut items = Vec::new();
let mut subtotal = Decimal::ZERO;
// CPU
if projection.cpu_core_hours > Decimal::ZERO {
let price = self
.pricing
.get_base_price(ServiceType::Compute, ResourceUnit::CpuCoreHours)
.unwrap_or(Decimal::new(2, 2));
let total = projection.cpu_core_hours * price;
items.push(CostLineItem {
description: "CPU Core-Hours".to_string(),
unit: ResourceUnit::CpuCoreHours,
quantity: projection.cpu_core_hours,
unit_price: price,
total,
});
subtotal += total;
}
// GPU
if projection.gpu_hours > Decimal::ZERO {
let price = self
.pricing
.get_base_price(ServiceType::Compute, ResourceUnit::GpuHours)
.unwrap_or(Decimal::new(50, 2));
let total = projection.gpu_hours * price;
items.push(CostLineItem {
description: "GPU Hours".to_string(),
unit: ResourceUnit::GpuHours,
quantity: projection.gpu_hours,
unit_price: price,
total,
});
subtotal += total;
}
// Memory
if projection.memory_gb_hours > Decimal::ZERO {
let price = self
.pricing
.get_base_price(ServiceType::Compute, ResourceUnit::MemoryGbHours)
.unwrap_or(Decimal::new(5, 3));
let total = projection.memory_gb_hours * price;
items.push(CostLineItem {
description: "Memory GB-Hours".to_string(),
unit: ResourceUnit::MemoryGbHours,
quantity: projection.memory_gb_hours,
unit_price: price,
total,
});
subtotal += total;
}
// Invocations
if projection.invocations_millions > Decimal::ZERO {
let price_per_inv = self
.pricing
.get_base_price(ServiceType::Compute, ResourceUnit::Invocations)
.unwrap_or(Decimal::new(2, 7));
let invocations = projection.invocations_millions * Decimal::from(1_000_000);
let total = invocations * price_per_inv;
items.push(CostLineItem {
description: "Serverless Invocations".to_string(),
unit: ResourceUnit::Invocations,
quantity: invocations,
unit_price: price_per_inv,
total,
});
subtotal += total;
}
// Free tier
let free_cpu = self
.pricing
.get_free_allocation(ServiceType::Compute, ResourceUnit::CpuCoreHours);
let free_credit = (free_cpu * Decimal::new(2, 2)).min(subtotal);
Ok(ServiceCostBreakdown {
service: ServiceType::Compute,
items,
subtotal,
free_tier_credit: free_credit,
net_cost: subtotal - free_credit,
})
}
/// Estimate network costs
fn estimate_network(&self, projection: &UsageProjection) -> Result<ServiceCostBreakdown> {
let mut items = Vec::new();
let mut subtotal = Decimal::ZERO;
if projection.network_bandwidth_gb > Decimal::ZERO {
let price = self
.pricing
.get_base_price(ServiceType::Network, ResourceUnit::BandwidthGb)
.unwrap_or(Decimal::new(1, 2));
let total = projection.network_bandwidth_gb * price;
items.push(CostLineItem {
description: "Network Bandwidth".to_string(),
unit: ResourceUnit::BandwidthGb,
quantity: projection.network_bandwidth_gb,
unit_price: price,
total,
});
subtotal += total;
}
// Free tier
let free_bandwidth = self
.pricing
.get_free_allocation(ServiceType::Network, ResourceUnit::BandwidthGb);
let free_credit = (free_bandwidth * Decimal::new(1, 2)).min(subtotal);
Ok(ServiceCostBreakdown {
service: ServiceType::Network,
items,
subtotal,
free_tier_credit: free_credit,
net_cost: subtotal - free_credit,
})
}
/// Quick estimate for a single service
pub fn quick_estimate(
&self,
service: ServiceType,
resource: ResourceUnit,
amount: Decimal,
) -> Result<SynorDecimal> {
self.pricing.calculate_cost(service, resource, amount)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn setup_estimator() -> CostEstimator {
let pricing = Arc::new(PricingEngine::new());
CostEstimator::new(pricing)
}
#[tokio::test]
async fn test_storage_estimate() {
let estimator = setup_estimator();
let projection = UsageProjection::new()
.with_storage(dec!(100), dec!(50)) // 100 GB storage, 50 GB retrieval
.for_months(1);
let estimate = estimator.estimate(projection).await.unwrap();
// Storage: 100 * 0.02 = 2.00
// Retrieval: 50 * 0.01 = 0.50
// Total before free tier: 2.50
assert!(estimate.total > Decimal::ZERO);
assert!(estimate.by_service.contains_key(&ServiceType::Storage));
}
#[tokio::test]
async fn test_compute_estimate() {
let estimator = setup_estimator();
let projection = UsageProjection::new()
.with_compute(dec!(100), dec!(10), dec!(200), dec!(5)) // 100 CPU hours, 10 GPU hours, 200 mem hours, 5M invocations
.for_months(1);
let estimate = estimator.estimate(projection).await.unwrap();
// CPU: 100 * 0.02 = 2.00
// GPU: 10 * 0.50 = 5.00
// Memory: 200 * 0.005 = 1.00
// Invocations: 5M * 0.0000002 = 1.00
// Total: ~9.00 before free tier
assert!(estimate.by_service.contains_key(&ServiceType::Compute));
let compute = &estimate.by_service[&ServiceType::Compute];
assert_eq!(compute.items.len(), 4);
}
#[tokio::test]
async fn test_tier_discount() {
let estimator = setup_estimator();
let projection = UsageProjection::new()
.with_storage(dec!(1000), dec!(500))
.with_tier("premium"); // 20% discount
let estimate = estimator.estimate(projection).await.unwrap();
assert_eq!(estimate.tier, "premium");
assert!(estimate.tier_discount > Decimal::ZERO);
}
#[test]
fn test_quick_estimate() {
let estimator = setup_estimator();
let cost = estimator
.quick_estimate(ServiceType::Compute, ResourceUnit::GpuHours, dec!(10))
.unwrap();
assert_eq!(cost, dec!(5.00)); // 10 * 0.50
}
}

View file

@ -0,0 +1,7 @@
//! Cost Calculator
//!
//! Estimate costs before resource consumption.
mod estimator;
pub use estimator::{CostEstimate, CostEstimator, UsageProjection};

View file

@ -0,0 +1,142 @@
//! Error types for Synor Economics
use thiserror::Error;
/// Result type for economics operations
pub type Result<T> = std::result::Result<T, EconomicsError>;
/// Economics error types
#[derive(Debug, Error)]
pub enum EconomicsError {
/// Price feed unavailable
#[error("Price feed unavailable: {0}")]
PriceFeedUnavailable(String),
/// Price too stale
#[error("Price data stale: last update {0} ago")]
PriceStale(String),
/// Invalid price
#[error("Invalid price: {0}")]
InvalidPrice(String),
/// Insufficient balance
#[error("Insufficient balance: required {required}, available {available}")]
InsufficientBalance {
required: String,
available: String,
},
/// Account not found
#[error("Account not found: {0}")]
AccountNotFound(String),
/// Invoice not found
#[error("Invoice not found: {0}")]
InvoiceNotFound(String),
/// Invoice already paid
#[error("Invoice {0} already paid")]
InvoiceAlreadyPaid(String),
/// Payment failed
#[error("Payment failed: {0}")]
PaymentFailed(String),
/// Invalid metering event
#[error("Invalid metering event: {0}")]
InvalidMeteringEvent(String),
/// Usage limit exceeded
#[error("Usage limit exceeded: {resource} limit is {limit}, requested {requested}")]
UsageLimitExceeded {
resource: String,
limit: String,
requested: String,
},
/// Invalid fee distribution
#[error("Invalid fee distribution: {0}")]
InvalidFeeDistribution(String),
/// Invalid pricing tier
#[error("Invalid pricing tier: {0}")]
InvalidPricingTier(String),
/// Discount expired
#[error("Discount {0} has expired")]
DiscountExpired(String),
/// Discount not applicable
#[error("Discount not applicable: {0}")]
DiscountNotApplicable(String),
/// Service not configured
#[error("Service not configured: {0}")]
ServiceNotConfigured(String),
/// Rate limit exceeded
#[error("Rate limit exceeded: {0}")]
RateLimitExceeded(String),
/// Serialization error
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
/// IO error
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
/// Database error
#[error("Database error: {0}")]
Database(String),
/// Configuration error
#[error("Configuration error: {0}")]
Configuration(String),
/// Internal error
#[error("Internal error: {0}")]
Internal(String),
}
impl EconomicsError {
/// Check if the error is retryable
pub fn is_retryable(&self) -> bool {
matches!(
self,
EconomicsError::PriceFeedUnavailable(_)
| EconomicsError::PaymentFailed(_)
| EconomicsError::Database(_)
| EconomicsError::Io(_)
| EconomicsError::RateLimitExceeded(_)
)
}
/// Get error code for API responses
pub fn error_code(&self) -> &'static str {
match self {
EconomicsError::PriceFeedUnavailable(_) => "PRICE_FEED_UNAVAILABLE",
EconomicsError::PriceStale(_) => "PRICE_STALE",
EconomicsError::InvalidPrice(_) => "INVALID_PRICE",
EconomicsError::InsufficientBalance { .. } => "INSUFFICIENT_BALANCE",
EconomicsError::AccountNotFound(_) => "ACCOUNT_NOT_FOUND",
EconomicsError::InvoiceNotFound(_) => "INVOICE_NOT_FOUND",
EconomicsError::InvoiceAlreadyPaid(_) => "INVOICE_ALREADY_PAID",
EconomicsError::PaymentFailed(_) => "PAYMENT_FAILED",
EconomicsError::InvalidMeteringEvent(_) => "INVALID_METERING_EVENT",
EconomicsError::UsageLimitExceeded { .. } => "USAGE_LIMIT_EXCEEDED",
EconomicsError::InvalidFeeDistribution(_) => "INVALID_FEE_DISTRIBUTION",
EconomicsError::InvalidPricingTier(_) => "INVALID_PRICING_TIER",
EconomicsError::DiscountExpired(_) => "DISCOUNT_EXPIRED",
EconomicsError::DiscountNotApplicable(_) => "DISCOUNT_NOT_APPLICABLE",
EconomicsError::ServiceNotConfigured(_) => "SERVICE_NOT_CONFIGURED",
EconomicsError::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED",
EconomicsError::Serialization(_) => "SERIALIZATION_ERROR",
EconomicsError::Io(_) => "IO_ERROR",
EconomicsError::Database(_) => "DATABASE_ERROR",
EconomicsError::Configuration(_) => "CONFIGURATION_ERROR",
EconomicsError::Internal(_) => "INTERNAL_ERROR",
}
}
}

View file

@ -0,0 +1,388 @@
//! # Synor Economics
//!
//! Economics, pricing, metering, and billing infrastructure for Synor L2 services.
//!
//! ## Architecture
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────┐
//! │ SYNOR ECONOMICS LAYER │
//! ├─────────────────────────────────────────────────────────────┤
//! │ Billing Engine: Invoice → Payment → Credit │
//! │ Metering: Storage | Hosting | Database | Compute | Network │
//! │ Pricing: Oracle → TWAP → Tiers → Discounts │
//! └─────────────────────────────────────────────────────────────┘
//! ```
//!
//! ## Components
//!
//! - **Pricing Oracle**: Aggregates SYNOR/USD prices from multiple sources
//! - **Metering Service**: Tracks real-time resource usage across L2 services
//! - **Billing Engine**: Generates invoices, processes payments, manages credits
//! - **Cost Calculator**: Estimates costs before resource consumption
pub mod billing;
pub mod calculator;
pub mod error;
pub mod metering;
pub mod oracle;
pub mod pricing;
pub use billing::{BillingEngine, Credit, Invoice, InvoiceStatus, Payment, PaymentMethod};
pub use calculator::{CostEstimate, CostEstimator, UsageProjection};
pub use error::{EconomicsError, Result};
pub use metering::{
ComputeUsage, DatabaseUsage, HostingUsage, MeteringService, NetworkUsage, StorageUsage,
UsageEvent, UsageRecord,
};
pub use oracle::{PriceFeed, PriceOracle, PriceSource, TokenPrice};
pub use pricing::{Discount, DiscountType, PricingEngine, PricingTier, ServicePricing};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Synor token amount in smallest unit (1 SYNOR = 10^18 units)
pub type SynorAmount = u128;
/// SYNOR decimal representation (for UI display)
pub type SynorDecimal = Decimal;
/// Account identifier
pub type AccountId = String;
/// Service identifier
pub type ServiceId = String;
/// Fee distribution configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeeDistribution {
/// Percentage burned (deflationary)
pub burn_rate: Decimal,
/// Percentage to stakers
pub staker_rate: Decimal,
/// Percentage to community treasury
pub treasury_rate: Decimal,
/// Percentage to validators/node operators
pub operator_rate: Decimal,
}
impl Default for FeeDistribution {
fn default() -> Self {
Self::transaction_fees()
}
}
impl FeeDistribution {
/// Transaction fee distribution (L1)
pub fn transaction_fees() -> Self {
Self {
burn_rate: Decimal::new(10, 2), // 10%
staker_rate: Decimal::new(60, 2), // 60%
treasury_rate: Decimal::new(20, 2), // 20%
operator_rate: Decimal::new(10, 2), // 10%
}
}
/// L2 service fee distribution
pub fn l2_service_fees() -> Self {
Self {
burn_rate: Decimal::new(10, 2), // 10%
staker_rate: Decimal::ZERO, // 0%
treasury_rate: Decimal::new(20, 2), // 20%
operator_rate: Decimal::new(70, 2), // 70%
}
}
/// Calculate distribution amounts from a total fee
pub fn distribute(&self, total: SynorDecimal) -> FeeBreakdown {
FeeBreakdown {
burn: total * self.burn_rate,
stakers: total * self.staker_rate,
treasury: total * self.treasury_rate,
operators: total * self.operator_rate,
total,
}
}
/// Validate that rates sum to 100%
pub fn validate(&self) -> Result<()> {
let sum = self.burn_rate + self.staker_rate + self.treasury_rate + self.operator_rate;
if sum != Decimal::ONE {
return Err(EconomicsError::InvalidFeeDistribution(format!(
"Rates must sum to 100%, got {}%",
sum * Decimal::ONE_HUNDRED
)));
}
Ok(())
}
}
/// Breakdown of fee distribution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeeBreakdown {
pub burn: SynorDecimal,
pub stakers: SynorDecimal,
pub treasury: SynorDecimal,
pub operators: SynorDecimal,
pub total: SynorDecimal,
}
/// Service type for metering and billing
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceType {
/// Storage L2
Storage,
/// Web hosting
Hosting,
/// Database L2
Database,
/// Compute L2 (CPU/GPU/TPU)
Compute,
/// Network bandwidth
Network,
/// Smart contract execution
Contract,
}
impl ServiceType {
/// Get the fee distribution model for this service type
pub fn fee_distribution(&self) -> FeeDistribution {
match self {
ServiceType::Contract => FeeDistribution::transaction_fees(),
_ => FeeDistribution::l2_service_fees(),
}
}
}
impl std::fmt::Display for ServiceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ServiceType::Storage => write!(f, "storage"),
ServiceType::Hosting => write!(f, "hosting"),
ServiceType::Database => write!(f, "database"),
ServiceType::Compute => write!(f, "compute"),
ServiceType::Network => write!(f, "network"),
ServiceType::Contract => write!(f, "contract"),
}
}
}
/// Resource unit for pricing and metering
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceUnit {
/// Storage: bytes
Bytes,
/// Storage: gigabytes per month
GbMonth,
/// Database: queries
Queries,
/// Database: vector searches
VectorSearches,
/// Compute: CPU core hours
CpuCoreHours,
/// Compute: GPU hours
GpuHours,
/// Compute: memory GB hours
MemoryGbHours,
/// Compute: serverless invocations
Invocations,
/// Network: bandwidth in GB
BandwidthGb,
/// Hosting: custom domains
Domains,
/// Contract: gas units
Gas,
}
impl std::fmt::Display for ResourceUnit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResourceUnit::Bytes => write!(f, "bytes"),
ResourceUnit::GbMonth => write!(f, "GB/month"),
ResourceUnit::Queries => write!(f, "queries"),
ResourceUnit::VectorSearches => write!(f, "vector searches"),
ResourceUnit::CpuCoreHours => write!(f, "CPU core hours"),
ResourceUnit::GpuHours => write!(f, "GPU hours"),
ResourceUnit::MemoryGbHours => write!(f, "memory GB hours"),
ResourceUnit::Invocations => write!(f, "invocations"),
ResourceUnit::BandwidthGb => write!(f, "GB bandwidth"),
ResourceUnit::Domains => write!(f, "domains"),
ResourceUnit::Gas => write!(f, "gas"),
}
}
}
/// Account balance and usage summary
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountSummary {
pub account_id: AccountId,
pub balance: SynorDecimal,
pub credit_balance: SynorDecimal,
pub prepaid_balance: SynorDecimal,
pub current_period_usage: HashMap<ServiceType, SynorDecimal>,
pub current_period_cost: SynorDecimal,
pub billing_tier: String,
pub last_payment: Option<DateTime<Utc>>,
pub next_invoice_date: Option<DateTime<Utc>>,
}
/// Unified economics manager combining all services
pub struct EconomicsManager {
pub oracle: Arc<RwLock<PriceOracle>>,
pub metering: Arc<MeteringService>,
pub billing: Arc<BillingEngine>,
pub pricing: Arc<PricingEngine>,
pub calculator: Arc<CostEstimator>,
}
impl EconomicsManager {
/// Create a new economics manager with default configuration
pub fn new() -> Self {
let oracle = Arc::new(RwLock::new(PriceOracle::new()));
let pricing = Arc::new(PricingEngine::new());
let metering = Arc::new(MeteringService::new(pricing.clone()));
let billing = Arc::new(BillingEngine::new(metering.clone(), pricing.clone()));
let calculator = Arc::new(CostEstimator::new(pricing.clone()));
Self {
oracle,
metering,
billing,
pricing,
calculator,
}
}
/// Get account summary including balance, usage, and billing info
pub async fn get_account_summary(&self, account_id: &str) -> Result<AccountSummary> {
let usage = self.metering.get_account_usage(account_id).await?;
let billing_info = self.billing.get_account_info(account_id).await?;
let mut current_period_usage = HashMap::new();
let mut current_period_cost = Decimal::ZERO;
for (service_type, amount) in usage.by_service.iter() {
current_period_usage.insert(*service_type, *amount);
current_period_cost += *amount;
}
Ok(AccountSummary {
account_id: account_id.to_string(),
balance: billing_info.balance,
credit_balance: billing_info.credit_balance,
prepaid_balance: billing_info.prepaid_balance,
current_period_usage,
current_period_cost,
billing_tier: billing_info.tier_name,
last_payment: billing_info.last_payment,
next_invoice_date: billing_info.next_invoice,
})
}
/// Estimate cost for a usage projection
pub async fn estimate_cost(&self, projection: UsageProjection) -> Result<CostEstimate> {
self.calculator.estimate(projection).await
}
/// Record usage event
pub async fn record_usage(&self, event: UsageEvent) -> Result<()> {
self.metering.record(event).await
}
/// Update price from external feed
pub async fn update_price(&self, price: TokenPrice) -> Result<()> {
let mut oracle = self.oracle.write().await;
oracle.update_price(price)
}
/// Get current SYNOR/USD price
pub async fn get_synor_price(&self) -> Result<SynorDecimal> {
let oracle = self.oracle.read().await;
oracle.get_price("SYNOR", "USD")
}
/// Generate invoice for account
pub async fn generate_invoice(&self, account_id: &str) -> Result<Invoice> {
self.billing.generate_invoice(account_id).await
}
/// Process payment
pub async fn process_payment(&self, payment: Payment) -> Result<()> {
self.billing.process_payment(payment).await
}
}
impl Default for EconomicsManager {
fn default() -> Self {
Self::new()
}
}
/// Convert raw amount to SYNOR decimal (18 decimals)
pub fn to_synor_decimal(raw: SynorAmount) -> SynorDecimal {
Decimal::from(raw) / Decimal::from(10u128.pow(18))
}
/// Convert SYNOR decimal to raw amount
pub fn from_synor_decimal(decimal: SynorDecimal) -> SynorAmount {
let scaled = decimal * Decimal::from(10u128.pow(18));
scaled.try_into().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_fee_distribution_transaction() {
let dist = FeeDistribution::transaction_fees();
assert!(dist.validate().is_ok());
let breakdown = dist.distribute(dec!(100));
assert_eq!(breakdown.burn, dec!(10));
assert_eq!(breakdown.stakers, dec!(60));
assert_eq!(breakdown.treasury, dec!(20));
assert_eq!(breakdown.operators, dec!(10));
}
#[test]
fn test_fee_distribution_l2() {
let dist = FeeDistribution::l2_service_fees();
assert!(dist.validate().is_ok());
let breakdown = dist.distribute(dec!(100));
assert_eq!(breakdown.burn, dec!(10));
assert_eq!(breakdown.stakers, dec!(0));
assert_eq!(breakdown.treasury, dec!(20));
assert_eq!(breakdown.operators, dec!(70));
}
#[test]
fn test_synor_decimal_conversion() {
// 1 SYNOR = 10^18 units
let one_synor = 1_000_000_000_000_000_000u128;
let decimal = to_synor_decimal(one_synor);
assert_eq!(decimal, Decimal::ONE);
let back = from_synor_decimal(decimal);
assert_eq!(back, one_synor);
}
#[test]
fn test_invalid_fee_distribution() {
let invalid = FeeDistribution {
burn_rate: dec!(0.50),
staker_rate: dec!(0.50),
treasury_rate: dec!(0.10),
operator_rate: dec!(0.10),
};
assert!(invalid.validate().is_err());
}
}

View file

@ -0,0 +1,137 @@
//! Compute L2 Usage Metering
use serde::{Deserialize, Serialize};
/// Compute service usage metrics
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComputeUsage {
/// CPU core-seconds consumed
pub cpu_core_seconds: u64,
/// GPU seconds consumed
pub gpu_seconds: u64,
/// Memory GB-seconds consumed
pub memory_gb_seconds: u64,
/// Number of serverless invocations
pub invocations: u64,
/// TPU seconds consumed
pub tpu_seconds: u64,
}
impl ComputeUsage {
/// Create new compute usage
pub fn new() -> Self {
Self::default()
}
/// Add CPU usage
pub fn with_cpu(mut self, core_seconds: u64) -> Self {
self.cpu_core_seconds = core_seconds;
self
}
/// Add GPU usage
pub fn with_gpu(mut self, seconds: u64) -> Self {
self.gpu_seconds = seconds;
self
}
/// Add memory usage
pub fn with_memory(mut self, gb_seconds: u64) -> Self {
self.memory_gb_seconds = gb_seconds;
self
}
/// Add serverless invocations
pub fn with_invocations(mut self, count: u64) -> Self {
self.invocations = count;
self
}
/// Add TPU usage
pub fn with_tpu(mut self, seconds: u64) -> Self {
self.tpu_seconds = seconds;
self
}
/// Get CPU core-hours
pub fn cpu_core_hours(&self) -> f64 {
self.cpu_core_seconds as f64 / 3600.0
}
/// Get GPU hours
pub fn gpu_hours(&self) -> f64 {
self.gpu_seconds as f64 / 3600.0
}
/// Get memory GB-hours
pub fn memory_gb_hours(&self) -> f64 {
self.memory_gb_seconds as f64 / 3600.0
}
/// Get TPU hours
pub fn tpu_hours(&self) -> f64 {
self.tpu_seconds as f64 / 3600.0
}
/// Get invocations in millions
pub fn invocations_millions(&self) -> f64 {
self.invocations as f64 / 1_000_000.0
}
}
/// GPU type for pricing differentiation
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GpuType {
/// NVIDIA RTX 3090
Rtx3090,
/// NVIDIA RTX 4090
Rtx4090,
/// NVIDIA A100
A100,
/// NVIDIA H100
H100,
/// AMD MI250
Mi250,
/// Generic GPU
Generic,
}
impl GpuType {
/// Get price multiplier relative to base GPU price
pub fn price_multiplier(&self) -> f64 {
match self {
GpuType::Rtx3090 => 0.8,
GpuType::Rtx4090 => 1.0,
GpuType::A100 => 2.5,
GpuType::H100 => 4.0,
GpuType::Mi250 => 2.0,
GpuType::Generic => 0.5,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_usage() {
let usage = ComputeUsage::new()
.with_cpu(7200) // 2 core-hours
.with_gpu(3600) // 1 GPU hour
.with_memory(14400) // 4 GB-hours
.with_invocations(1_000_000);
assert_eq!(usage.cpu_core_hours(), 2.0);
assert_eq!(usage.gpu_hours(), 1.0);
assert_eq!(usage.memory_gb_hours(), 4.0);
assert_eq!(usage.invocations_millions(), 1.0);
}
#[test]
fn test_gpu_pricing() {
assert!(GpuType::H100.price_multiplier() > GpuType::Rtx4090.price_multiplier());
assert!(GpuType::Rtx4090.price_multiplier() > GpuType::Generic.price_multiplier());
}
}

View file

@ -0,0 +1,89 @@
//! Database L2 Usage Metering
use serde::{Deserialize, Serialize};
/// Database service usage metrics
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DatabaseUsage {
/// Number of queries executed
pub queries: u64,
/// Number of vector searches
pub vector_searches: u64,
/// Storage used in bytes
pub storage_bytes: u64,
/// Number of documents stored
pub documents: u64,
/// Number of indexes maintained
pub indexes: u32,
}
impl DatabaseUsage {
/// Create new database usage
pub fn new() -> Self {
Self::default()
}
/// Add queries
pub fn with_queries(mut self, count: u64) -> Self {
self.queries = count;
self
}
/// Add vector searches
pub fn with_vector_searches(mut self, count: u64) -> Self {
self.vector_searches = count;
self
}
/// Add storage
pub fn with_storage(mut self, bytes: u64) -> Self {
self.storage_bytes = bytes;
self
}
/// Add documents
pub fn with_documents(mut self, count: u64) -> Self {
self.documents = count;
self
}
/// Add indexes
pub fn with_indexes(mut self, count: u32) -> Self {
self.indexes = count;
self
}
/// Convert storage to GB
pub fn storage_gb(&self) -> f64 {
self.storage_bytes as f64 / 1_073_741_824.0
}
/// Get queries in millions
pub fn queries_millions(&self) -> f64 {
self.queries as f64 / 1_000_000.0
}
/// Get vector searches in millions
pub fn vector_searches_millions(&self) -> f64 {
self.vector_searches as f64 / 1_000_000.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_database_usage() {
let usage = DatabaseUsage::new()
.with_queries(5_000_000)
.with_vector_searches(100_000)
.with_storage(536_870_912) // 0.5 GB
.with_documents(10_000)
.with_indexes(5);
assert_eq!(usage.queries_millions(), 5.0);
assert_eq!(usage.vector_searches_millions(), 0.1);
assert_eq!(usage.storage_gb(), 0.5);
}
}

View file

@ -0,0 +1,70 @@
//! Hosting Service Usage Metering
use serde::{Deserialize, Serialize};
/// Hosting service usage metrics
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostingUsage {
/// Bandwidth consumed in bytes
pub bandwidth_bytes: u64,
/// Number of custom domains active
pub custom_domains: u32,
/// Number of HTTP requests
pub requests: u64,
/// Number of unique visitors
pub unique_visitors: u64,
}
impl HostingUsage {
/// Create new hosting usage
pub fn new() -> Self {
Self::default()
}
/// Add bandwidth
pub fn with_bandwidth(mut self, bytes: u64) -> Self {
self.bandwidth_bytes = bytes;
self
}
/// Add custom domains
pub fn with_domains(mut self, count: u32) -> Self {
self.custom_domains = count;
self
}
/// Add requests
pub fn with_requests(mut self, count: u64) -> Self {
self.requests = count;
self
}
/// Add unique visitors
pub fn with_visitors(mut self, count: u64) -> Self {
self.unique_visitors = count;
self
}
/// Convert bandwidth to GB
pub fn bandwidth_gb(&self) -> f64 {
self.bandwidth_bytes as f64 / 1_073_741_824.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hosting_usage() {
let usage = HostingUsage::new()
.with_bandwidth(10_737_418_240) // 10 GB
.with_domains(3)
.with_requests(100_000)
.with_visitors(5_000);
assert_eq!(usage.bandwidth_gb(), 10.0);
assert_eq!(usage.custom_domains, 3);
assert_eq!(usage.requests, 100_000);
}
}

View file

@ -0,0 +1,559 @@
//! Metering Service
//!
//! Real-time usage tracking for all Synor L2 services:
//! - Storage L2: bytes stored, retrieved, deals created
//! - Hosting: bandwidth, domains
//! - Database L2: queries, vector searches, storage
//! - Compute L2: CPU hours, GPU hours, memory, invocations
//! - Network: bandwidth consumed
mod compute;
mod database;
mod hosting;
mod network;
mod storage;
pub use compute::ComputeUsage;
pub use database::DatabaseUsage;
pub use hosting::HostingUsage;
pub use network::NetworkUsage;
pub use storage::StorageUsage;
use crate::error::{EconomicsError, Result};
use crate::pricing::PricingEngine;
use crate::{AccountId, ResourceUnit, ServiceId, ServiceType, SynorDecimal};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Usage event from L2 services
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageEvent {
/// Unique event ID
pub id: String,
/// Account being charged
pub account_id: AccountId,
/// Service generating the usage
pub service_id: ServiceId,
/// Type of service
pub service_type: ServiceType,
/// Resource unit consumed
pub resource_unit: ResourceUnit,
/// Amount consumed
pub amount: Decimal,
/// Timestamp
pub timestamp: DateTime<Utc>,
/// Additional metadata
pub metadata: HashMap<String, String>,
}
impl UsageEvent {
/// Create a new usage event
pub fn new(
account_id: impl Into<String>,
service_type: ServiceType,
resource_unit: ResourceUnit,
amount: Decimal,
) -> Self {
Self {
id: generate_event_id(),
account_id: account_id.into(),
service_id: String::new(),
service_type,
resource_unit,
amount,
timestamp: Utc::now(),
metadata: HashMap::new(),
}
}
/// Add service ID
pub fn with_service_id(mut self, service_id: impl Into<String>) -> Self {
self.service_id = service_id.into();
self
}
/// Add metadata
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
}
/// Generate unique event ID
fn generate_event_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("evt_{:x}", nanos)
}
/// Aggregated usage record for a billing period
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageRecord {
pub account_id: AccountId,
pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>,
pub by_service: HashMap<ServiceType, SynorDecimal>,
pub by_resource: HashMap<ResourceUnit, Decimal>,
pub total_cost: SynorDecimal,
pub event_count: usize,
}
impl UsageRecord {
/// Create empty usage record for a period
pub fn new(account_id: impl Into<String>, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
Self {
account_id: account_id.into(),
period_start: start,
period_end: end,
by_service: HashMap::new(),
by_resource: HashMap::new(),
total_cost: Decimal::ZERO,
event_count: 0,
}
}
}
/// Account usage summary
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountUsage {
pub account_id: AccountId,
pub by_service: HashMap<ServiceType, SynorDecimal>,
pub current_period_start: DateTime<Utc>,
pub last_event: Option<DateTime<Utc>>,
}
/// Metering service configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeteringConfig {
/// Aggregation interval (default: 1 hour)
pub aggregation_interval_secs: u64,
/// Maximum events to buffer before flush
pub buffer_size: usize,
/// Enable real-time usage tracking
pub real_time_tracking: bool,
/// Event retention period in days
pub retention_days: u32,
}
impl Default for MeteringConfig {
fn default() -> Self {
Self {
aggregation_interval_secs: 3600, // 1 hour
buffer_size: 10000,
real_time_tracking: true,
retention_days: 90,
}
}
}
/// Metering service for tracking resource usage
pub struct MeteringService {
config: MeteringConfig,
pricing: Arc<PricingEngine>,
/// Buffered events pending aggregation
event_buffer: RwLock<Vec<UsageEvent>>,
/// Current period usage by account
current_usage: RwLock<HashMap<AccountId, AccountUsage>>,
/// Historical aggregated records
records: RwLock<Vec<UsageRecord>>,
/// Event channel for real-time processing
event_tx: async_channel::Sender<UsageEvent>,
event_rx: async_channel::Receiver<UsageEvent>,
}
impl MeteringService {
/// Create a new metering service
pub fn new(pricing: Arc<PricingEngine>) -> Self {
let (event_tx, event_rx) = async_channel::bounded(10000);
Self {
config: MeteringConfig::default(),
pricing,
event_buffer: RwLock::new(Vec::new()),
current_usage: RwLock::new(HashMap::new()),
records: RwLock::new(Vec::new()),
event_tx,
event_rx,
}
}
/// Create with custom configuration
pub fn with_config(pricing: Arc<PricingEngine>, config: MeteringConfig) -> Self {
let (event_tx, event_rx) = async_channel::bounded(config.buffer_size);
Self {
config,
pricing,
event_buffer: RwLock::new(Vec::new()),
current_usage: RwLock::new(HashMap::new()),
records: RwLock::new(Vec::new()),
event_tx,
event_rx,
}
}
/// Record a usage event
pub async fn record(&self, event: UsageEvent) -> Result<()> {
// Validate event
if event.amount <= Decimal::ZERO {
return Err(EconomicsError::InvalidMeteringEvent(
"Amount must be positive".to_string(),
));
}
// Calculate cost for this event
let cost = self.pricing.calculate_cost(
event.service_type,
event.resource_unit,
event.amount,
)?;
// Update current usage
{
let mut usage = self.current_usage.write().await;
let account_usage = usage.entry(event.account_id.clone()).or_insert_with(|| {
AccountUsage {
account_id: event.account_id.clone(),
by_service: HashMap::new(),
current_period_start: Utc::now(),
last_event: None,
}
});
*account_usage
.by_service
.entry(event.service_type)
.or_insert(Decimal::ZERO) += cost;
account_usage.last_event = Some(event.timestamp);
}
// Buffer event for aggregation
{
let mut buffer = self.event_buffer.write().await;
buffer.push(event.clone());
// Flush if buffer is full
if buffer.len() >= self.config.buffer_size {
self.flush_buffer(&mut buffer).await?;
}
}
// Send to event channel for real-time processing
if self.config.real_time_tracking {
let _ = self.event_tx.try_send(event);
}
Ok(())
}
/// Record storage usage
pub async fn record_storage(&self, account_id: &str, usage: StorageUsage) -> Result<()> {
// Storage: bytes stored
if usage.bytes_stored > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Storage,
ResourceUnit::Bytes,
Decimal::from(usage.bytes_stored),
)).await?;
}
// Storage: bytes retrieved
if usage.bytes_retrieved > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Storage,
ResourceUnit::BandwidthGb,
Decimal::from(usage.bytes_retrieved) / Decimal::from(1_073_741_824u64), // to GB
)).await?;
}
Ok(())
}
/// Record hosting usage
pub async fn record_hosting(&self, account_id: &str, usage: HostingUsage) -> Result<()> {
// Bandwidth
if usage.bandwidth_bytes > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Hosting,
ResourceUnit::BandwidthGb,
Decimal::from(usage.bandwidth_bytes) / Decimal::from(1_073_741_824u64),
)).await?;
}
// Custom domains
if usage.custom_domains > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Hosting,
ResourceUnit::Domains,
Decimal::from(usage.custom_domains),
)).await?;
}
Ok(())
}
/// Record database usage
pub async fn record_database(&self, account_id: &str, usage: DatabaseUsage) -> Result<()> {
// Queries
if usage.queries > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Database,
ResourceUnit::Queries,
Decimal::from(usage.queries),
)).await?;
}
// Vector searches
if usage.vector_searches > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Database,
ResourceUnit::VectorSearches,
Decimal::from(usage.vector_searches),
)).await?;
}
// Storage
if usage.storage_bytes > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Database,
ResourceUnit::GbMonth,
Decimal::from(usage.storage_bytes) / Decimal::from(1_073_741_824u64),
)).await?;
}
Ok(())
}
/// Record compute usage
pub async fn record_compute(&self, account_id: &str, usage: ComputeUsage) -> Result<()> {
// CPU hours
if usage.cpu_core_seconds > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Compute,
ResourceUnit::CpuCoreHours,
Decimal::from(usage.cpu_core_seconds) / Decimal::from(3600),
)).await?;
}
// GPU hours
if usage.gpu_seconds > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Compute,
ResourceUnit::GpuHours,
Decimal::from(usage.gpu_seconds) / Decimal::from(3600),
)).await?;
}
// Memory GB hours
if usage.memory_gb_seconds > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Compute,
ResourceUnit::MemoryGbHours,
Decimal::from(usage.memory_gb_seconds) / Decimal::from(3600),
)).await?;
}
// Invocations (serverless)
if usage.invocations > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Compute,
ResourceUnit::Invocations,
Decimal::from(usage.invocations),
)).await?;
}
Ok(())
}
/// Record network usage
pub async fn record_network(&self, account_id: &str, usage: NetworkUsage) -> Result<()> {
let total_bytes = usage.ingress_bytes + usage.egress_bytes;
if total_bytes > 0 {
self.record(UsageEvent::new(
account_id,
ServiceType::Network,
ResourceUnit::BandwidthGb,
Decimal::from(total_bytes) / Decimal::from(1_073_741_824u64),
)).await?;
}
Ok(())
}
/// Get current usage for an account
pub async fn get_account_usage(&self, account_id: &str) -> Result<AccountUsage> {
let usage = self.current_usage.read().await;
usage
.get(account_id)
.cloned()
.ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))
}
/// Get usage for a specific billing period
pub async fn get_period_usage(
&self,
account_id: &str,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<UsageRecord> {
// Aggregate from buffer and records
let mut record = UsageRecord::new(account_id, start, end);
// Check buffered events
let buffer = self.event_buffer.read().await;
for event in buffer.iter() {
if event.account_id == account_id
&& event.timestamp >= start
&& event.timestamp < end
{
let cost = self.pricing.calculate_cost(
event.service_type,
event.resource_unit,
event.amount,
)?;
*record
.by_service
.entry(event.service_type)
.or_insert(Decimal::ZERO) += cost;
*record
.by_resource
.entry(event.resource_unit)
.or_insert(Decimal::ZERO) += event.amount;
record.total_cost += cost;
record.event_count += 1;
}
}
Ok(record)
}
/// Flush event buffer to storage
async fn flush_buffer(&self, buffer: &mut Vec<UsageEvent>) -> Result<()> {
if buffer.is_empty() {
return Ok(());
}
tracing::info!("Flushing {} metering events", buffer.len());
// In production, this would persist to database
// For now, we just aggregate and clear
buffer.clear();
Ok(())
}
/// Reset usage for a new billing period
pub async fn reset_period(&self, account_id: &str) -> Result<()> {
let mut usage = self.current_usage.write().await;
if let Some(account_usage) = usage.get_mut(account_id) {
account_usage.by_service.clear();
account_usage.current_period_start = Utc::now();
}
Ok(())
}
/// Get event receiver for real-time processing
pub fn subscribe(&self) -> async_channel::Receiver<UsageEvent> {
self.event_rx.clone()
}
/// Get metering stats
pub async fn stats(&self) -> MeteringStats {
let buffer = self.event_buffer.read().await;
let usage = self.current_usage.read().await;
MeteringStats {
buffer_size: buffer.len(),
active_accounts: usage.len(),
total_events_buffered: buffer.len(),
}
}
}
/// Metering service statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeteringStats {
pub buffer_size: usize,
pub active_accounts: usize,
pub total_events_buffered: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[tokio::test]
async fn test_record_usage_event() {
let pricing = Arc::new(PricingEngine::new());
let metering = MeteringService::new(pricing);
let event = UsageEvent::new(
"account_123",
ServiceType::Storage,
ResourceUnit::Bytes,
dec!(1000000),
);
metering.record(event).await.unwrap();
let usage = metering.get_account_usage("account_123").await.unwrap();
assert!(usage.by_service.contains_key(&ServiceType::Storage));
}
#[tokio::test]
async fn test_storage_usage() {
let pricing = Arc::new(PricingEngine::new());
let metering = MeteringService::new(pricing);
let usage = StorageUsage {
bytes_stored: 1_000_000,
bytes_retrieved: 500_000,
deals_created: 1,
};
metering.record_storage("account_456", usage).await.unwrap();
let account_usage = metering.get_account_usage("account_456").await.unwrap();
assert!(account_usage.by_service.contains_key(&ServiceType::Storage));
}
#[tokio::test]
async fn test_invalid_amount() {
let pricing = Arc::new(PricingEngine::new());
let metering = MeteringService::new(pricing);
let event = UsageEvent::new(
"account_789",
ServiceType::Storage,
ResourceUnit::Bytes,
dec!(-100), // Invalid: negative
);
let result = metering.record(event).await;
assert!(result.is_err());
}
}

View file

@ -0,0 +1,85 @@
//! Network Usage Metering
use serde::{Deserialize, Serialize};
/// Network bandwidth usage metrics
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkUsage {
/// Ingress bytes (incoming)
pub ingress_bytes: u64,
/// Egress bytes (outgoing)
pub egress_bytes: u64,
/// Number of P2P connections
pub connections: u32,
/// Cross-shard messages sent
pub cross_shard_messages: u64,
}
impl NetworkUsage {
/// Create new network usage
pub fn new() -> Self {
Self::default()
}
/// Add ingress bandwidth
pub fn with_ingress(mut self, bytes: u64) -> Self {
self.ingress_bytes = bytes;
self
}
/// Add egress bandwidth
pub fn with_egress(mut self, bytes: u64) -> Self {
self.egress_bytes = bytes;
self
}
/// Add connections
pub fn with_connections(mut self, count: u32) -> Self {
self.connections = count;
self
}
/// Add cross-shard messages
pub fn with_cross_shard(mut self, count: u64) -> Self {
self.cross_shard_messages = count;
self
}
/// Get total bandwidth in bytes
pub fn total_bytes(&self) -> u64 {
self.ingress_bytes + self.egress_bytes
}
/// Get ingress in GB
pub fn ingress_gb(&self) -> f64 {
self.ingress_bytes as f64 / 1_073_741_824.0
}
/// Get egress in GB
pub fn egress_gb(&self) -> f64 {
self.egress_bytes as f64 / 1_073_741_824.0
}
/// Get total bandwidth in GB
pub fn total_gb(&self) -> f64 {
self.total_bytes() as f64 / 1_073_741_824.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_network_usage() {
let usage = NetworkUsage::new()
.with_ingress(2_147_483_648) // 2 GB
.with_egress(1_073_741_824) // 1 GB
.with_connections(50)
.with_cross_shard(1000);
assert_eq!(usage.ingress_gb(), 2.0);
assert_eq!(usage.egress_gb(), 1.0);
assert_eq!(usage.total_gb(), 3.0);
}
}

View file

@ -0,0 +1,71 @@
//! Storage L2 Usage Metering
use serde::{Deserialize, Serialize};
/// Storage service usage metrics
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StorageUsage {
/// Bytes stored during period
pub bytes_stored: u64,
/// Bytes retrieved during period
pub bytes_retrieved: u64,
/// Number of storage deals created
pub deals_created: u32,
}
impl StorageUsage {
/// Create new storage usage
pub fn new() -> Self {
Self::default()
}
/// Add bytes stored
pub fn with_stored(mut self, bytes: u64) -> Self {
self.bytes_stored = bytes;
self
}
/// Add bytes retrieved
pub fn with_retrieved(mut self, bytes: u64) -> Self {
self.bytes_retrieved = bytes;
self
}
/// Add deals created
pub fn with_deals(mut self, count: u32) -> Self {
self.deals_created = count;
self
}
/// Get total bytes (stored + retrieved)
pub fn total_bytes(&self) -> u64 {
self.bytes_stored + self.bytes_retrieved
}
/// Convert bytes stored to GB
pub fn stored_gb(&self) -> f64 {
self.bytes_stored as f64 / 1_073_741_824.0
}
/// Convert bytes retrieved to GB
pub fn retrieved_gb(&self) -> f64 {
self.bytes_retrieved as f64 / 1_073_741_824.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_usage() {
let usage = StorageUsage::new()
.with_stored(1_073_741_824) // 1 GB
.with_retrieved(536_870_912) // 0.5 GB
.with_deals(5);
assert_eq!(usage.stored_gb(), 1.0);
assert_eq!(usage.retrieved_gb(), 0.5);
assert_eq!(usage.deals_created, 5);
}
}

View file

@ -0,0 +1,453 @@
//! Price Oracle
//!
//! Aggregates SYNOR/USD prices from multiple sources with TWAP for stability.
mod price_feed;
mod twap;
pub use price_feed::{PriceFeed, PriceSource};
pub use twap::TwapCalculator;
use crate::error::{EconomicsError, Result};
use crate::SynorDecimal;
use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Token price data point
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenPrice {
/// Token symbol (e.g., "SYNOR")
pub token: String,
/// Quote currency (e.g., "USD")
pub quote: String,
/// Price value
pub price: SynorDecimal,
/// Timestamp
pub timestamp: DateTime<Utc>,
/// Source of the price
pub source: PriceSource,
/// Confidence score (0.0 - 1.0)
pub confidence: f64,
}
impl TokenPrice {
/// Create a new token price
pub fn new(
token: impl Into<String>,
quote: impl Into<String>,
price: SynorDecimal,
source: PriceSource,
) -> Self {
Self {
token: token.into(),
quote: quote.into(),
price,
timestamp: Utc::now(),
source,
confidence: 1.0,
}
}
/// Check if the price is stale (older than max_age)
pub fn is_stale(&self, max_age: Duration) -> bool {
Utc::now() - self.timestamp > max_age
}
/// Get price pair key
pub fn pair_key(&self) -> String {
format!("{}/{}", self.token, self.quote)
}
}
/// Oracle configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OracleConfig {
/// Maximum price age before considered stale
pub max_price_age: Duration,
/// TWAP window duration
pub twap_window: Duration,
/// Minimum number of price sources required
pub min_sources: usize,
/// Maximum deviation between sources (percentage)
pub max_deviation: Decimal,
/// Update interval
pub update_interval: Duration,
}
impl Default for OracleConfig {
fn default() -> Self {
Self {
max_price_age: Duration::minutes(5),
twap_window: Duration::hours(1),
min_sources: 2,
max_deviation: Decimal::new(5, 2), // 5%
update_interval: Duration::seconds(30),
}
}
}
/// Aggregated price with metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregatedPrice {
/// Spot price (latest)
pub spot: SynorDecimal,
/// Time-weighted average price
pub twap: SynorDecimal,
/// Median price across sources
pub median: SynorDecimal,
/// Number of active sources
pub source_count: usize,
/// Last update timestamp
pub last_update: DateTime<Utc>,
/// Overall confidence score
pub confidence: f64,
}
/// Price oracle aggregating multiple sources
pub struct PriceOracle {
config: OracleConfig,
/// Price history keyed by pair (e.g., "SYNOR/USD")
price_history: HashMap<String, Vec<TokenPrice>>,
/// Active price feeds
feeds: Vec<Box<dyn PriceFeed + Send + Sync>>,
/// TWAP calculator
twap_calculator: TwapCalculator,
/// Cached aggregated prices
cache: HashMap<String, AggregatedPrice>,
}
impl PriceOracle {
/// Create a new price oracle
pub fn new() -> Self {
Self::with_config(OracleConfig::default())
}
/// Create with custom configuration
pub fn with_config(config: OracleConfig) -> Self {
let twap_calculator = TwapCalculator::new(config.twap_window);
Self {
config,
price_history: HashMap::new(),
feeds: Vec::new(),
twap_calculator,
cache: HashMap::new(),
}
}
/// Add a price feed source
pub fn add_feed(&mut self, feed: Box<dyn PriceFeed + Send + Sync>) {
self.feeds.push(feed);
}
/// Update price from external source
pub fn update_price(&mut self, price: TokenPrice) -> Result<()> {
// Validate price
if price.price <= Decimal::ZERO {
return Err(EconomicsError::InvalidPrice(
"Price must be positive".to_string(),
));
}
let pair_key = price.pair_key();
// Add to history
let history = self.price_history.entry(pair_key.clone()).or_default();
history.push(price.clone());
// Keep only prices within TWAP window
let cutoff = Utc::now() - self.config.twap_window;
history.retain(|p| p.timestamp > cutoff);
// Update aggregated price
self.update_aggregated_price(&pair_key)?;
Ok(())
}
/// Get current spot price
pub fn get_price(&self, token: &str, quote: &str) -> Result<SynorDecimal> {
let pair_key = format!("{}/{}", token, quote);
self.cache
.get(&pair_key)
.map(|p| p.spot)
.ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair_key))
}
/// Get TWAP price
pub fn get_twap(&self, token: &str, quote: &str) -> Result<SynorDecimal> {
let pair_key = format!("{}/{}", token, quote);
self.cache
.get(&pair_key)
.map(|p| p.twap)
.ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair_key))
}
/// Get full aggregated price data
pub fn get_aggregated_price(&self, token: &str, quote: &str) -> Result<AggregatedPrice> {
let pair_key = format!("{}/{}", token, quote);
self.cache
.get(&pair_key)
.cloned()
.ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair_key))
}
/// Get price history for a pair
pub fn get_price_history(&self, token: &str, quote: &str) -> Vec<TokenPrice> {
let pair_key = format!("{}/{}", token, quote);
self.price_history.get(&pair_key).cloned().unwrap_or_default()
}
/// Fetch prices from all configured feeds
pub async fn fetch_all_prices(&mut self) -> Result<()> {
let mut new_prices = Vec::new();
for feed in &self.feeds {
match feed.fetch_price("SYNOR", "USD").await {
Ok(price) => new_prices.push(price),
Err(e) => {
tracing::warn!("Failed to fetch price from {}: {}", feed.source(), e);
}
}
}
if new_prices.len() < self.config.min_sources {
return Err(EconomicsError::PriceFeedUnavailable(format!(
"Only {} sources available, need at least {}",
new_prices.len(),
self.config.min_sources
)));
}
// Check for excessive deviation
let prices: Vec<_> = new_prices.iter().map(|p| p.price).collect();
if !self.validate_price_consistency(&prices)? {
return Err(EconomicsError::InvalidPrice(
"Excessive price deviation between sources".to_string(),
));
}
// Update all prices
for price in new_prices {
self.update_price(price)?;
}
Ok(())
}
/// Validate that prices from different sources are consistent
fn validate_price_consistency(&self, prices: &[SynorDecimal]) -> Result<bool> {
if prices.len() < 2 {
return Ok(true);
}
// Calculate median
let mut sorted = prices.to_vec();
sorted.sort();
let median = sorted[sorted.len() / 2];
// Check each price against median
for price in prices {
let deviation = (*price - median).abs() / median;
if deviation > self.config.max_deviation {
return Ok(false);
}
}
Ok(true)
}
/// Update aggregated price for a pair
fn update_aggregated_price(&mut self, pair_key: &str) -> Result<()> {
let history = match self.price_history.get(pair_key) {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
// Get latest price (spot)
let latest = history.last().unwrap();
// Check if stale
if latest.is_stale(self.config.max_price_age) {
// Don't update cache with stale price, but don't error
return Ok(());
}
// Calculate TWAP
let twap = self.twap_calculator.calculate(history);
// Calculate median
let mut prices: Vec<_> = history.iter().map(|p| p.price).collect();
prices.sort();
let median = prices[prices.len() / 2];
// Count unique sources
let mut sources: std::collections::HashSet<String> = std::collections::HashSet::new();
for p in history {
sources.insert(format!("{:?}", p.source));
}
// Calculate confidence based on source diversity and freshness
let freshness = 1.0
- (Utc::now() - latest.timestamp).num_seconds() as f64
/ self.config.max_price_age.num_seconds() as f64;
let diversity = (sources.len() as f64 / self.config.min_sources as f64).min(1.0);
let confidence = (freshness * 0.6 + diversity * 0.4) * latest.confidence;
let aggregated = AggregatedPrice {
spot: latest.price,
twap,
median,
source_count: sources.len(),
last_update: latest.timestamp,
confidence: confidence.max(0.0).min(1.0),
};
self.cache.insert(pair_key.to_string(), aggregated);
Ok(())
}
/// Convert amount from one currency to another
pub fn convert(
&self,
amount: SynorDecimal,
from: &str,
to: &str,
use_twap: bool,
) -> Result<SynorDecimal> {
if from == to {
return Ok(amount);
}
// Try direct conversion
let price = if use_twap {
self.get_twap(from, to).or_else(|_| {
// Try inverse
self.get_twap(to, from).map(|p| Decimal::ONE / p)
})
} else {
self.get_price(from, to).or_else(|_| {
// Try inverse
self.get_price(to, from).map(|p| Decimal::ONE / p)
})
}?;
Ok(amount * price)
}
/// Get oracle health status
pub fn health_check(&self) -> OracleHealthStatus {
let mut pairs_status = HashMap::new();
for (pair, prices) in &self.price_history {
let latest = prices.last();
let is_stale = latest
.map(|p| p.is_stale(self.config.max_price_age))
.unwrap_or(true);
pairs_status.insert(
pair.clone(),
PairStatus {
price_count: prices.len(),
is_stale,
last_update: latest.map(|p| p.timestamp),
},
);
}
let healthy = !pairs_status.is_empty()
&& pairs_status.values().all(|s| !s.is_stale && s.price_count > 0);
OracleHealthStatus {
healthy,
feed_count: self.feeds.len(),
pairs: pairs_status,
}
}
}
impl Default for PriceOracle {
fn default() -> Self {
Self::new()
}
}
/// Status of a trading pair
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairStatus {
pub price_count: usize,
pub is_stale: bool,
pub last_update: Option<DateTime<Utc>>,
}
/// Oracle health status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OracleHealthStatus {
pub healthy: bool,
pub feed_count: usize,
pub pairs: HashMap<String, PairStatus>,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_update_and_get_price() {
let mut oracle = PriceOracle::new();
let price = TokenPrice::new("SYNOR", "USD", dec!(1.50), PriceSource::Internal);
oracle.update_price(price).unwrap();
let retrieved = oracle.get_price("SYNOR", "USD").unwrap();
assert_eq!(retrieved, dec!(1.50));
}
#[test]
fn test_twap_calculation() {
let mut oracle = PriceOracle::new();
// Add multiple prices
for i in 0..5 {
let price = TokenPrice {
token: "SYNOR".to_string(),
quote: "USD".to_string(),
price: dec!(1.0) + Decimal::new(i, 1),
timestamp: Utc::now() - Duration::minutes(5 - i as i64),
source: PriceSource::Internal,
confidence: 1.0,
};
oracle.update_price(price).unwrap();
}
let twap = oracle.get_twap("SYNOR", "USD").unwrap();
// TWAP should be weighted average
assert!(twap > dec!(1.0) && twap < dec!(1.5));
}
#[test]
fn test_price_conversion() {
let mut oracle = PriceOracle::new();
let price = TokenPrice::new("SYNOR", "USD", dec!(2.0), PriceSource::Internal);
oracle.update_price(price).unwrap();
// Convert 10 SYNOR to USD
let usd = oracle.convert(dec!(10), "SYNOR", "USD", false).unwrap();
assert_eq!(usd, dec!(20));
// Convert 20 USD to SYNOR (inverse)
let synor = oracle.convert(dec!(20), "USD", "SYNOR", false).unwrap();
assert_eq!(synor, dec!(10));
}
#[test]
fn test_health_check() {
let oracle = PriceOracle::new();
let status = oracle.health_check();
assert!(!status.healthy); // No prices yet
}
}

View file

@ -0,0 +1,393 @@
//! Price Feed Sources
//!
//! Trait and implementations for fetching prices from various sources.
use crate::error::{EconomicsError, Result};
use crate::oracle::TokenPrice;
use crate::SynorDecimal;
use async_trait::async_trait;
use chrono::Utc;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Price source identifier
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PriceSource {
/// Internal DEX prices
Internal,
/// Synor native DEX
SynorDex,
/// Chainlink oracle
Chainlink,
/// Pyth network
Pyth,
/// Band protocol
Band,
/// CoinGecko API
CoinGecko,
/// CoinMarketCap API
CoinMarketCap,
/// Binance exchange
Binance,
/// Uniswap TWAP
Uniswap,
/// Custom/external source
Custom(u8),
}
impl std::fmt::Display for PriceSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PriceSource::Internal => write!(f, "internal"),
PriceSource::SynorDex => write!(f, "synor_dex"),
PriceSource::Chainlink => write!(f, "chainlink"),
PriceSource::Pyth => write!(f, "pyth"),
PriceSource::Band => write!(f, "band"),
PriceSource::CoinGecko => write!(f, "coingecko"),
PriceSource::CoinMarketCap => write!(f, "coinmarketcap"),
PriceSource::Binance => write!(f, "binance"),
PriceSource::Uniswap => write!(f, "uniswap"),
PriceSource::Custom(id) => write!(f, "custom_{}", id),
}
}
}
/// Trait for price feed implementations
#[async_trait]
pub trait PriceFeed: Send + Sync {
/// Get the source identifier
fn source(&self) -> PriceSource;
/// Fetch current price for a token pair
async fn fetch_price(&self, token: &str, quote: &str) -> Result<TokenPrice>;
/// Check if the feed is healthy
async fn health_check(&self) -> bool {
true
}
/// Get supported pairs
fn supported_pairs(&self) -> Vec<(String, String)> {
vec![("SYNOR".to_string(), "USD".to_string())]
}
}
/// Mock price feed for testing
pub struct MockPriceFeed {
source: PriceSource,
base_price: SynorDecimal,
volatility: f64,
}
impl MockPriceFeed {
pub fn new(source: PriceSource, base_price: SynorDecimal) -> Self {
Self {
source,
base_price,
volatility: 0.01,
}
}
pub fn with_volatility(mut self, volatility: f64) -> Self {
self.volatility = volatility;
self
}
}
#[async_trait]
impl PriceFeed for MockPriceFeed {
fn source(&self) -> PriceSource {
self.source
}
async fn fetch_price(&self, token: &str, quote: &str) -> Result<TokenPrice> {
// Add small random variance
let variance = (rand_simple() * 2.0 - 1.0) * self.volatility;
let price = self.base_price * (Decimal::ONE + Decimal::from_f64_retain(variance).unwrap_or_default());
Ok(TokenPrice {
token: token.to_string(),
quote: quote.to_string(),
price,
timestamp: Utc::now(),
source: self.source,
confidence: 0.95,
})
}
}
/// Simple random number generator (no external deps)
fn rand_simple() -> f64 {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.subsec_nanos();
(nanos as f64 % 1000.0) / 1000.0
}
/// Internal DEX price feed
pub struct InternalDexFeed {
dex_endpoint: String,
}
impl InternalDexFeed {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
dex_endpoint: endpoint.into(),
}
}
}
#[async_trait]
impl PriceFeed for InternalDexFeed {
fn source(&self) -> PriceSource {
PriceSource::SynorDex
}
async fn fetch_price(&self, token: &str, quote: &str) -> Result<TokenPrice> {
// In production, this would query the Synor DEX contract
#[cfg(feature = "http-feeds")]
{
let url = format!(
"{}/api/v1/price?token={}&quote={}",
self.dex_endpoint, token, quote
);
let response = reqwest::get(&url)
.await
.map_err(|e| EconomicsError::PriceFeedUnavailable(e.to_string()))?;
#[derive(Deserialize)]
struct DexPriceResponse {
price: String,
timestamp: i64,
}
let data: DexPriceResponse = response
.json()
.await
.map_err(|e| EconomicsError::InvalidPrice(e.to_string()))?;
let price = data
.price
.parse::<Decimal>()
.map_err(|e| EconomicsError::InvalidPrice(e.to_string()))?;
Ok(TokenPrice {
token: token.to_string(),
quote: quote.to_string(),
price,
timestamp: chrono::DateTime::from_timestamp(data.timestamp, 0)
.unwrap_or_else(Utc::now),
source: PriceSource::SynorDex,
confidence: 0.99,
})
}
#[cfg(not(feature = "http-feeds"))]
{
let _ = (token, quote);
Err(EconomicsError::PriceFeedUnavailable(
"HTTP feeds not enabled".to_string(),
))
}
}
}
/// Chainlink price feed adapter
pub struct ChainlinkFeed {
contract_address: String,
rpc_url: String,
}
impl ChainlinkFeed {
pub fn new(contract_address: impl Into<String>, rpc_url: impl Into<String>) -> Self {
Self {
contract_address: contract_address.into(),
rpc_url: rpc_url.into(),
}
}
}
#[async_trait]
impl PriceFeed for ChainlinkFeed {
fn source(&self) -> PriceSource {
PriceSource::Chainlink
}
async fn fetch_price(&self, token: &str, quote: &str) -> Result<TokenPrice> {
// In production, this would call the Chainlink price feed contract
// via eth_call to latestRoundData()
let _ = (&self.contract_address, &self.rpc_url, token, quote);
Err(EconomicsError::PriceFeedUnavailable(
"Chainlink integration pending".to_string(),
))
}
}
/// CoinGecko API price feed
pub struct CoinGeckoFeed {
api_key: Option<String>,
}
impl CoinGeckoFeed {
pub fn new(api_key: Option<String>) -> Self {
Self { api_key }
}
}
#[async_trait]
impl PriceFeed for CoinGeckoFeed {
fn source(&self) -> PriceSource {
PriceSource::CoinGecko
}
async fn fetch_price(&self, token: &str, quote: &str) -> Result<TokenPrice> {
#[cfg(feature = "http-feeds")]
{
let token_id = match token.to_uppercase().as_str() {
"SYNOR" => "synor", // Would need actual CoinGecko ID
"BTC" => "bitcoin",
"ETH" => "ethereum",
_ => return Err(EconomicsError::PriceFeedUnavailable(
format!("Token {} not supported on CoinGecko", token)
)),
};
let quote_currency = quote.to_lowercase();
let url = format!(
"https://api.coingecko.com/api/v3/simple/price?ids={}&vs_currencies={}",
token_id, quote_currency
);
let client = reqwest::Client::new();
let mut request = client.get(&url);
if let Some(ref key) = self.api_key {
request = request.header("x-cg-demo-api-key", key);
}
let response = request
.send()
.await
.map_err(|e| EconomicsError::PriceFeedUnavailable(e.to_string()))?;
let data: serde_json::Value = response
.json()
.await
.map_err(|e| EconomicsError::InvalidPrice(e.to_string()))?;
let price = data[token_id][&quote_currency]
.as_f64()
.ok_or_else(|| EconomicsError::InvalidPrice("Price not found".to_string()))?;
Ok(TokenPrice {
token: token.to_string(),
quote: quote.to_string(),
price: Decimal::from_f64_retain(price)
.unwrap_or_default(),
timestamp: Utc::now(),
source: PriceSource::CoinGecko,
confidence: 0.90,
})
}
#[cfg(not(feature = "http-feeds"))]
{
let _ = (token, quote);
Err(EconomicsError::PriceFeedUnavailable(
"HTTP feeds not enabled".to_string(),
))
}
}
fn supported_pairs(&self) -> Vec<(String, String)> {
vec![
("SYNOR".to_string(), "USD".to_string()),
("BTC".to_string(), "USD".to_string()),
("ETH".to_string(), "USD".to_string()),
]
}
}
/// Aggregate multiple price feeds
pub struct AggregateFeed {
feeds: Vec<Box<dyn PriceFeed + Send + Sync>>,
}
impl AggregateFeed {
pub fn new(feeds: Vec<Box<dyn PriceFeed + Send + Sync>>) -> Self {
Self { feeds }
}
/// Fetch prices from all feeds and return median
pub async fn fetch_median(&self, token: &str, quote: &str) -> Result<TokenPrice> {
let mut prices = Vec::new();
for feed in &self.feeds {
match feed.fetch_price(token, quote).await {
Ok(price) => prices.push(price),
Err(e) => {
tracing::warn!("Feed {} failed: {}", feed.source(), e);
}
}
}
if prices.is_empty() {
return Err(EconomicsError::PriceFeedUnavailable(
"All feeds failed".to_string(),
));
}
// Sort by price and take median
prices.sort_by(|a, b| a.price.cmp(&b.price));
let median_idx = prices.len() / 2;
let mut median = prices.remove(median_idx);
// Adjust confidence based on source count
median.confidence *= prices.len() as f64 / self.feeds.len() as f64;
Ok(median)
}
}
#[async_trait]
impl PriceFeed for AggregateFeed {
fn source(&self) -> PriceSource {
PriceSource::Internal
}
async fn fetch_price(&self, token: &str, quote: &str) -> Result<TokenPrice> {
self.fetch_median(token, quote).await
}
async fn health_check(&self) -> bool {
let mut healthy_count = 0;
for feed in &self.feeds {
if feed.health_check().await {
healthy_count += 1;
}
}
healthy_count >= self.feeds.len() / 2
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[tokio::test]
async fn test_mock_feed() {
let feed = MockPriceFeed::new(PriceSource::Internal, dec!(1.50));
let price = feed.fetch_price("SYNOR", "USD").await.unwrap();
assert_eq!(price.token, "SYNOR");
assert_eq!(price.quote, "USD");
assert!(price.price > dec!(1.40) && price.price < dec!(1.60));
}
}

View file

@ -0,0 +1,387 @@
//! Time-Weighted Average Price (TWAP) Calculator
//!
//! TWAP provides price stability by averaging over a time window,
//! reducing impact of short-term manipulation or volatility.
use crate::oracle::TokenPrice;
use crate::SynorDecimal;
use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
/// TWAP calculator configuration
#[derive(Debug, Clone)]
pub struct TwapConfig {
/// Time window for TWAP calculation
pub window: Duration,
/// Minimum data points required
pub min_data_points: usize,
/// Whether to use volume weighting (when available)
pub volume_weighted: bool,
}
impl Default for TwapConfig {
fn default() -> Self {
Self {
window: Duration::hours(1),
min_data_points: 3,
volume_weighted: false,
}
}
}
/// TWAP calculator
pub struct TwapCalculator {
config: TwapConfig,
}
impl TwapCalculator {
/// Create a new TWAP calculator with specified window
pub fn new(window: Duration) -> Self {
Self {
config: TwapConfig {
window,
..Default::default()
},
}
}
/// Create with full configuration
pub fn with_config(config: TwapConfig) -> Self {
Self { config }
}
/// Calculate TWAP from price history
///
/// Uses time-weighted interpolation between price points
pub fn calculate(&self, prices: &[TokenPrice]) -> SynorDecimal {
if prices.is_empty() {
return Decimal::ZERO;
}
if prices.len() == 1 {
return prices[0].price;
}
let now = Utc::now();
let window_start = now - self.config.window;
// Filter prices within window
let relevant: Vec<_> = prices
.iter()
.filter(|p| p.timestamp >= window_start)
.collect();
if relevant.is_empty() {
// Fall back to latest price if all data is stale
return prices.last().map(|p| p.price).unwrap_or(Decimal::ZERO);
}
// Time-weighted average
self.calculate_time_weighted(&relevant, window_start, now)
}
/// Calculate time-weighted average
///
/// For each time interval between price points, the price is weighted
/// by the duration of that interval.
fn calculate_time_weighted(
&self,
prices: &[&TokenPrice],
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> SynorDecimal {
let total_duration = (end - start).num_seconds() as f64;
if total_duration <= 0.0 {
return prices.last().map(|p| p.price).unwrap_or(Decimal::ZERO);
}
let mut weighted_sum = Decimal::ZERO;
let mut total_weight = Decimal::ZERO;
for i in 0..prices.len() {
let price = &prices[i];
// Calculate time weight for this price point
let interval_start = if i == 0 {
start.max(price.timestamp)
} else {
prices[i - 1].timestamp.max(price.timestamp)
};
let interval_end = if i + 1 < prices.len() {
prices[i + 1].timestamp
} else {
end
};
let duration = (interval_end - interval_start).num_seconds() as f64;
if duration > 0.0 {
let weight = Decimal::from_f64_retain(duration / total_duration)
.unwrap_or(Decimal::ZERO);
weighted_sum += price.price * weight;
total_weight += weight;
}
}
if total_weight == Decimal::ZERO {
prices.last().map(|p| p.price).unwrap_or(Decimal::ZERO)
} else {
weighted_sum / total_weight
}
}
/// Calculate TWAP with exponential decay (more recent prices weighted higher)
pub fn calculate_exponential(&self, prices: &[TokenPrice], decay_rate: f64) -> SynorDecimal {
if prices.is_empty() {
return Decimal::ZERO;
}
if prices.len() == 1 {
return prices[0].price;
}
let now = Utc::now();
let mut weighted_sum = Decimal::ZERO;
let mut total_weight = Decimal::ZERO;
for price in prices {
let age_seconds = (now - price.timestamp).num_seconds() as f64;
let weight = (-decay_rate * age_seconds).exp();
let weight_decimal = Decimal::from_f64_retain(weight).unwrap_or(Decimal::ZERO);
weighted_sum += price.price * weight_decimal;
total_weight += weight_decimal;
}
if total_weight == Decimal::ZERO {
prices.last().map(|p| p.price).unwrap_or(Decimal::ZERO)
} else {
weighted_sum / total_weight
}
}
/// Calculate simple moving average (not time-weighted)
pub fn calculate_sma(&self, prices: &[TokenPrice]) -> SynorDecimal {
if prices.is_empty() {
return Decimal::ZERO;
}
let sum: Decimal = prices.iter().map(|p| p.price).sum();
sum / Decimal::from(prices.len())
}
/// Calculate volume-weighted average price (VWAP)
/// Requires prices with volume data
pub fn calculate_vwap(&self, prices: &[(TokenPrice, SynorDecimal)]) -> SynorDecimal {
if prices.is_empty() {
return Decimal::ZERO;
}
let mut weighted_sum = Decimal::ZERO;
let mut total_volume = Decimal::ZERO;
for (price, volume) in prices {
weighted_sum += price.price * *volume;
total_volume += *volume;
}
if total_volume == Decimal::ZERO {
prices.last().map(|(p, _)| p.price).unwrap_or(Decimal::ZERO)
} else {
weighted_sum / total_volume
}
}
/// Calculate price volatility (standard deviation)
pub fn calculate_volatility(&self, prices: &[TokenPrice]) -> SynorDecimal {
if prices.len() < 2 {
return Decimal::ZERO;
}
let mean = self.calculate_sma(prices);
let variance: Decimal = prices
.iter()
.map(|p| {
let diff = p.price - mean;
diff * diff
})
.sum::<Decimal>()
/ Decimal::from(prices.len());
// Approximate square root using Newton's method
let mut guess = variance;
for _ in 0..10 {
if guess == Decimal::ZERO {
break;
}
guess = (guess + variance / guess) / Decimal::from(2);
}
guess
}
}
/// TWAP observation for on-chain storage
#[derive(Debug, Clone)]
pub struct TwapObservation {
pub timestamp: DateTime<Utc>,
pub price_cumulative: SynorDecimal,
pub seconds_per_liquidity_cumulative: SynorDecimal,
}
/// On-chain TWAP oracle (Uniswap V3 style)
pub struct OnChainTwap {
observations: Vec<TwapObservation>,
cardinality: usize,
cardinality_next: usize,
}
impl OnChainTwap {
pub fn new(cardinality: usize) -> Self {
Self {
observations: Vec::with_capacity(cardinality),
cardinality,
cardinality_next: cardinality,
}
}
/// Record new price observation
pub fn observe(&mut self, price: SynorDecimal, liquidity: SynorDecimal) {
let last = self.observations.last();
let timestamp = Utc::now();
let (price_cumulative, spl_cumulative) = if let Some(last_obs) = last {
let time_delta = (timestamp - last_obs.timestamp).num_seconds();
let time_delta_dec = Decimal::from(time_delta);
(
last_obs.price_cumulative + price * time_delta_dec,
last_obs.seconds_per_liquidity_cumulative
+ if liquidity > Decimal::ZERO {
time_delta_dec / liquidity
} else {
Decimal::ZERO
},
)
} else {
(Decimal::ZERO, Decimal::ZERO)
};
let observation = TwapObservation {
timestamp,
price_cumulative,
seconds_per_liquidity_cumulative: spl_cumulative,
};
if self.observations.len() < self.cardinality {
self.observations.push(observation);
} else {
// Ring buffer - overwrite oldest
let index = self.observations.len() % self.cardinality;
self.observations[index] = observation;
}
}
/// Calculate TWAP between two timestamps
pub fn consult(&self, seconds_ago: i64) -> Option<SynorDecimal> {
if self.observations.len() < 2 {
return None;
}
let now = Utc::now();
let target_time = now - Duration::seconds(seconds_ago);
// Find observations bracketing target_time
let mut before: Option<&TwapObservation> = None;
let mut after: Option<&TwapObservation> = None;
for obs in &self.observations {
if obs.timestamp <= target_time {
before = Some(obs);
} else if after.is_none() {
after = Some(obs);
}
}
match (before, after) {
(Some(b), Some(a)) => {
let time_delta = (a.timestamp - b.timestamp).num_seconds();
if time_delta <= 0 {
return None;
}
let price_delta = a.price_cumulative - b.price_cumulative;
Some(price_delta / Decimal::from(time_delta))
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oracle::PriceSource;
use rust_decimal_macros::dec;
fn make_price(price: Decimal, minutes_ago: i64) -> TokenPrice {
TokenPrice {
token: "SYNOR".to_string(),
quote: "USD".to_string(),
price,
timestamp: Utc::now() - Duration::minutes(minutes_ago),
source: PriceSource::Internal,
confidence: 1.0,
}
}
#[test]
fn test_sma() {
let calculator = TwapCalculator::new(Duration::hours(1));
let prices = vec![
make_price(dec!(100), 5),
make_price(dec!(110), 4),
make_price(dec!(105), 3),
make_price(dec!(115), 2),
make_price(dec!(120), 1),
];
let sma = calculator.calculate_sma(&prices);
assert_eq!(sma, dec!(110)); // (100+110+105+115+120)/5 = 110
}
#[test]
fn test_twap_single_price() {
let calculator = TwapCalculator::new(Duration::hours(1));
let prices = vec![make_price(dec!(100), 5)];
let twap = calculator.calculate(&prices);
assert_eq!(twap, dec!(100));
}
#[test]
fn test_volatility() {
let calculator = TwapCalculator::new(Duration::hours(1));
let prices = vec![
make_price(dec!(100), 5),
make_price(dec!(100), 4),
make_price(dec!(100), 3),
];
let volatility = calculator.calculate_volatility(&prices);
assert_eq!(volatility, dec!(0)); // No variation
}
#[test]
fn test_on_chain_twap() {
let mut twap = OnChainTwap::new(10);
// Add observations
twap.observe(dec!(100), dec!(1000));
std::thread::sleep(std::time::Duration::from_millis(10));
twap.observe(dec!(110), dec!(1000));
// Should have 2 observations
assert_eq!(twap.observations.len(), 2);
}
}

View file

@ -0,0 +1,399 @@
//! Discount System
//!
//! Promotional, volume, and loyalty discounts.
use crate::{ServiceType, SynorDecimal};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Discount type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiscountType {
/// Percentage off
Percentage,
/// Fixed amount off
FixedAmount,
/// Volume-based (spend X, get Y% off)
Volume,
/// Loyalty reward
Loyalty,
/// Referral discount
Referral,
/// Promotional campaign
Promotional,
/// Partner/reseller discount
Partner,
}
impl std::fmt::Display for DiscountType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiscountType::Percentage => write!(f, "Percentage"),
DiscountType::FixedAmount => write!(f, "Fixed Amount"),
DiscountType::Volume => write!(f, "Volume"),
DiscountType::Loyalty => write!(f, "Loyalty"),
DiscountType::Referral => write!(f, "Referral"),
DiscountType::Promotional => write!(f, "Promotional"),
DiscountType::Partner => write!(f, "Partner"),
}
}
}
/// Discount definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Discount {
/// Unique discount code/ID
pub code: String,
/// Discount name
pub name: String,
/// Description
pub description: String,
/// Discount type
pub discount_type: DiscountType,
/// Discount value (percentage or fixed amount)
pub value: SynorDecimal,
/// Minimum spend to qualify
pub min_spend: Option<SynorDecimal>,
/// Maximum discount amount
pub max_discount: Option<SynorDecimal>,
/// Applicable service types (None = all)
pub service_types: Option<Vec<ServiceType>>,
/// Applicable account IDs (None = all)
pub account_ids: Option<Vec<String>>,
/// Start date
pub start_date: DateTime<Utc>,
/// End date (None = no expiry)
pub end_date: Option<DateTime<Utc>>,
/// Maximum uses (None = unlimited)
pub max_uses: Option<u32>,
/// Current uses
pub current_uses: u32,
/// Active status
pub is_active: bool,
}
impl Discount {
/// Create a new percentage discount
pub fn percentage(code: impl Into<String>, name: impl Into<String>, percentage: SynorDecimal) -> Self {
Self {
code: code.into(),
name: name.into(),
description: format!("{}% off", percentage),
discount_type: DiscountType::Percentage,
value: percentage,
min_spend: None,
max_discount: None,
service_types: None,
account_ids: None,
start_date: Utc::now(),
end_date: None,
max_uses: None,
current_uses: 0,
is_active: true,
}
}
/// Create a fixed amount discount
pub fn fixed_amount(code: impl Into<String>, name: impl Into<String>, amount: SynorDecimal) -> Self {
Self {
code: code.into(),
name: name.into(),
description: format!("{} SYNOR off", amount),
discount_type: DiscountType::FixedAmount,
value: amount,
min_spend: None,
max_discount: Some(amount),
service_types: None,
account_ids: None,
start_date: Utc::now(),
end_date: None,
max_uses: None,
current_uses: 0,
is_active: true,
}
}
/// Create a volume discount
pub fn volume(min_spend: SynorDecimal, percentage: SynorDecimal) -> Self {
Self {
code: format!("VOLUME_{}", min_spend),
name: format!("Volume Discount ({} SYNOR+)", min_spend),
description: format!("{}% off when spending {} SYNOR or more", percentage, min_spend),
discount_type: DiscountType::Volume,
value: percentage,
min_spend: Some(min_spend),
max_discount: None,
service_types: None,
account_ids: None,
start_date: Utc::now(),
end_date: None,
max_uses: None,
current_uses: 0,
is_active: true,
}
}
/// Create a referral discount
pub fn referral(referrer_id: impl Into<String>) -> Self {
let referrer = referrer_id.into();
Self {
code: format!("REF_{}", &referrer[..8.min(referrer.len())].to_uppercase()),
name: "Referral Discount".to_string(),
description: "10% off for referred users".to_string(),
discount_type: DiscountType::Referral,
value: Decimal::new(10, 0), // 10%
min_spend: None,
max_discount: Some(Decimal::new(50, 0)), // Max 50 SYNOR
service_types: None,
account_ids: None,
start_date: Utc::now(),
end_date: Some(Utc::now() + chrono::Duration::days(90)),
max_uses: Some(1),
current_uses: 0,
is_active: true,
}
}
/// Set expiration
pub fn with_expiry(mut self, end_date: DateTime<Utc>) -> Self {
self.end_date = Some(end_date);
self
}
/// Set expiration days from now
pub fn with_expiry_days(mut self, days: i64) -> Self {
self.end_date = Some(Utc::now() + chrono::Duration::days(days));
self
}
/// Set minimum spend
pub fn with_min_spend(mut self, min: SynorDecimal) -> Self {
self.min_spend = Some(min);
self
}
/// Set maximum discount
pub fn with_max_discount(mut self, max: SynorDecimal) -> Self {
self.max_discount = Some(max);
self
}
/// Limit to specific service types
pub fn for_services(mut self, services: Vec<ServiceType>) -> Self {
self.service_types = Some(services);
self
}
/// Limit to specific accounts
pub fn for_accounts(mut self, accounts: Vec<String>) -> Self {
self.account_ids = Some(accounts);
self
}
/// Set maximum uses
pub fn with_max_uses(mut self, max: u32) -> Self {
self.max_uses = Some(max);
self
}
/// Check if discount is currently valid
pub fn is_valid(&self) -> bool {
if !self.is_active {
return false;
}
let now = Utc::now();
if now < self.start_date {
return false;
}
if let Some(end) = self.end_date {
if now > end {
return false;
}
}
if let Some(max) = self.max_uses {
if self.current_uses >= max {
return false;
}
}
true
}
/// Check if discount is applicable to an account and service
pub fn is_applicable(&self, account_id: &str, service_type: Option<ServiceType>) -> bool {
if !self.is_valid() {
return false;
}
// Check account restriction
if let Some(ref accounts) = self.account_ids {
if !accounts.iter().any(|a| a == account_id) {
return false;
}
}
// Check service restriction
if let Some(ref services) = self.service_types {
if let Some(st) = service_type {
if !services.contains(&st) {
return false;
}
}
}
true
}
/// Calculate discount amount for a given spend
pub fn calculate_discount(&self, amount: SynorDecimal) -> SynorDecimal {
// Check minimum spend
if let Some(min) = self.min_spend {
if amount < min {
return Decimal::ZERO;
}
}
let discount = match self.discount_type {
DiscountType::Percentage | DiscountType::Volume | DiscountType::Loyalty | DiscountType::Referral | DiscountType::Partner => {
amount * (self.value / Decimal::ONE_HUNDRED)
}
DiscountType::FixedAmount | DiscountType::Promotional => {
self.value.min(amount) // Can't discount more than amount
}
};
// Apply maximum discount cap
if let Some(max) = self.max_discount {
discount.min(max)
} else {
discount
}
}
/// Use the discount (increment counter)
pub fn use_discount(&mut self) -> bool {
if !self.is_valid() {
return false;
}
self.current_uses += 1;
// Check if now exhausted
if let Some(max) = self.max_uses {
if self.current_uses >= max {
self.is_active = false;
}
}
true
}
}
/// Volume discount tiers
pub fn standard_volume_discounts() -> Vec<Discount> {
vec![
Discount::volume(Decimal::new(100, 0), Decimal::new(5, 0)), // 5% at 100+ SYNOR
Discount::volume(Decimal::new(500, 0), Decimal::new(10, 0)), // 10% at 500+ SYNOR
Discount::volume(Decimal::new(1000, 0), Decimal::new(15, 0)), // 15% at 1000+ SYNOR
Discount::volume(Decimal::new(5000, 0), Decimal::new(20, 0)), // 20% at 5000+ SYNOR
]
}
/// Find best applicable volume discount
pub fn find_best_volume_discount(amount: SynorDecimal) -> Option<Discount> {
standard_volume_discounts()
.into_iter()
.filter(|d| {
d.min_spend
.map(|min| amount >= min)
.unwrap_or(false)
})
.max_by(|a, b| a.value.cmp(&b.value))
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_percentage_discount() {
let discount = Discount::percentage("TEST10", "Test Discount", dec!(10));
assert!(discount.is_valid());
let amount = discount.calculate_discount(dec!(100));
assert_eq!(amount, dec!(10)); // 10% of 100
}
#[test]
fn test_fixed_discount() {
let discount = Discount::fixed_amount("FIXED50", "Fixed 50", dec!(50));
// Normal case
let amount = discount.calculate_discount(dec!(100));
assert_eq!(amount, dec!(50));
// Amount less than discount
let amount = discount.calculate_discount(dec!(30));
assert_eq!(amount, dec!(30)); // Can't exceed original amount
}
#[test]
fn test_volume_discount() {
let discount = Discount::volume(dec!(100), dec!(10)).with_min_spend(dec!(100));
// Below minimum
let amount = discount.calculate_discount(dec!(50));
assert_eq!(amount, dec!(0));
// Above minimum
let amount = discount.calculate_discount(dec!(200));
assert_eq!(amount, dec!(20)); // 10% of 200
}
#[test]
fn test_discount_max_cap() {
let discount = Discount::percentage("CAPPED", "Capped Discount", dec!(50))
.with_max_discount(dec!(100));
// Would be 250 but capped at 100
let amount = discount.calculate_discount(dec!(500));
assert_eq!(amount, dec!(100));
}
#[test]
fn test_discount_expiry() {
let mut discount = Discount::percentage("EXPIRED", "Expired", dec!(10))
.with_expiry(Utc::now() - chrono::Duration::days(1));
assert!(!discount.is_valid());
}
#[test]
fn test_discount_usage_limit() {
let mut discount = Discount::percentage("LIMITED", "Limited Use", dec!(10))
.with_max_uses(2);
assert!(discount.use_discount());
assert!(discount.use_discount());
assert!(!discount.use_discount()); // Third use should fail
}
#[test]
fn test_find_best_volume_discount() {
let best = find_best_volume_discount(dec!(750));
assert!(best.is_some());
assert_eq!(best.unwrap().value, dec!(10)); // 10% tier (500+)
let best_high = find_best_volume_discount(dec!(6000));
assert!(best_high.is_some());
assert_eq!(best_high.unwrap().value, dec!(20)); // 20% tier (5000+)
}
}

View file

@ -0,0 +1,463 @@
//! Pricing Engine
//!
//! Service pricing, tier management, and discount system.
mod discounts;
mod tiers;
pub use discounts::{Discount, DiscountType};
pub use tiers::PricingTier;
use crate::error::{EconomicsError, Result};
use crate::{ResourceUnit, ServiceType, SynorDecimal};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Service pricing configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServicePricing {
/// Base prices by resource unit
pub base_prices: HashMap<ResourceUnit, SynorDecimal>,
/// Minimum billable amount
pub minimum_charge: SynorDecimal,
/// Free tier included
pub free_tier: Option<FreeTier>,
}
/// Free tier allocation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FreeTier {
/// Free allocation per resource
pub allocations: HashMap<ResourceUnit, Decimal>,
/// Monthly reset
pub monthly_reset: bool,
}
/// Pricing engine managing all pricing logic
pub struct PricingEngine {
/// Pricing by service type
service_pricing: HashMap<ServiceType, ServicePricing>,
/// Available pricing tiers
tiers: Vec<PricingTier>,
/// Active discounts
discounts: Vec<Discount>,
}
impl PricingEngine {
/// Create a new pricing engine with default prices
pub fn new() -> Self {
let mut engine = Self {
service_pricing: HashMap::new(),
tiers: Vec::new(),
discounts: Vec::new(),
};
engine.initialize_default_pricing();
engine.initialize_default_tiers();
engine
}
/// Initialize default service pricing
fn initialize_default_pricing(&mut self) {
// Storage L2 pricing
let mut storage_prices = HashMap::new();
storage_prices.insert(ResourceUnit::GbMonth, Decimal::new(2, 2)); // 0.02 SYNOR/GB/month
storage_prices.insert(ResourceUnit::BandwidthGb, Decimal::new(1, 2)); // 0.01 SYNOR/GB retrieval
storage_prices.insert(ResourceUnit::Bytes, Decimal::new(2, 11)); // For small files
self.service_pricing.insert(
ServiceType::Storage,
ServicePricing {
base_prices: storage_prices,
minimum_charge: Decimal::new(1, 4), // 0.0001 SYNOR
free_tier: Some(FreeTier {
allocations: [(ResourceUnit::GbMonth, Decimal::new(5, 1))].into(), // 0.5 GB free
monthly_reset: true,
}),
},
);
// Hosting pricing
let mut hosting_prices = HashMap::new();
hosting_prices.insert(ResourceUnit::BandwidthGb, Decimal::new(5, 2)); // 0.05 SYNOR/GB
hosting_prices.insert(ResourceUnit::Domains, Decimal::new(50, 2)); // 0.50 SYNOR/domain/month
self.service_pricing.insert(
ServiceType::Hosting,
ServicePricing {
base_prices: hosting_prices,
minimum_charge: Decimal::ZERO,
free_tier: Some(FreeTier {
allocations: [
(ResourceUnit::BandwidthGb, Decimal::new(1, 0)), // 1 GB free
]
.into(),
monthly_reset: true,
}),
},
);
// Database L2 pricing
let mut db_prices = HashMap::new();
db_prices.insert(ResourceUnit::GbMonth, Decimal::new(10, 2)); // 0.10 SYNOR/GB/month
db_prices.insert(ResourceUnit::Queries, Decimal::new(1, 8)); // 0.00000001 SYNOR/query (0.01/million)
db_prices.insert(ResourceUnit::VectorSearches, Decimal::new(5, 8)); // 0.00000005 SYNOR/search (0.05/million)
self.service_pricing.insert(
ServiceType::Database,
ServicePricing {
base_prices: db_prices,
minimum_charge: Decimal::new(1, 4),
free_tier: Some(FreeTier {
allocations: [
(ResourceUnit::GbMonth, Decimal::new(5, 1)), // 0.5 GB free
(ResourceUnit::Queries, Decimal::new(1_000_000, 0)), // 1M queries free
(ResourceUnit::VectorSearches, Decimal::new(100_000, 0)), // 100K vector searches free
]
.into(),
monthly_reset: true,
}),
},
);
// Compute L2 pricing
let mut compute_prices = HashMap::new();
compute_prices.insert(ResourceUnit::CpuCoreHours, Decimal::new(2, 2)); // 0.02 SYNOR/core-hour
compute_prices.insert(ResourceUnit::GpuHours, Decimal::new(50, 2)); // 0.50 SYNOR/GPU-hour (RTX 4090)
compute_prices.insert(ResourceUnit::MemoryGbHours, Decimal::new(5, 3)); // 0.005 SYNOR/GB-hour
compute_prices.insert(ResourceUnit::Invocations, Decimal::new(2, 7)); // 0.0000002 SYNOR/invocation (0.20/million)
self.service_pricing.insert(
ServiceType::Compute,
ServicePricing {
base_prices: compute_prices,
minimum_charge: Decimal::new(1, 4),
free_tier: Some(FreeTier {
allocations: [
(ResourceUnit::CpuCoreHours, Decimal::new(100, 0)), // 100 core-hours free
(ResourceUnit::MemoryGbHours, Decimal::new(200, 0)), // 200 GB-hours free
(ResourceUnit::Invocations, Decimal::new(1_000_000, 0)), // 1M invocations free
]
.into(),
monthly_reset: true,
}),
},
);
// Network pricing
let mut network_prices = HashMap::new();
network_prices.insert(ResourceUnit::BandwidthGb, Decimal::new(1, 2)); // 0.01 SYNOR/GB
self.service_pricing.insert(
ServiceType::Network,
ServicePricing {
base_prices: network_prices,
minimum_charge: Decimal::ZERO,
free_tier: Some(FreeTier {
allocations: [(ResourceUnit::BandwidthGb, Decimal::new(10, 0))].into(), // 10 GB free
monthly_reset: true,
}),
},
);
// Contract execution (gas pricing)
let mut contract_prices = HashMap::new();
contract_prices.insert(ResourceUnit::Gas, Decimal::new(1, 9)); // 0.000000001 SYNOR/gas
self.service_pricing.insert(
ServiceType::Contract,
ServicePricing {
base_prices: contract_prices,
minimum_charge: Decimal::ZERO,
free_tier: None,
},
);
}
/// Initialize default pricing tiers
fn initialize_default_tiers(&mut self) {
self.tiers = vec![
PricingTier::free(),
PricingTier::standard(),
PricingTier::premium(),
PricingTier::enterprise(),
];
}
/// Calculate cost for resource usage
pub fn calculate_cost(
&self,
service_type: ServiceType,
resource_unit: ResourceUnit,
amount: Decimal,
) -> Result<SynorDecimal> {
let pricing = self
.service_pricing
.get(&service_type)
.ok_or_else(|| EconomicsError::ServiceNotConfigured(service_type.to_string()))?;
let unit_price = pricing
.base_prices
.get(&resource_unit)
.ok_or_else(|| {
EconomicsError::ServiceNotConfigured(format!(
"{} - {}",
service_type, resource_unit
))
})?;
let cost = amount * unit_price;
// Apply minimum charge
Ok(cost.max(pricing.minimum_charge))
}
/// Calculate cost with tier discount applied
pub fn calculate_cost_with_tier(
&self,
service_type: ServiceType,
resource_unit: ResourceUnit,
amount: Decimal,
tier_name: &str,
) -> Result<SynorDecimal> {
let base_cost = self.calculate_cost(service_type, resource_unit, amount)?;
let tier = self.tiers.iter().find(|t| t.name == tier_name);
if let Some(tier) = tier {
let discount_rate = tier.discount_percentage / Decimal::ONE_HUNDRED;
Ok(base_cost * (Decimal::ONE - discount_rate))
} else {
Ok(base_cost)
}
}
/// Calculate tier discount amount
pub fn calculate_tier_discount(
&self,
tier_name: &str,
subtotal: SynorDecimal,
) -> Result<SynorDecimal> {
let tier = self.tiers.iter().find(|t| t.name == tier_name);
if let Some(tier) = tier {
let discount_rate = tier.discount_percentage / Decimal::ONE_HUNDRED;
Ok(subtotal * discount_rate)
} else {
Ok(Decimal::ZERO)
}
}
/// Get free tier allocation for a resource
pub fn get_free_allocation(
&self,
service_type: ServiceType,
resource_unit: ResourceUnit,
) -> Decimal {
self.service_pricing
.get(&service_type)
.and_then(|p| p.free_tier.as_ref())
.and_then(|f| f.allocations.get(&resource_unit))
.copied()
.unwrap_or(Decimal::ZERO)
}
/// Get base price for a resource
pub fn get_base_price(
&self,
service_type: ServiceType,
resource_unit: ResourceUnit,
) -> Option<SynorDecimal> {
self.service_pricing
.get(&service_type)
.and_then(|p| p.base_prices.get(&resource_unit))
.copied()
}
/// Get pricing tier by name
pub fn get_tier(&self, name: &str) -> Option<&PricingTier> {
self.tiers.iter().find(|t| t.name == name)
}
/// Get all tiers
pub fn get_all_tiers(&self) -> &[PricingTier] {
&self.tiers
}
/// Add a custom discount
pub fn add_discount(&mut self, discount: Discount) {
self.discounts.push(discount);
}
/// Apply applicable discounts to an amount
pub fn apply_discounts(
&self,
amount: SynorDecimal,
account_id: &str,
service_type: Option<ServiceType>,
) -> (SynorDecimal, Vec<Discount>) {
let mut discounted = amount;
let mut applied = Vec::new();
for discount in &self.discounts {
if discount.is_applicable(account_id, service_type) {
let discount_amount = discount.calculate_discount(discounted);
discounted -= discount_amount;
applied.push(discount.clone());
}
}
(discounted, applied)
}
/// Get pricing summary for display
pub fn get_pricing_summary(&self) -> PricingSummary {
PricingSummary {
storage: ServicePricingSummary {
gb_month: self.get_base_price(ServiceType::Storage, ResourceUnit::GbMonth),
retrieval_gb: self.get_base_price(ServiceType::Storage, ResourceUnit::BandwidthGb),
free_storage_gb: self.get_free_allocation(ServiceType::Storage, ResourceUnit::GbMonth),
},
hosting: HostingPricingSummary {
bandwidth_gb: self.get_base_price(ServiceType::Hosting, ResourceUnit::BandwidthGb),
domain_month: self.get_base_price(ServiceType::Hosting, ResourceUnit::Domains),
free_bandwidth_gb: self
.get_free_allocation(ServiceType::Hosting, ResourceUnit::BandwidthGb),
},
database: DatabasePricingSummary {
storage_gb_month: self.get_base_price(ServiceType::Database, ResourceUnit::GbMonth),
queries_million: self
.get_base_price(ServiceType::Database, ResourceUnit::Queries)
.map(|p| p * Decimal::from(1_000_000)),
vector_searches_million: self
.get_base_price(ServiceType::Database, ResourceUnit::VectorSearches)
.map(|p| p * Decimal::from(1_000_000)),
free_queries: self
.get_free_allocation(ServiceType::Database, ResourceUnit::Queries),
},
compute: ComputePricingSummary {
cpu_core_hour: self.get_base_price(ServiceType::Compute, ResourceUnit::CpuCoreHours),
gpu_hour: self.get_base_price(ServiceType::Compute, ResourceUnit::GpuHours),
memory_gb_hour: self
.get_base_price(ServiceType::Compute, ResourceUnit::MemoryGbHours),
invocations_million: self
.get_base_price(ServiceType::Compute, ResourceUnit::Invocations)
.map(|p| p * Decimal::from(1_000_000)),
free_cpu_hours: self
.get_free_allocation(ServiceType::Compute, ResourceUnit::CpuCoreHours),
},
}
}
}
impl Default for PricingEngine {
fn default() -> Self {
Self::new()
}
}
/// Pricing summary for display
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricingSummary {
pub storage: ServicePricingSummary,
pub hosting: HostingPricingSummary,
pub database: DatabasePricingSummary,
pub compute: ComputePricingSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServicePricingSummary {
pub gb_month: Option<SynorDecimal>,
pub retrieval_gb: Option<SynorDecimal>,
pub free_storage_gb: SynorDecimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostingPricingSummary {
pub bandwidth_gb: Option<SynorDecimal>,
pub domain_month: Option<SynorDecimal>,
pub free_bandwidth_gb: SynorDecimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabasePricingSummary {
pub storage_gb_month: Option<SynorDecimal>,
pub queries_million: Option<SynorDecimal>,
pub vector_searches_million: Option<SynorDecimal>,
pub free_queries: SynorDecimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputePricingSummary {
pub cpu_core_hour: Option<SynorDecimal>,
pub gpu_hour: Option<SynorDecimal>,
pub memory_gb_hour: Option<SynorDecimal>,
pub invocations_million: Option<SynorDecimal>,
pub free_cpu_hours: SynorDecimal,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_storage_pricing() {
let engine = PricingEngine::new();
// 10 GB storage for a month
let cost = engine
.calculate_cost(ServiceType::Storage, ResourceUnit::GbMonth, dec!(10))
.unwrap();
assert_eq!(cost, dec!(0.20)); // 10 * 0.02
}
#[test]
fn test_compute_pricing() {
let engine = PricingEngine::new();
// 5 GPU hours
let cost = engine
.calculate_cost(ServiceType::Compute, ResourceUnit::GpuHours, dec!(5))
.unwrap();
assert_eq!(cost, dec!(2.50)); // 5 * 0.50
}
#[test]
fn test_database_queries() {
let engine = PricingEngine::new();
// 10 million queries
let cost = engine
.calculate_cost(ServiceType::Database, ResourceUnit::Queries, dec!(10_000_000))
.unwrap();
assert_eq!(cost, dec!(0.10)); // 10M * 0.00000001
}
#[test]
fn test_tier_discount() {
let engine = PricingEngine::new();
// Premium tier gets 20% discount
let base_cost = dec!(100);
let discount = engine.calculate_tier_discount("premium", base_cost).unwrap();
assert_eq!(discount, dec!(20)); // 20%
}
#[test]
fn test_free_allocation() {
let engine = PricingEngine::new();
let free_storage = engine.get_free_allocation(ServiceType::Storage, ResourceUnit::GbMonth);
assert_eq!(free_storage, dec!(0.5));
let free_queries = engine.get_free_allocation(ServiceType::Database, ResourceUnit::Queries);
assert_eq!(free_queries, dec!(1_000_000));
}
}

View file

@ -0,0 +1,300 @@
//! Pricing Tiers
//!
//! Define different pricing tiers: Free, Standard, Premium, Enterprise
use crate::SynorDecimal;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Pricing tier definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricingTier {
/// Tier name/identifier
pub name: String,
/// Display name
pub display_name: String,
/// Monthly base fee (0 for free tier)
pub monthly_fee: SynorDecimal,
/// Discount percentage on usage
pub discount_percentage: SynorDecimal,
/// Priority support included
pub priority_support: bool,
/// SLA guarantee percentage
pub sla_percentage: SynorDecimal,
/// Custom domain limit (0 = unlimited)
pub custom_domain_limit: u32,
/// API rate limit (requests/minute)
pub api_rate_limit: u32,
/// Features included
pub features: Vec<String>,
/// Minimum commitment (months, 0 = no commitment)
pub min_commitment_months: u32,
}
impl PricingTier {
/// Free tier
pub fn free() -> Self {
Self {
name: "free".to_string(),
display_name: "Free".to_string(),
monthly_fee: Decimal::ZERO,
discount_percentage: Decimal::ZERO,
priority_support: false,
sla_percentage: Decimal::new(95, 0), // 95% SLA
custom_domain_limit: 1,
api_rate_limit: 100,
features: vec![
"0.5 GB Storage".to_string(),
"1 GB Hosting Bandwidth".to_string(),
"1M Database Queries".to_string(),
"100 CPU Core-Hours".to_string(),
"Community Support".to_string(),
],
min_commitment_months: 0,
}
}
/// Standard tier
pub fn standard() -> Self {
Self {
name: "standard".to_string(),
display_name: "Standard".to_string(),
monthly_fee: Decimal::new(10, 0), // 10 SYNOR/month
discount_percentage: Decimal::new(10, 0), // 10% discount
priority_support: false,
sla_percentage: Decimal::new(99, 0), // 99% SLA
custom_domain_limit: 5,
api_rate_limit: 1000,
features: vec![
"Everything in Free".to_string(),
"10% Usage Discount".to_string(),
"5 Custom Domains".to_string(),
"Email Support".to_string(),
"99% SLA Guarantee".to_string(),
],
min_commitment_months: 0,
}
}
/// Premium tier
pub fn premium() -> Self {
Self {
name: "premium".to_string(),
display_name: "Premium".to_string(),
monthly_fee: Decimal::new(50, 0), // 50 SYNOR/month
discount_percentage: Decimal::new(20, 0), // 20% discount
priority_support: true,
sla_percentage: Decimal::new(999, 1), // 99.9% SLA
custom_domain_limit: 20,
api_rate_limit: 5000,
features: vec![
"Everything in Standard".to_string(),
"20% Usage Discount".to_string(),
"20 Custom Domains".to_string(),
"Priority Support".to_string(),
"99.9% SLA Guarantee".to_string(),
"Advanced Analytics".to_string(),
],
min_commitment_months: 0,
}
}
/// Enterprise tier
pub fn enterprise() -> Self {
Self {
name: "enterprise".to_string(),
display_name: "Enterprise".to_string(),
monthly_fee: Decimal::new(500, 0), // 500 SYNOR/month (or custom)
discount_percentage: Decimal::new(30, 0), // 30% discount
priority_support: true,
sla_percentage: Decimal::new(9999, 2), // 99.99% SLA
custom_domain_limit: 0, // Unlimited
api_rate_limit: 0, // Unlimited
features: vec![
"Everything in Premium".to_string(),
"30%+ Usage Discount".to_string(),
"Unlimited Custom Domains".to_string(),
"Dedicated Support".to_string(),
"99.99% SLA Guarantee".to_string(),
"Custom Integrations".to_string(),
"Volume Pricing".to_string(),
"Invoice Billing".to_string(),
],
min_commitment_months: 12,
}
}
/// Create custom tier
pub fn custom(
name: impl Into<String>,
display_name: impl Into<String>,
monthly_fee: SynorDecimal,
discount_percentage: SynorDecimal,
) -> Self {
Self {
name: name.into(),
display_name: display_name.into(),
monthly_fee,
discount_percentage,
priority_support: discount_percentage >= Decimal::new(20, 0),
sla_percentage: Decimal::new(99, 0),
custom_domain_limit: 10,
api_rate_limit: 2000,
features: vec!["Custom tier".to_string()],
min_commitment_months: 0,
}
}
/// Check if this tier has a feature
pub fn has_feature(&self, feature: &str) -> bool {
self.features.iter().any(|f| f.to_lowercase().contains(&feature.to_lowercase()))
}
/// Calculate effective monthly cost including usage discount
pub fn effective_cost(&self, base_usage: SynorDecimal) -> SynorDecimal {
let discount_rate = self.discount_percentage / Decimal::ONE_HUNDRED;
let discounted_usage = base_usage * (Decimal::ONE - discount_rate);
self.monthly_fee + discounted_usage
}
/// Check if an upgrade to another tier makes sense
pub fn should_upgrade_to(&self, other: &PricingTier, monthly_usage: SynorDecimal) -> bool {
let current_cost = self.effective_cost(monthly_usage);
let other_cost = other.effective_cost(monthly_usage);
// Upgrade if other tier is cheaper or offers significant benefits
other_cost < current_cost || (other.sla_percentage > self.sla_percentage && other_cost <= current_cost * Decimal::new(12, 1))
}
}
/// Tier comparison for upgrade recommendations
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TierComparison {
pub current_tier: String,
pub recommended_tier: String,
pub current_monthly_cost: SynorDecimal,
pub recommended_monthly_cost: SynorDecimal,
pub monthly_savings: SynorDecimal,
pub additional_benefits: Vec<String>,
}
/// Compare tiers based on usage
pub fn compare_tiers(
current: &PricingTier,
all_tiers: &[PricingTier],
monthly_usage: SynorDecimal,
) -> Option<TierComparison> {
let current_cost = current.effective_cost(monthly_usage);
// Find most cost-effective tier
let mut best_tier: Option<&PricingTier> = None;
let mut best_cost = current_cost;
for tier in all_tiers {
if tier.name == current.name {
continue;
}
let tier_cost = tier.effective_cost(monthly_usage);
if tier_cost < best_cost {
best_cost = tier_cost;
best_tier = Some(tier);
}
}
best_tier.map(|recommended| {
let recommended_cost = recommended.effective_cost(monthly_usage);
let savings = current_cost - recommended_cost;
// Find additional benefits
let additional_benefits: Vec<_> = recommended
.features
.iter()
.filter(|f| !current.has_feature(f))
.cloned()
.collect();
TierComparison {
current_tier: current.name.clone(),
recommended_tier: recommended.name.clone(),
current_monthly_cost: current_cost,
recommended_monthly_cost: recommended_cost,
monthly_savings: savings,
additional_benefits,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_free_tier() {
let tier = PricingTier::free();
assert_eq!(tier.monthly_fee, Decimal::ZERO);
assert_eq!(tier.discount_percentage, Decimal::ZERO);
}
#[test]
fn test_standard_tier() {
let tier = PricingTier::standard();
assert_eq!(tier.monthly_fee, dec!(10));
assert_eq!(tier.discount_percentage, dec!(10));
assert!(!tier.priority_support);
}
#[test]
fn test_premium_tier() {
let tier = PricingTier::premium();
assert_eq!(tier.monthly_fee, dec!(50));
assert_eq!(tier.discount_percentage, dec!(20));
assert!(tier.priority_support);
}
#[test]
fn test_effective_cost() {
let free = PricingTier::free();
let premium = PricingTier::premium();
// With 200 SYNOR usage
let free_cost = free.effective_cost(dec!(200));
let premium_cost = premium.effective_cost(dec!(200));
// Free: 0 + 200 = 200
// Premium: 50 + (200 * 0.80) = 50 + 160 = 210
assert_eq!(free_cost, dec!(200));
assert_eq!(premium_cost, dec!(210));
// With 500 SYNOR usage, premium becomes cheaper
let free_cost_high = free.effective_cost(dec!(500));
let premium_cost_high = premium.effective_cost(dec!(500));
// Free: 0 + 500 = 500
// Premium: 50 + (500 * 0.80) = 50 + 400 = 450
assert_eq!(free_cost_high, dec!(500));
assert_eq!(premium_cost_high, dec!(450));
assert!(premium_cost_high < free_cost_high);
}
#[test]
fn test_tier_comparison() {
let tiers = vec![
PricingTier::free(),
PricingTier::standard(),
PricingTier::premium(),
];
let free = &tiers[0];
// High usage - should recommend upgrade
let comparison = compare_tiers(free, &tiers, dec!(500));
assert!(comparison.is_some());
let comp = comparison.unwrap();
assert_eq!(comp.current_tier, "free");
assert!(comp.monthly_savings > Decimal::ZERO);
}
}

View file

@ -0,0 +1,169 @@
# Docker Compose for Synor Economics Services
# Phase 12: Economics & Billing Infrastructure
version: "3.8"
services:
# Price Oracle Service
# Aggregates SYNOR/USD prices from multiple sources
price-oracle:
build:
context: .
dockerfile: docker/economics-service/Dockerfile
container_name: synor-price-oracle
ports:
- "4010:4010" # HTTP API
- "4011:4011" # Metrics
environment:
- RUST_LOG=info
- SERVICE_TYPE=oracle
- ORACLE_UPDATE_INTERVAL=30
- ORACLE_MIN_SOURCES=2
volumes:
- ./docker/economics-service/config.toml:/app/config/config.toml:ro
- oracle-data:/app/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4010/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
networks:
- synor-economics
# Metering Service
# Real-time usage tracking for L2 services
metering-service:
build:
context: .
dockerfile: docker/economics-service/Dockerfile
container_name: synor-metering
ports:
- "4012:4010" # HTTP API (different host port)
- "4013:4011" # Metrics
environment:
- RUST_LOG=info
- SERVICE_TYPE=metering
- METERING_BUFFER_SIZE=10000
- METERING_AGGREGATION_INTERVAL=3600
volumes:
- ./docker/economics-service/config.toml:/app/config/config.toml:ro
- metering-data:/app/data
depends_on:
- price-oracle
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4010/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
networks:
- synor-economics
# Billing Service
# Invoice generation and payment processing
billing-service:
build:
context: .
dockerfile: docker/economics-service/Dockerfile
container_name: synor-billing
ports:
- "4014:4010" # HTTP API
- "4015:4011" # Metrics
environment:
- RUST_LOG=info
- SERVICE_TYPE=billing
- BILLING_CYCLE_DAYS=30
- AUTO_PAY_ENABLED=true
volumes:
- ./docker/economics-service/config.toml:/app/config/config.toml:ro
- billing-data:/app/data
depends_on:
- price-oracle
- metering-service
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4010/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
networks:
- synor-economics
# Cost Calculator API
# Cost estimation endpoint
cost-calculator:
build:
context: .
dockerfile: docker/economics-service/Dockerfile
container_name: synor-cost-calculator
ports:
- "4016:4010" # HTTP API
- "4017:4011" # Metrics
environment:
- RUST_LOG=info
- SERVICE_TYPE=calculator
volumes:
- ./docker/economics-service/config.toml:/app/config/config.toml:ro
depends_on:
- price-oracle
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4010/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
networks:
- synor-economics
# Redis for caching and event streaming
redis:
image: redis:7-alpine
container_name: synor-economics-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- synor-economics
# PostgreSQL for persistent storage
postgres:
image: postgres:16-alpine
container_name: synor-economics-postgres
ports:
- "5432:5432"
environment:
- POSTGRES_USER=synor
- POSTGRES_PASSWORD=synor_economics_dev
- POSTGRES_DB=synor_economics
volumes:
- postgres-data:/var/lib/postgresql/data
- ./docker/economics-service/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U synor -d synor_economics"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- synor-economics
networks:
synor-economics:
driver: bridge
name: synor-economics-network
volumes:
oracle-data:
metering-data:
billing-data:
redis-data:
postgres-data:

View file

@ -0,0 +1,49 @@
# Synor Economics Service Dockerfile
# Provides pricing oracle, metering, and billing APIs
FROM rust:1.75-bookworm AS builder
WORKDIR /app
# Copy workspace files
COPY Cargo.toml Cargo.lock ./
COPY crates/ ./crates/
# Build the economics service binary
RUN cargo build --release -p synor-economics --features "http-feeds"
# Runtime image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy built binary
COPY --from=builder /app/target/release/synor-economics /usr/local/bin/ 2>/dev/null || true
# Create config directory
RUN mkdir -p /app/config /app/data
# Copy default config
COPY docker/economics-service/config.toml /app/config/
# Environment variables
ENV RUST_LOG=info
ENV CONFIG_PATH=/app/config/config.toml
ENV DATA_PATH=/app/data
# Ports
# 4010 - HTTP API
# 4011 - Metrics
EXPOSE 4010 4011
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:4010/health || exit 1
# For library-only crate, we run a simple health indicator
CMD ["echo", "Economics service ready. Use as library dependency."]

View file

@ -0,0 +1,128 @@
# Synor Economics Service Configuration
[server]
# HTTP API port
http_port = 4010
# Metrics port (Prometheus)
metrics_port = 4011
# Bind address
bind_address = "0.0.0.0"
[oracle]
# Maximum price age before considered stale (seconds)
max_price_age_secs = 300
# TWAP window duration (seconds)
twap_window_secs = 3600
# Minimum number of price sources required
min_sources = 2
# Maximum deviation between sources (percentage)
max_deviation_percent = 5.0
# Update interval (seconds)
update_interval_secs = 30
[oracle.sources]
# Enable internal DEX price feed
synor_dex = true
# Enable CoinGecko (requires API key for higher rate limits)
coingecko = true
coingecko_api_key = ""
[metering]
# Aggregation interval (seconds)
aggregation_interval_secs = 3600
# Maximum events to buffer before flush
buffer_size = 10000
# Enable real-time usage tracking
real_time_tracking = true
# Event retention period (days)
retention_days = 90
[billing]
# Billing cycle in days
billing_cycle_days = 30
# Grace period for late payments (days)
grace_period_days = 7
# Minimum invoice amount (SYNOR)
minimum_invoice_amount = 0.01
# Enable auto-pay from prepaid balance
auto_pay_enabled = true
# Enable invoice reminders
reminders_enabled = true
# Days before due date to send reminder
reminder_days_before = 3
[pricing]
# Default tier for new accounts
default_tier = "free"
# Storage L2 pricing (SYNOR)
[pricing.storage]
gb_month = 0.02
retrieval_gb = 0.01
deal_creation = 0.001
# Hosting pricing (SYNOR)
[pricing.hosting]
bandwidth_gb = 0.05
custom_domain_month = 0.50
ssl_certificate = 0.00
# Database L2 pricing (SYNOR)
[pricing.database]
storage_gb_month = 0.10
queries_per_million = 0.01
vector_searches_per_million = 0.05
# Compute L2 pricing (SYNOR)
[pricing.compute]
cpu_core_hour = 0.02
gpu_hour_rtx4090 = 0.50
memory_gb_hour = 0.005
invocations_per_million = 0.20
# Free tier allocations
[pricing.free_tier]
storage_gb = 0.5
hosting_bandwidth_gb = 1.0
database_queries = 1000000
compute_cpu_hours = 100
compute_memory_gb_hours = 200
compute_invocations = 1000000
network_bandwidth_gb = 10
# Tier discounts
[pricing.tiers.free]
monthly_fee = 0.0
discount_percent = 0
[pricing.tiers.standard]
monthly_fee = 10.0
discount_percent = 10
[pricing.tiers.premium]
monthly_fee = 50.0
discount_percent = 20
[pricing.tiers.enterprise]
monthly_fee = 500.0
discount_percent = 30
# Fee distribution
[distribution]
# L1 transaction fees
[distribution.l1]
burn_percent = 10
staker_percent = 60
treasury_percent = 20
validator_percent = 10
# L2 service fees
[distribution.l2]
burn_percent = 10
operator_percent = 70
treasury_percent = 20
[logging]
level = "info"
format = "json"
output = "stdout"

View file

@ -0,0 +1,226 @@
-- Synor Economics Database Schema
-- Phase 12: Economics & Billing Infrastructure
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Accounts table
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
account_id VARCHAR(255) UNIQUE NOT NULL,
tier VARCHAR(50) NOT NULL DEFAULT 'free',
prepaid_balance DECIMAL(38, 18) NOT NULL DEFAULT 0,
credit_balance DECIMAL(38, 18) NOT NULL DEFAULT 0,
billing_cycle_start TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Price history table
CREATE TABLE IF NOT EXISTS price_history (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
token VARCHAR(20) NOT NULL,
quote VARCHAR(20) NOT NULL,
price DECIMAL(38, 18) NOT NULL,
source VARCHAR(50) NOT NULL,
confidence DECIMAL(5, 4) NOT NULL DEFAULT 1.0,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create index for price lookups
CREATE INDEX IF NOT EXISTS idx_price_history_pair_time
ON price_history(token, quote, timestamp DESC);
-- Usage events table
CREATE TABLE IF NOT EXISTS usage_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id VARCHAR(100) UNIQUE NOT NULL,
account_id VARCHAR(255) NOT NULL REFERENCES accounts(account_id),
service_id VARCHAR(255),
service_type VARCHAR(50) NOT NULL,
resource_unit VARCHAR(50) NOT NULL,
amount DECIMAL(38, 18) NOT NULL,
cost DECIMAL(38, 18),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
metadata JSONB DEFAULT '{}'
);
-- Create indexes for usage queries
CREATE INDEX IF NOT EXISTS idx_usage_events_account
ON usage_events(account_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_service
ON usage_events(service_type, timestamp DESC);
-- Invoices table
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id VARCHAR(100) UNIQUE NOT NULL,
invoice_number VARCHAR(50) NOT NULL,
account_id VARCHAR(255) NOT NULL REFERENCES accounts(account_id),
status VARCHAR(20) NOT NULL DEFAULT 'draft',
period_start TIMESTAMP WITH TIME ZONE NOT NULL,
period_end TIMESTAMP WITH TIME ZONE NOT NULL,
subtotal DECIMAL(38, 18) NOT NULL DEFAULT 0,
discount DECIMAL(38, 18) NOT NULL DEFAULT 0,
discount_description TEXT,
tax DECIMAL(38, 18) NOT NULL DEFAULT 0,
total DECIMAL(38, 18) NOT NULL DEFAULT 0,
due_date TIMESTAMP WITH TIME ZONE NOT NULL,
paid_at TIMESTAMP WITH TIME ZONE,
payment_id VARCHAR(100),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create indexes for invoice queries
CREATE INDEX IF NOT EXISTS idx_invoices_account
ON invoices(account_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_status
ON invoices(status);
-- Invoice line items table
CREATE TABLE IF NOT EXISTS invoice_line_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id VARCHAR(100) NOT NULL REFERENCES invoices(invoice_id),
description TEXT NOT NULL,
service_type VARCHAR(50) NOT NULL,
quantity DECIMAL(38, 18) NOT NULL,
unit_price DECIMAL(38, 18) NOT NULL,
amount DECIMAL(38, 18) NOT NULL
);
-- Payments table
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
payment_id VARCHAR(100) UNIQUE NOT NULL,
account_id VARCHAR(255) NOT NULL REFERENCES accounts(account_id),
invoice_id VARCHAR(100) REFERENCES invoices(invoice_id),
amount DECIMAL(38, 18) NOT NULL,
method VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
transaction_hash VARCHAR(100),
block_number BIGINT,
failure_reason TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
-- Create indexes for payment queries
CREATE INDEX IF NOT EXISTS idx_payments_account
ON payments(account_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_payments_status
ON payments(status);
-- Credits table
CREATE TABLE IF NOT EXISTS credits (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
credit_id VARCHAR(100) UNIQUE NOT NULL,
account_id VARCHAR(255) NOT NULL REFERENCES accounts(account_id),
original_amount DECIMAL(38, 18) NOT NULL,
remaining_amount DECIMAL(38, 18) NOT NULL,
used_amount DECIMAL(38, 18) NOT NULL DEFAULT 0,
credit_type VARCHAR(50) NOT NULL,
reason TEXT NOT NULL,
reference_id VARCHAR(255),
approved_by VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT true,
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create indexes for credit queries
CREATE INDEX IF NOT EXISTS idx_credits_account
ON credits(account_id, is_active);
-- Discounts table
CREATE TABLE IF NOT EXISTS discounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
discount_type VARCHAR(50) NOT NULL,
value DECIMAL(38, 18) NOT NULL,
min_spend DECIMAL(38, 18),
max_discount DECIMAL(38, 18),
service_types TEXT[],
account_ids TEXT[],
start_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
end_date TIMESTAMP WITH TIME ZONE,
max_uses INTEGER,
current_uses INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Pricing tiers table
CREATE TABLE IF NOT EXISTS pricing_tiers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
monthly_fee DECIMAL(38, 18) NOT NULL DEFAULT 0,
discount_percentage DECIMAL(5, 2) NOT NULL DEFAULT 0,
priority_support BOOLEAN NOT NULL DEFAULT false,
sla_percentage DECIMAL(5, 2) NOT NULL DEFAULT 95.00,
custom_domain_limit INTEGER NOT NULL DEFAULT 1,
api_rate_limit INTEGER NOT NULL DEFAULT 100,
features TEXT[] NOT NULL DEFAULT '{}',
min_commitment_months INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Insert default pricing tiers
INSERT INTO pricing_tiers (name, display_name, monthly_fee, discount_percentage, priority_support, sla_percentage, custom_domain_limit, api_rate_limit, features, min_commitment_months)
VALUES
('free', 'Free', 0, 0, false, 95.00, 1, 100, ARRAY['0.5 GB Storage', '1 GB Hosting Bandwidth', '1M Database Queries', '100 CPU Core-Hours', 'Community Support'], 0),
('standard', 'Standard', 10, 10, false, 99.00, 5, 1000, ARRAY['Everything in Free', '10% Usage Discount', '5 Custom Domains', 'Email Support', '99% SLA Guarantee'], 0),
('premium', 'Premium', 50, 20, true, 99.90, 20, 5000, ARRAY['Everything in Standard', '20% Usage Discount', '20 Custom Domains', 'Priority Support', '99.9% SLA Guarantee', 'Advanced Analytics'], 0),
('enterprise', 'Enterprise', 500, 30, true, 99.99, 0, 0, ARRAY['Everything in Premium', '30%+ Usage Discount', 'Unlimited Custom Domains', 'Dedicated Support', '99.99% SLA Guarantee', 'Custom Integrations', 'Volume Pricing', 'Invoice Billing'], 12)
ON CONFLICT (name) DO NOTHING;
-- Aggregated usage view
CREATE OR REPLACE VIEW usage_summary AS
SELECT
account_id,
service_type,
DATE_TRUNC('day', timestamp) as usage_date,
SUM(amount) as total_amount,
SUM(cost) as total_cost,
COUNT(*) as event_count
FROM usage_events
GROUP BY account_id, service_type, DATE_TRUNC('day', timestamp);
-- Outstanding invoices view
CREATE OR REPLACE VIEW outstanding_invoices AS
SELECT
i.*,
a.prepaid_balance,
a.credit_balance,
(a.prepaid_balance + a.credit_balance) >= i.total as can_auto_pay
FROM invoices i
JOIN accounts a ON i.account_id = a.account_id
WHERE i.status IN ('pending', 'overdue')
ORDER BY i.due_date ASC;
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Add triggers for updated_at
CREATE TRIGGER update_accounts_updated_at
BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_invoices_updated_at
BEFORE UPDATE ON invoices
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Grant permissions
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO synor;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO synor;