1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub struct BtcSharesValue {
4 pub gross: u64,
6 pub fee: u64,
8}
9
10#[derive(Debug, thiserror::Error)]
11pub enum BtcSharesValueError {
12 #[error("shares exceed the vault's issued shares")]
13 InsufficientShares,
14 #[error("claim fee bps exceed the 10000 denominator")]
15 InvalidClaimFeeBps,
16}
17
18pub fn sats_to_btc(
25 shares: u64,
26 vault_amount: u64,
27 vault_shares: u64,
28 claim_fee_bps: u32,
29) -> Result<BtcSharesValue, BtcSharesValueError> {
30 const BPS_DENOMINATOR: u128 = 10_000;
31
32 if claim_fee_bps as u128 > BPS_DENOMINATOR {
33 return Err(BtcSharesValueError::InvalidClaimFeeBps);
34 }
35 if shares == 0 {
36 return Ok(BtcSharesValue { gross: 0, fee: 0 });
37 }
38 if shares > vault_shares {
39 return Err(BtcSharesValueError::InsufficientShares);
40 }
41
42 let gross = (shares as u128 * vault_amount as u128 / vault_shares as u128) as u64;
46 let fee = (gross as u128 * claim_fee_bps as u128 / BPS_DENOMINATOR) as u64;
47 Ok(BtcSharesValue { gross, fee })
48}
49
50#[derive(Debug, thiserror::Error)]
51pub enum BtcToSatsError {
52 #[error("share amount is not computable for the vault state")]
53 MathOverflow,
54}
55
56pub fn btc_to_sats(btc_amount: u64, vault_amount: u64, vault_shares: u64) -> Result<u64, BtcToSatsError> {
63 if vault_shares == 0 {
64 return Ok(btc_amount);
65 }
66 if vault_amount == 0 {
67 return Err(BtcToSatsError::MathOverflow);
68 }
69 u64::try_from(btc_amount as u128 * vault_shares as u128 / vault_amount as u128)
70 .map_err(|_| BtcToSatsError::MathOverflow)
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 const CLAIM_FEE_BPS: u32 = 1_000;
80
81 #[test]
82 fn values_shares_at_the_current_exchange_rate() {
83 assert_eq!(sats_to_btc(50, 100, 100, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 50, fee: 5 });
85 }
86
87 #[test]
88 fn full_drain_of_appreciated_vault_matches_on_chain_payout() {
89 assert_eq!(sats_to_btc(50, 55, 50, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 55, fee: 5 });
92 }
93
94 #[test]
95 fn floors_gross_and_fee() {
96 assert_eq!(sats_to_btc(1, 10, 3, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 3, fee: 0 });
98 }
99
100 #[test]
101 fn zero_fee_bps_yields_gross_only() {
102 assert_eq!(sats_to_btc(50, 100, 100, 0).unwrap(), BtcSharesValue { gross: 50, fee: 0 });
103 }
104
105 #[test]
106 fn zero_shares_are_worth_zero() {
107 assert_eq!(sats_to_btc(0, 100, 100, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 0, fee: 0 });
108 assert_eq!(sats_to_btc(0, 0, 0, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 0, fee: 0 });
110 }
111
112 #[test]
113 fn rejects_claim_fee_above_the_bps_denominator() {
114 assert!(matches!(sats_to_btc(50, 100, 100, 10_001), Err(BtcSharesValueError::InvalidClaimFeeBps)));
115 }
116
117 #[test]
118 fn full_fee_withholds_the_entire_gross() {
119 assert_eq!(sats_to_btc(50, 100, 100, 10_000).unwrap(), BtcSharesValue { gross: 50, fee: 50 });
120 }
121
122 #[test]
123 fn rejects_overdraw() {
124 assert!(matches!(sats_to_btc(101, 100, 100, CLAIM_FEE_BPS), Err(BtcSharesValueError::InsufficientShares)));
125 assert!(matches!(sats_to_btc(1, 5, 0, CLAIM_FEE_BPS), Err(BtcSharesValueError::InsufficientShares)));
127 }
128
129 #[test]
130 fn handles_max_values_without_overflow() {
131 let max = u64::MAX;
133 let value = sats_to_btc(max, max, max, CLAIM_FEE_BPS).unwrap();
134 assert_eq!(value.gross, max);
135 }
136
137 #[test]
138 fn first_deposit_mints_one_to_one() {
139 assert_eq!(btc_to_sats(100, 0, 0).unwrap(), 100);
140 assert_eq!(btc_to_sats(10, 5, 0).unwrap(), 10);
142 }
143
144 #[test]
145 fn later_deposit_mints_at_exchange_rate_and_floors() {
146 assert_eq!(btc_to_sats(11, 55, 50).unwrap(), 10);
149 assert_eq!(btc_to_sats(10, 55, 50).unwrap(), 9);
151 }
152
153 #[test]
154 fn zero_deposit_mints_zero_shares() {
155 assert_eq!(btc_to_sats(0, 100, 100).unwrap(), 0);
156 assert_eq!(btc_to_sats(0, 0, 0).unwrap(), 0);
157 }
158
159 #[test]
160 fn errors_where_on_chain_math_fails() {
161 assert!(matches!(btc_to_sats(1, 0, 100), Err(BtcToSatsError::MathOverflow)));
163 assert!(matches!(btc_to_sats(u64::MAX, 1, 2), Err(BtcToSatsError::MathOverflow)));
165 }
166
167 #[test]
168 fn round_trips_with_sats_to_btc_at_zero_fee() {
169 let (deposit, vault_amount, vault_shares) = (11, 55, 50);
172 let shares = btc_to_sats(deposit, vault_amount, vault_shares).unwrap();
173 let value = sats_to_btc(shares, vault_amount + deposit, vault_shares + shares, 0).unwrap();
174 assert_eq!(value.gross, deposit);
175 }
176}