Skip to main content

saorsa_node/payment/
pricing.rs

1//! Local fullness-based pricing algorithm for saorsa-node.
2//!
3//! Mirrors the logarithmic pricing curve from autonomi's `MerklePaymentVault` contract:
4//! - Empty node → price ≈ `MIN_PRICE` (floor)
5//! - Filling up → price increases logarithmically
6//! - Nearly full → price spikes (ln(x) as x→0)
7//! - At capacity → returns `u64::MAX` (effectively refuses new data)
8//!
9//! ## Design Rationale: Capacity-Based Pricing
10//!
11//! Pricing is based on node **fullness** (percentage of storage capacity used),
12//! not on a fixed cost-per-byte. This design mirrors the autonomi
13//! `MerklePaymentVault` on-chain contract and creates natural load balancing:
14//!
15//! - **Empty nodes** charge the minimum floor price, attracting new data
16//! - **Nearly full nodes** charge exponentially more via the logarithmic curve
17//! - **This pushes clients toward emptier nodes**, distributing data across the network
18//!
19//! A flat cost-per-byte model would not incentivize distribution — all nodes would
20//! charge the same regardless of remaining capacity. The logarithmic curve ensures
21//! the network self-balances as nodes fill up.
22
23use ant_evm::{Amount, QuotingMetrics};
24
25/// Minimum price floor (matches contract's `minPrice = 3`).
26const MIN_PRICE: u64 = 3;
27
28/// Scaling factor for the logarithmic pricing curve.
29/// In the contract this is 1e18; we normalize to 1.0 for f64 arithmetic.
30const SCALING_FACTOR: f64 = 1.0;
31
32/// ANT price constant (normalized to 1.0, matching contract's 1e18/1e18 ratio).
33const ANT_PRICE: f64 = 1.0;
34
35/// Calculate a local price estimate from node quoting metrics.
36///
37/// Implements the autonomi pricing formula:
38/// ```text
39/// price = (-s/ANT) * (ln|rUpper - 1| - ln|rLower - 1|) + pMin*(rUpper - rLower) - (rUpper - rLower)/ANT
40/// ```
41///
42/// where:
43/// - `rLower = total_cost_units / max_cost_units` (current fullness ratio)
44/// - `rUpper = (total_cost_units + cost_unit) / max_cost_units` (fullness after storing)
45/// - `s` = scaling factor, `ANT` = ANT price, `pMin` = minimum price
46#[allow(
47    clippy::cast_precision_loss,
48    clippy::cast_possible_truncation,
49    clippy::cast_sign_loss
50)]
51#[must_use]
52pub fn calculate_price(metrics: &QuotingMetrics) -> Amount {
53    let min_price = Amount::from(MIN_PRICE);
54
55    // Edge case: zero or very small capacity
56    if metrics.max_records == 0 {
57        return min_price;
58    }
59
60    // Use close_records_stored as the authoritative record count for pricing.
61    let total_records = metrics.close_records_stored as u64;
62
63    let max_records = metrics.max_records as f64;
64
65    // Normalize to [0, 1) range (matching contract's _getBound)
66    let r_lower = total_records as f64 / max_records;
67    // Adding one record (cost_unit = 1 normalized)
68    let r_upper = (total_records + 1) as f64 / max_records;
69
70    // At capacity: return maximum price to effectively refuse new data
71    if r_lower >= 1.0 || r_upper >= 1.0 {
72        return Amount::from(u64::MAX);
73    }
74    if (r_upper - r_lower).abs() < f64::EPSILON {
75        return min_price;
76    }
77
78    // Calculate |r - 1| for logarithm inputs
79    let upper_diff = (r_upper - 1.0).abs();
80    let lower_diff = (r_lower - 1.0).abs();
81
82    // Avoid log(0)
83    if upper_diff < f64::EPSILON || lower_diff < f64::EPSILON {
84        return min_price;
85    }
86
87    let log_upper = upper_diff.ln();
88    let log_lower = lower_diff.ln();
89    let log_diff = log_upper - log_lower;
90
91    let linear_part = r_upper - r_lower;
92
93    // Formula: price = (-s/ANT) * logDiff + pMin * linearPart - linearPart/ANT
94    let part_one = (-SCALING_FACTOR / ANT_PRICE) * log_diff;
95    let part_two = MIN_PRICE as f64 * linear_part;
96    let part_three = linear_part / ANT_PRICE;
97
98    let price = part_one + part_two - part_three;
99
100    if price <= 0.0 || !price.is_finite() {
101        return min_price;
102    }
103
104    // Scale by data_size (larger data costs proportionally more)
105    let data_size_factor = metrics.data_size.max(1) as f64;
106    let scaled_price = price * data_size_factor;
107
108    if !scaled_price.is_finite() {
109        return min_price;
110    }
111
112    // Convert to Amount (U256), floor at MIN_PRICE
113    let price_u64 = if scaled_price > u64::MAX as f64 {
114        u64::MAX
115    } else {
116        (scaled_price as u64).max(MIN_PRICE)
117    };
118
119    Amount::from(price_u64)
120}
121
122#[cfg(test)]
123#[allow(clippy::unwrap_used, clippy::expect_used)]
124mod tests {
125    use super::*;
126
127    fn make_metrics(
128        records_stored: usize,
129        max_records: usize,
130        data_size: usize,
131        data_type: u32,
132    ) -> QuotingMetrics {
133        let records_per_type = if records_stored > 0 {
134            vec![(data_type, u32::try_from(records_stored).unwrap_or(u32::MAX))]
135        } else {
136            vec![]
137        };
138        QuotingMetrics {
139            data_type,
140            data_size,
141            close_records_stored: records_stored,
142            records_per_type,
143            max_records,
144            received_payment_count: 0,
145            live_time: 0,
146            network_density: None,
147            network_size: Some(500),
148        }
149    }
150
151    #[test]
152    fn test_empty_node_gets_min_price() {
153        let metrics = make_metrics(0, 1000, 1, 0);
154        let price = calculate_price(&metrics);
155        // Empty node should return approximately MIN_PRICE
156        assert_eq!(price, Amount::from(MIN_PRICE));
157    }
158
159    #[test]
160    fn test_half_full_node_costs_more() {
161        let empty = make_metrics(0, 1000, 1024, 0);
162        let half = make_metrics(500, 1000, 1024, 0);
163        let price_empty = calculate_price(&empty);
164        let price_half = calculate_price(&half);
165        assert!(
166            price_half > price_empty,
167            "Half-full price ({price_half}) should exceed empty price ({price_empty})"
168        );
169    }
170
171    #[test]
172    fn test_nearly_full_node_costs_much_more() {
173        let half = make_metrics(500, 1000, 1024, 0);
174        let nearly_full = make_metrics(900, 1000, 1024, 0);
175        let price_half = calculate_price(&half);
176        let price_nearly_full = calculate_price(&nearly_full);
177        assert!(
178            price_nearly_full > price_half,
179            "Nearly-full price ({price_nearly_full}) should far exceed half-full price ({price_half})"
180        );
181    }
182
183    #[test]
184    fn test_full_node_returns_max_price() {
185        // At capacity (r_lower >= 1.0), effectively refuse new data with max price
186        let metrics = make_metrics(1000, 1000, 1024, 0);
187        let price = calculate_price(&metrics);
188        assert_eq!(price, Amount::from(u64::MAX));
189    }
190
191    #[test]
192    fn test_price_increases_monotonically() {
193        let max_records = 1000;
194        let data_size = 1024;
195        let mut prev_price = Amount::ZERO;
196
197        // Check from 0% to 99% full
198        for pct in 0..100 {
199            let records = pct * max_records / 100;
200            let metrics = make_metrics(records, max_records, data_size, 0);
201            let price = calculate_price(&metrics);
202            assert!(
203                price >= prev_price,
204                "Price at {pct}% ({price}) should be >= price at previous step ({prev_price})"
205            );
206            prev_price = price;
207        }
208    }
209
210    #[test]
211    fn test_zero_max_records_returns_min_price() {
212        let metrics = make_metrics(0, 0, 1024, 0);
213        let price = calculate_price(&metrics);
214        assert_eq!(price, Amount::from(MIN_PRICE));
215    }
216
217    #[test]
218    fn test_different_data_sizes_same_fullness() {
219        let small = make_metrics(500, 1000, 100, 0);
220        let large = make_metrics(500, 1000, 10000, 0);
221        let price_small = calculate_price(&small);
222        let price_large = calculate_price(&large);
223        assert!(
224            price_large > price_small,
225            "Larger data ({price_large}) should cost more than smaller data ({price_small})"
226        );
227    }
228
229    #[test]
230    fn test_price_with_multiple_record_types() {
231        // 300 type-0 records + 200 type-1 records = 500 total out of 1000
232        let metrics = QuotingMetrics {
233            data_type: 0,
234            data_size: 1024,
235            close_records_stored: 500,
236            records_per_type: vec![(0, 300), (1, 200)],
237            max_records: 1000,
238            received_payment_count: 0,
239            live_time: 0,
240            network_density: None,
241            network_size: Some(500),
242        };
243        let price_multi = calculate_price(&metrics);
244
245        // Compare with single-type equivalent (500 of type 0)
246        let metrics_single = make_metrics(500, 1000, 1024, 0);
247        let price_single = calculate_price(&metrics_single);
248
249        // Same total records → same price
250        assert_eq!(price_multi, price_single);
251    }
252
253    #[test]
254    fn test_price_at_95_percent() {
255        let metrics = make_metrics(950, 1000, 1024, 0);
256        let price = calculate_price(&metrics);
257        let min = Amount::from(MIN_PRICE);
258        assert!(
259            price > min,
260            "Price at 95% should be above minimum, got {price}"
261        );
262    }
263
264    #[test]
265    fn test_price_at_99_percent() {
266        let metrics = make_metrics(990, 1000, 1024, 0);
267        let price = calculate_price(&metrics);
268        let price_95 = calculate_price(&make_metrics(950, 1000, 1024, 0));
269        assert!(
270            price > price_95,
271            "Price at 99% ({price}) should exceed price at 95% ({price_95})"
272        );
273    }
274
275    #[test]
276    fn test_over_capacity_returns_max_price() {
277        // 1100 records stored but max is 1000 — over capacity
278        let metrics = make_metrics(1100, 1000, 1024, 0);
279        let price = calculate_price(&metrics);
280        assert_eq!(
281            price,
282            Amount::from(u64::MAX),
283            "Over-capacity should return max price"
284        );
285    }
286
287    #[test]
288    fn test_price_deterministic() {
289        let metrics = make_metrics(500, 1000, 1024, 0);
290        let price1 = calculate_price(&metrics);
291        let price2 = calculate_price(&metrics);
292        let price3 = calculate_price(&metrics);
293        assert_eq!(price1, price2);
294        assert_eq!(price2, price3);
295    }
296}