Skip to main content

satrush_client/
sats.rs

1/// Virtual share base used by the on-chain vault math (decimal offset of 3):
2/// conversions behave as if `SHARE_OFFSET` extra shares and 1 extra satoshi
3/// always exist, so an empty vault mints 1_000 shares per satoshi.
4pub const SHARE_OFFSET: u64 = 1_000;
5
6/// BTC value of a sats-vault share amount, as computed by [`sats_to_btc`].
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct BtcSharesValue {
9    /// Value at the current exchange rate, before the claim fee.
10    pub gross: u64,
11    /// Fee withheld on claim; the payout is `gross - fee`.
12    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
23/// BTC value of `shares` against a vault holding `vault_amount` BTC with
24/// `vault_shares` shares issued (`SatsVault::btc_amount` /
25/// `SatsVault::btc_shares`). `claim_fee_bps` is
26/// `SatrushConfig::sats_vault_claim_fee_bps`; pass `0` when only the gross
27/// value matters. Zero `shares` value to zero without error; `shares`
28/// exceeding `vault_shares` is rejected.
29pub 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    // shares <= vault_shares bounds gross strictly below vault_amount + 1, and
48    // claim_fee_bps <= BPS_DENOMINATOR bounds fee <= gross: both narrowing
49    // conversions are lossless.
50    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
61/// Shares minted for depositing `btc_amount` BTC into a vault holding
62/// `vault_amount` BTC with `vault_shares` shares issued
63/// (`SatsVault::btc_amount` / `SatsVault::btc_shares`): scaled by the vault's
64/// virtual `(shares + SHARE_OFFSET) / (assets + 1)` ratio and floored, so an
65/// empty vault mints [`SHARE_OFFSET`] shares per satoshi. Errors when the
66/// result exceeds `u64::MAX`.
67pub 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    /// 10% claim fee, matching the program's default
77    /// `SatrushConfig::sats_vault_claim_fee_bps`.
78    const CLAIM_FEE_BPS: u32 = 1_000;
79
80    #[test]
81    fn values_shares_at_the_current_exchange_rate() {
82        // 100 BTC / 100k shares (offset parity): 50k shares -> gross 50, fee 5 (10%).
83        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        // 55 BTC / 50k shares (appreciated): all shares -> gross 54, fee 5,
89        // matching the on-chain redeem payout of 49 (the vault keeps the
90        // remaining 6 as `leftovers`).
91        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        // 1k shares of 10 BTC / 3k shares: gross floor(1000*11/4000) = 2,
97        // fee floor(200/10000) = 0.
98        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        // Even against an empty vault.
110        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        // Any nonzero claim against an empty vault is an overdraw.
130        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        // shares == vault_shares at u64::MAX: gross is the whole vault minus
136        // the sliver absorbed by the virtual base.
137        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        // 55 BTC / 50k shares: 11 BTC -> floor(11 * 51_000 / 56) = 10_017
150        // shares, matching the on-chain deposit.
151        assert_eq!(btc_to_sats(11, 55, 50_000).unwrap(), 10_017);
152        // 10 BTC -> floor(10 * 51_000 / 56) = 9_107 shares.
153        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        // 2x appreciated: 1 satoshi mints ~SHARE_OFFSET/2 shares instead of
159        // flooring to zero like the pre-offset math did.
160        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        // The virtual satoshi keeps the on-chain denominator nonzero, so this
172        // (unreachable post-reset) state values deposits instead of erroring.
173        assert_eq!(btc_to_sats(1, 0, 100).unwrap(), 1_100);
174    }
175
176    #[test]
177    fn errors_where_on_chain_math_fails() {
178        // Result exceeds u64: MAX BTC against a nearly-empty vault.
179        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        // Deposit at offset parity, then value the minted shares against the
185        // post-deposit vault state: exact round trip.
186        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}