1pub const SHARE_OFFSET: u64 = 1_000;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct BtcSharesValue {
9 pub gross: u64,
11 pub fee: u64,
13}
14
15#[derive(Debug, thiserror::Error)]
16pub enum BtcSharesValueError {
17 #[error("shares exceed the vault's issued shares")]
18 InsufficientShares,
19 #[error("claim fee bps exceed the 10000 denominator")]
20 InvalidClaimFeeBps,
21}
22
23pub fn sats_to_btc(
30 shares: u64,
31 vault_amount: u64,
32 vault_shares: u64,
33 claim_fee_bps: u32,
34) -> Result<BtcSharesValue, BtcSharesValueError> {
35 const BPS_DENOMINATOR: u128 = 10_000;
36
37 if claim_fee_bps as u128 > BPS_DENOMINATOR {
38 return Err(BtcSharesValueError::InvalidClaimFeeBps);
39 }
40 if shares == 0 {
41 return Ok(BtcSharesValue { gross: 0, fee: 0 });
42 }
43 if shares > vault_shares {
44 return Err(BtcSharesValueError::InsufficientShares);
45 }
46
47 let gross = (shares as u128 * (vault_amount as u128 + 1) / (vault_shares as u128 + SHARE_OFFSET as u128)) as u64;
51 let fee = (gross as u128 * claim_fee_bps as u128 / BPS_DENOMINATOR) as u64;
52 Ok(BtcSharesValue { gross, fee })
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum BtcToSatsError {
57 #[error("share amount is not computable for the vault state")]
58 MathOverflow,
59}
60
61pub fn btc_to_sats(btc_amount: u64, vault_amount: u64, vault_shares: u64) -> Result<u64, BtcToSatsError> {
68 u64::try_from(btc_amount as u128 * (vault_shares as u128 + SHARE_OFFSET as u128) / (vault_amount as u128 + 1))
69 .map_err(|_| BtcToSatsError::MathOverflow)
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 const CLAIM_FEE_BPS: u32 = 1_000;
79
80 #[test]
81 fn values_shares_at_the_current_exchange_rate() {
82 assert_eq!(sats_to_btc(50_000, 100, 100_000, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 50, fee: 5 });
84 }
85
86 #[test]
87 fn full_drain_of_appreciated_vault_matches_on_chain_payout() {
88 assert_eq!(sats_to_btc(50_000, 55, 50_000, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 54, fee: 5 });
92 }
93
94 #[test]
95 fn floors_gross_and_fee() {
96 assert_eq!(sats_to_btc(1_000, 10, 3_000, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 2, fee: 0 });
99 }
100
101 #[test]
102 fn zero_fee_bps_yields_gross_only() {
103 assert_eq!(sats_to_btc(50_000, 100, 100_000, 0).unwrap(), BtcSharesValue { gross: 50, fee: 0 });
104 }
105
106 #[test]
107 fn zero_shares_are_worth_zero() {
108 assert_eq!(sats_to_btc(0, 100, 100_000, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 0, fee: 0 });
109 assert_eq!(sats_to_btc(0, 0, 0, CLAIM_FEE_BPS).unwrap(), BtcSharesValue { gross: 0, fee: 0 });
111 }
112
113 #[test]
114 fn rejects_claim_fee_above_the_bps_denominator() {
115 assert!(matches!(sats_to_btc(50_000, 100, 100_000, 10_001), Err(BtcSharesValueError::InvalidClaimFeeBps)));
116 }
117
118 #[test]
119 fn full_fee_withholds_the_entire_gross() {
120 assert_eq!(sats_to_btc(50_000, 100, 100_000, 10_000).unwrap(), BtcSharesValue { gross: 50, fee: 50 });
121 }
122
123 #[test]
124 fn rejects_overdraw() {
125 assert!(matches!(
126 sats_to_btc(100_001, 100, 100_000, CLAIM_FEE_BPS),
127 Err(BtcSharesValueError::InsufficientShares)
128 ));
129 assert!(matches!(sats_to_btc(1, 5, 0, CLAIM_FEE_BPS), Err(BtcSharesValueError::InsufficientShares)));
131 }
132
133 #[test]
134 fn handles_max_values_without_overflow() {
135 let max = u64::MAX;
138 let value = sats_to_btc(max, max, max, CLAIM_FEE_BPS).unwrap();
139 assert!(value.gross <= max && value.gross > max - SHARE_OFFSET);
140 }
141
142 #[test]
143 fn first_deposit_mints_at_the_offset_rate() {
144 assert_eq!(btc_to_sats(100, 0, 0).unwrap(), 100 * SHARE_OFFSET);
145 }
146
147 #[test]
148 fn later_deposit_mints_at_exchange_rate_and_floors() {
149 assert_eq!(btc_to_sats(11, 55, 50_000).unwrap(), 10_017);
152 assert_eq!(btc_to_sats(10, 55, 50_000).unwrap(), 9_107);
154 }
155
156 #[test]
157 fn small_deposit_into_appreciated_vault_mints_shares() {
158 assert_eq!(btc_to_sats(1, 2_000, 1_000_000).unwrap(), 500);
161 }
162
163 #[test]
164 fn zero_deposit_mints_zero_shares() {
165 assert_eq!(btc_to_sats(0, 100, 100_000).unwrap(), 0);
166 assert_eq!(btc_to_sats(0, 0, 0).unwrap(), 0);
167 }
168
169 #[test]
170 fn shares_without_btc_no_longer_error() {
171 assert_eq!(btc_to_sats(1, 0, 100).unwrap(), 1_100);
174 }
175
176 #[test]
177 fn errors_where_on_chain_math_fails() {
178 assert!(matches!(btc_to_sats(u64::MAX, 1, 2), Err(BtcToSatsError::MathOverflow)));
180 }
181
182 #[test]
183 fn round_trips_with_sats_to_btc_at_zero_fee() {
184 let (deposit, vault_amount, vault_shares) = (11, 100, 100_000);
187 let shares = btc_to_sats(deposit, vault_amount, vault_shares).unwrap();
188 assert_eq!(shares, 11_000);
189 let value = sats_to_btc(shares, vault_amount + deposit, vault_shares + shares, 0).unwrap();
190 assert_eq!(value.gross, deposit);
191 }
192}