Skip to main content

satrush_client/
sats.rs

1/// BTC value of a sats-vault share amount, as computed by [`sats_to_btc`].
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub struct BtcSharesValue {
4    /// Value at the current exchange rate, before the claim fee.
5    pub gross: u64,
6    /// Fee withheld on claim; the payout is `gross - fee`.
7    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
18/// BTC value of `shares` against a vault holding `vault_amount` BTC with
19/// `vault_shares` shares issued (`SatsVault::btc_amount` /
20/// `SatsVault::btc_shares`). `claim_fee_bps` is
21/// `SatrushConfig::sats_vault_claim_fee_bps`; pass `0` when only the gross
22/// value matters. Zero `shares` value to zero without error; `shares`
23/// exceeding `vault_shares` is rejected.
24pub 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    // shares <= vault_shares and claim_fee_bps <= BPS_DENOMINATOR, so
43    // gross <= vault_amount and fee <= gross: both narrowing conversions are
44    // lossless.
45    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
56/// Shares minted for depositing `btc_amount` BTC into a vault holding
57/// `vault_amount` BTC with `vault_shares` shares issued
58/// (`SatsVault::btc_amount` / `SatsVault::btc_shares`): 1:1 while no shares
59/// exist, otherwise scaled by the vault's `shares / assets` ratio and
60/// floored. Errors when the vault has shares but no BTC, or the result
61/// exceeds `u64::MAX`.
62pub 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    /// 10% claim fee, matching the program's default
78    /// `SatrushConfig::sats_vault_claim_fee_bps`.
79    const CLAIM_FEE_BPS: u32 = 1_000;
80
81    #[test]
82    fn values_shares_at_the_current_exchange_rate() {
83        // 100 BTC / 100 shares: 50 shares are worth 50 gross, 5 fee (10%).
84        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        // 55 BTC / 50 shares (rate 1.1): all 50 shares -> gross 55, fee 5,
90        // matching the on-chain redeem payout of 50.
91        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        // 10 BTC / 3 shares: 1 share -> gross floor(10/3) = 3, fee floor(300/10000) = 0.
97        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        // Even against an empty vault.
109        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        // Any nonzero claim against an empty vault is an overdraw.
126        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        // shares == vault_shares at u64::MAX: gross is the whole vault.
132        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        // Residual BTC left by a full drain doesn't change the 1:1 reseed rate.
141        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        // 55 BTC / 50 shares (rate 1.1): 11 BTC -> 11 * 50 / 55 = 10 shares,
147        // matching the on-chain deposit.
148        assert_eq!(btc_to_sats(11, 55, 50).unwrap(), 10);
149        // 10 BTC -> floor(10 * 50 / 55) = 9 shares.
150        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        // Shares issued against no BTC: division by zero on-chain.
162        assert!(matches!(btc_to_sats(1, 0, 100), Err(BtcToSatsError::MathOverflow)));
163        // Result exceeds u64: MAX BTC at a 2-shares-per-BTC rate.
164        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        // Deposit into an appreciated vault, then value the minted shares
170        // against the post-deposit vault state.
171        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}