Skip to main content

ant_node/payment/
pricing.rs

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