Skip to content

Commit ea68d87

Browse files
committed
feat: simplify location funding
1 parent 1e1e7e5 commit ea68d87

File tree

15 files changed

+563
-989
lines changed

15 files changed

+563
-989
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- Migration: Remove unused tables after switching to computed balance system
2+
--
3+
-- The balance is now computed on-demand from:
4+
-- pool_balance = SUM(donations) - SUM(scans)
5+
-- available = pool_balance * max_fill_percentage * fill_ratio
6+
--
7+
-- These tables are no longer written to and can be removed:
8+
9+
-- Drop refills table (periodic refill log - no longer used)
10+
DROP TABLE IF EXISTS refills;
11+
12+
-- Drop location_pool_debits table (pool debit tracking - now using scans instead)
13+
DROP TABLE IF EXISTS location_pool_debits;

src/balance.rs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
use chrono::{DateTime, Utc};
2+
3+
/// Configuration for balance calculation
4+
#[derive(Debug, Clone)]
5+
pub struct BalanceConfig {
6+
/// Time to fill from 0 to max_fill (in days)
7+
pub time_to_full_days: u64,
8+
/// Maximum percentage of pool that can fill a location (e.g., 0.1 = 10%)
9+
pub max_fill_percentage: f64,
10+
}
11+
12+
impl Default for BalanceConfig {
13+
fn default() -> Self {
14+
Self {
15+
time_to_full_days: 21,
16+
max_fill_percentage: 0.1,
17+
}
18+
}
19+
}
20+
21+
/// Calculate the computed balance for a location
22+
///
23+
/// Formula:
24+
/// - max_fill = pool_balance * max_fill_percentage
25+
/// - fill_ratio = min(time_since_withdraw / time_to_full, 1.0)
26+
/// - computed_balance = max_fill * fill_ratio
27+
///
28+
/// Uses `created_at` when `last_withdraw_at` is None (location never withdrawn from).
29+
pub fn compute_balance_msats(
30+
pool_balance_msats: i64,
31+
last_withdraw_at: Option<DateTime<Utc>>,
32+
created_at: DateTime<Utc>,
33+
config: &BalanceConfig,
34+
) -> i64 {
35+
if pool_balance_msats <= 0 {
36+
return 0;
37+
}
38+
39+
// Determine reference time (last withdraw or creation time)
40+
let reference_time = last_withdraw_at.unwrap_or(created_at);
41+
let now = Utc::now();
42+
43+
// Calculate time elapsed since reference
44+
let elapsed = now.signed_duration_since(reference_time);
45+
let elapsed_secs = elapsed.num_seconds().max(0) as f64;
46+
47+
// Time to full in seconds
48+
let time_to_full_secs = (config.time_to_full_days * 24 * 60 * 60) as f64;
49+
50+
// Fill ratio (0.0 to 1.0)
51+
let fill_ratio = (elapsed_secs / time_to_full_secs).min(1.0);
52+
53+
// Max fill based on pool percentage
54+
let max_fill_msats = (pool_balance_msats as f64 * config.max_fill_percentage) as i64;
55+
56+
// Computed balance
57+
(max_fill_msats as f64 * fill_ratio) as i64
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use super::*;
63+
use chrono::Duration;
64+
65+
fn test_config() -> BalanceConfig {
66+
BalanceConfig {
67+
time_to_full_days: 21,
68+
max_fill_percentage: 0.1,
69+
}
70+
}
71+
72+
#[test]
73+
fn test_empty_pool_returns_zero() {
74+
let config = test_config();
75+
let now = Utc::now();
76+
let result = compute_balance_msats(0, None, now, &config);
77+
assert_eq!(result, 0);
78+
}
79+
80+
#[test]
81+
fn test_negative_pool_returns_zero() {
82+
let config = test_config();
83+
let now = Utc::now();
84+
let result = compute_balance_msats(-1000, None, now, &config);
85+
assert_eq!(result, 0);
86+
}
87+
88+
#[test]
89+
fn test_new_location_starts_at_zero() {
90+
let config = test_config();
91+
let now = Utc::now();
92+
// Created just now, no withdrawals
93+
let result = compute_balance_msats(1_000_000_000, None, now, &config); // 1M sats pool
94+
assert_eq!(result, 0);
95+
}
96+
97+
#[test]
98+
fn test_half_time_gives_half_fill() {
99+
let config = test_config();
100+
let now = Utc::now();
101+
let created_at = now - Duration::milliseconds((config.time_to_full_days as i64 * 24 * 60 * 60 * 1000) / 2);
102+
103+
let pool_msats = 1_000_000_000; // 1M sats = 1B msats
104+
let result = compute_balance_msats(pool_msats, None, created_at, &config);
105+
106+
// Expected: pool * 0.1 * 0.5 = 1B * 0.1 * 0.5 = 50M msats = 50k sats
107+
let expected = (pool_msats as f64 * 0.1 * 0.5) as i64;
108+
assert!((result - expected).abs() < 1000); // Allow small rounding error
109+
}
110+
111+
#[test]
112+
fn test_full_time_gives_max_fill() {
113+
let config = test_config();
114+
let now = Utc::now();
115+
let created_at = now - Duration::days(config.time_to_full_days as i64);
116+
117+
let pool_msats = 1_000_000_000; // 1M sats
118+
let result = compute_balance_msats(pool_msats, None, created_at, &config);
119+
120+
// Expected: pool * 0.1 = 1B * 0.1 = 100M msats = 100k sats
121+
let expected = (pool_msats as f64 * config.max_fill_percentage) as i64;
122+
assert_eq!(result, expected);
123+
}
124+
125+
#[test]
126+
fn test_over_time_caps_at_max_fill() {
127+
let config = test_config();
128+
let now = Utc::now();
129+
let created_at = now - Duration::days(config.time_to_full_days as i64 * 2); // Double the time
130+
131+
let pool_msats = 1_000_000_000;
132+
let result = compute_balance_msats(pool_msats, None, created_at, &config);
133+
134+
// Should cap at max_fill, not exceed it
135+
let expected = (pool_msats as f64 * config.max_fill_percentage) as i64;
136+
assert_eq!(result, expected);
137+
}
138+
139+
#[test]
140+
fn test_withdrawal_resets_fill() {
141+
let config = test_config();
142+
let now = Utc::now();
143+
let created_at = now - Duration::days(30); // Created 30 days ago
144+
let last_withdraw_at = Some(now); // Just withdrawn
145+
146+
let pool_msats = 1_000_000_000;
147+
let result = compute_balance_msats(pool_msats, last_withdraw_at, created_at, &config);
148+
149+
// Just withdrawn, should be ~0
150+
assert_eq!(result, 0);
151+
}
152+
153+
#[test]
154+
fn test_partial_refill_after_withdrawal() {
155+
let config = test_config();
156+
let now = Utc::now();
157+
let created_at = now - Duration::days(30);
158+
let last_withdraw_at = Some(now - Duration::days(7)); // Withdrew 7 days ago
159+
160+
let pool_msats = 1_000_000_000;
161+
let result = compute_balance_msats(pool_msats, last_withdraw_at, created_at, &config);
162+
163+
// 7/21 = 1/3 of the way to full
164+
let expected = (pool_msats as f64 * 0.1 * (7.0 / 21.0)) as i64;
165+
assert!((result - expected).abs() < 1000);
166+
}
167+
168+
#[test]
169+
fn test_different_fill_percentage() {
170+
let config = BalanceConfig {
171+
time_to_full_days: 21,
172+
max_fill_percentage: 0.05, // 5%
173+
};
174+
let now = Utc::now();
175+
let created_at = now - Duration::days(21);
176+
177+
let pool_msats = 1_000_000_000;
178+
let result = compute_balance_msats(pool_msats, None, created_at, &config);
179+
180+
// Expected: pool * 0.05 = 50M msats
181+
let expected = (pool_msats as f64 * 0.05) as i64;
182+
assert_eq!(result, expected);
183+
}
184+
185+
#[test]
186+
fn test_different_time_to_full() {
187+
let config = BalanceConfig {
188+
time_to_full_days: 7, // 1 week
189+
max_fill_percentage: 0.1,
190+
};
191+
let now = Utc::now();
192+
let created_at = now - Duration::days(7);
193+
194+
let pool_msats = 1_000_000_000;
195+
let result = compute_balance_msats(pool_msats, None, created_at, &config);
196+
197+
// Should be at max after 7 days
198+
let expected = (pool_msats as f64 * 0.1) as i64;
199+
assert_eq!(result, expected);
200+
}
201+
}

src/config.rs

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,13 @@ pub struct Config {
2121
#[arg(long, env = "SH_BASE_URL")]
2222
pub base_url: Option<String>,
2323

24-
/// Percentage of donation pool to distribute per minute (default: 0.016%)
25-
/// This is divided equally among all active locations
26-
#[arg(long, env = "SH_POOL_PERCENTAGE_PER_MINUTE", default_value = "0.00016")]
27-
pub pool_percentage_per_minute: f64,
24+
/// Time in days for a location to fill from empty to max_fill (default: 21)
25+
#[arg(long, env = "SH_TIME_TO_FULL_DAYS", default_value = "21")]
26+
pub time_to_full_days: u64,
2827

29-
/// Maximum sats per location (global cap)
30-
#[arg(long, env = "SH_MAX_SATS_PER_LOCATION", default_value = "1000")]
31-
pub max_sats_per_location: i64,
32-
33-
/// Refill check interval in seconds
34-
#[arg(long, env = "SH_REFILL_CHECK_INTERVAL_SECS", default_value = "300")]
35-
pub refill_check_interval_secs: u64,
28+
/// Maximum percentage of donation pool that can fill a location (default: 0.1 = 10%)
29+
#[arg(long, env = "SH_MAX_FILL_PERCENTAGE", default_value = "0.1")]
30+
pub max_fill_percentage: f64,
3631

3732
/// Static files directory
3833
#[arg(long, env = "SH_STATIC_DIR", default_value = "./static")]

0 commit comments

Comments
 (0)