Skip to main content

ark/
fees.rs

1use std::cmp::PartialOrd;
2use std::ops;
3
4use bitcoin::{Amount, FeeRate, ScriptBuf, Weight};
5
6use bitcoin_ext::{BlockHeight};
7
8use crate::Vtxo;
9
10/// Complete fee schedule for all operations.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
12pub struct FeeSchedule {
13	pub board: BoardFees,
14	pub offboard: OffboardFees,
15	pub refresh: RefreshFees,
16	pub lightning_receive: LightningReceiveFees,
17	pub lightning_send: LightningSendFees,
18}
19
20impl FeeSchedule {
21	pub fn validate(&self) -> Result<(), FeeScheduleValidationError> {
22		// Validate the order of the fee structs
23		let tables = [
24			("lightning_send", &self.lightning_send.ppm_expiry_table),
25			("offboard", &self.offboard.ppm_expiry_table),
26			("refresh", &self.refresh.ppm_expiry_table),
27		];
28		for (name, ppm_expiry_table) in tables {
29			let mut prev_entry : Option<&PpmExpiryFeeEntry> = None;
30			for current in ppm_expiry_table {
31				if let Some(previous) = prev_entry {
32					// Expiry blocks should be in ascending order.
33					if current.expiry_blocks_threshold < previous.expiry_blocks_threshold {
34						return Err(FeeScheduleValidationError::UnsortedPpmFeeTable {
35							name: name.to_string(),
36							current: current.expiry_blocks_threshold,
37							previous: previous.expiry_blocks_threshold,
38						})
39					}
40					// Ensuring the curve always increases means that we can avoid a whole host of
41					// problems where the tip is different to that of the client. We prefer to
42					// overpay slightly for a fee than to make operations brittle.
43					if current.ppm < previous.ppm {
44						return Err(FeeScheduleValidationError::IncorrectPpmFeeCurve {
45							name: name.to_string(),
46							current: current.ppm.0,
47							previous: previous.ppm.0,
48						});
49					}
50				}
51				prev_entry = Some(current);
52			}
53		}
54		Ok(())
55	}
56}
57
58impl Default for FeeSchedule {
59	/// Returns a fee schedule with zero fees.
60	fn default() -> Self {
61		let table = vec![PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate::ZERO }];
62		Self {
63			board: BoardFees {
64				min_fee: Amount::ZERO,
65				base_fee: Amount::ZERO,
66				ppm: PpmFeeRate::ZERO,
67			},
68			offboard: OffboardFees {
69				base_fee: Amount::ZERO,
70				fixed_additional_vb: 0,
71				ppm_expiry_table: table.clone(),
72			},
73			refresh: RefreshFees {
74				base_fee: Amount::ZERO,
75				ppm_expiry_table: table.clone(),
76			},
77			lightning_receive: LightningReceiveFees {
78				base_fee: Amount::ZERO,
79				ppm: PpmFeeRate::ZERO,
80			},
81			lightning_send: LightningSendFees {
82				min_fee: Amount::ZERO,
83				base_fee: Amount::ZERO,
84				ppm_expiry_table: table.clone(),
85			},
86		}
87	}
88}
89
90/// Error types for fee schedule validation.
91#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)]
92pub enum FeeScheduleValidationError {
93	#[error("{name} ppm expiry table must be sorted by expiry threshold in ascending order of expiry. {previous} is higher than {current}.")]
94	UnsortedPpmFeeTable { name: String, current: u32, previous: u32 },
95
96	#[error("{name} ppm expiry table fee curve must be in ascending order. {previous} is higher than {current}.")]
97	IncorrectPpmFeeCurve { name: String, current: u64, previous: u64 },
98}
99
100/// Fees for boarding the ark.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
102pub struct BoardFees {
103	/// Minimum fee to charge.
104	#[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
105	pub min_fee: Amount,
106	/// A fee applied to every transaction regardless of value.
107	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
108	pub base_fee: Amount,
109	/// PPM (parts per million) fee rate to apply based on the value of the transaction.
110	#[serde(rename = "ppm")]
111	pub ppm: PpmFeeRate,
112}
113
114impl BoardFees {
115	/// Calculate the total fee for a board operation.
116	/// Returns the maximum of the calculated fee (base_fee + ppm) and the minimum fee. `None` if an
117	/// overflow occurs.
118	pub fn calculate(&self, amount: Amount) -> Option<Amount> {
119		let fee = (amount * self.ppm).to_amount_ceil()?.checked_add(self.base_fee)?;
120		Some(fee.max(self.min_fee))
121	}
122
123	/// [BoardFees::calculate] for clients on protocol versions that round the ppm fee down.
124	#[deprecated(note = "only for protocol versions <= 3")]
125	pub fn calculate_legacy(&self, amount: Amount) -> Option<Amount> {
126		let numerator = amount.to_sat().checked_mul(self.ppm.0)?;
127		let fee = Amount::from_sat(numerator / 1_000_000).checked_add(self.base_fee)?;
128		Some(fee.max(self.min_fee))
129	}
130}
131
132/// Fees for offboarding from the ark.
133#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
134pub struct OffboardFees {
135	/// A fee applied to every transaction regardless of value.
136	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
137	pub base_fee: Amount,
138
139	/// Fixed number of virtual bytes charged offboard on top of the output size.
140	///
141	/// The fee for an offboard will be this value, plus the offboard output virtual size,
142	/// multiplied with the offboard fee rate, plus the `base_fee`, and plus the additional fee
143	/// calculated with the `ppm_expiry_table`.
144	pub fixed_additional_vb: u64,
145
146	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
147	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
148	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
149}
150
151impl OffboardFees {
152	/// Returns the fee charged for the user to make an offboard given the fee rate.
153	///
154	/// Returns `None` in the calculation overflows because of insane destinations or fee rates.
155	pub fn calculate(
156		&self,
157		destination: &ScriptBuf,
158		amount: Amount,
159		fee_rate: FeeRate,
160		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
161	) -> Option<Amount> {
162		let weight_fee = self.fixed_additional_vb.checked_add(destination.as_script().len() as u64)
163			.and_then(Weight::from_vb)
164			.and_then(|w| fee_rate.checked_mul_by_weight(w))?;
165		let ppm_fee = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
166		self.base_fee.checked_add(weight_fee)?.checked_add(ppm_fee)
167	}
168
169	/// [OffboardFees::calculate] for clients on protocol versions that calculate ppm fees
170	/// per VTXO.
171	#[deprecated(note = "only for protocol versions <= 3")]
172	#[allow(deprecated)]
173	pub fn calculate_legacy(
174		&self,
175		destination: &ScriptBuf,
176		amount: Amount,
177		fee_rate: FeeRate,
178		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
179	) -> Option<Amount> {
180		let weight_fee = self.fixed_additional_vb.checked_add(destination.as_script().len() as u64)
181			.and_then(Weight::from_vb)
182			.and_then(|w| fee_rate.checked_mul_by_weight(w))?;
183		let ppm_fee = calc_ppm_expiry_fee_legacy(Some(amount), &self.ppm_expiry_table, vtxos)?;
184		self.base_fee.checked_add(weight_fee)?.checked_add(ppm_fee)
185	}
186}
187
188/// Fees for refresh operations.
189#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
190pub struct RefreshFees {
191	/// A fee applied to every transaction regardless of value.
192	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
193	pub base_fee: Amount,
194	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
195	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
196	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
197}
198
199impl RefreshFees {
200	/// Calculate the total fee for a refresh operation.
201	///
202	/// Returns `None` if an overflow occurs.
203	pub fn calculate(
204		&self,
205		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
206	) -> Option<Amount> {
207		self.base_fee.checked_add(self.calculate_no_base_fee(vtxos)?)
208	}
209
210	/// Calculate the fee for a refresh operation, excluding the base fee.
211	///
212	/// Returns `None` if an overflow occurs.
213	pub fn calculate_no_base_fee(
214		&self,
215		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
216	) -> Option<Amount> {
217		calc_ppm_expiry_fee(None, &self.ppm_expiry_table, vtxos)
218	}
219
220	/// [RefreshFees::calculate] for clients on protocol versions that calculate ppm fees
221	/// per VTXO.
222	#[deprecated(note = "only for protocol versions <= 3")]
223	#[allow(deprecated)]
224	pub fn calculate_legacy(
225		&self,
226		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
227	) -> Option<Amount> {
228		let fee = calc_ppm_expiry_fee_legacy(None, &self.ppm_expiry_table, vtxos)?;
229		self.base_fee.checked_add(fee)
230	}
231}
232
233/// Fees for lightning receive operations.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
235pub struct LightningReceiveFees {
236	/// A fee applied to every transaction regardless of value.
237	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
238	pub base_fee: Amount,
239	/// PPM (parts per million) fee rate to apply based on the value of the transaction.
240	pub ppm: PpmFeeRate,
241}
242
243impl LightningReceiveFees {
244	/// Calculate the total fee for a lightning receive operation.
245	///
246	/// Returns `None` if an overflow occurs.
247	pub fn calculate(&self, amount: Amount) -> Option<Amount> {
248		self.base_fee.checked_add((amount * self.ppm).to_amount_ceil()?)
249	}
250
251	/// [LightningReceiveFees::calculate] for clients on protocol versions that round the ppm
252	/// fee down.
253	#[deprecated(note = "only for protocol versions <= 3")]
254	pub fn calculate_legacy(&self, amount: Amount) -> Option<Amount> {
255		let numerator = amount.to_sat().checked_mul(self.ppm.0)?;
256		self.base_fee.checked_add(Amount::from_sat(numerator / 1_000_000))
257	}
258}
259
260/// Fees for lightning send operations.
261#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
262pub struct LightningSendFees {
263	/// Minimum fee to charge.
264	#[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
265	pub min_fee: Amount,
266	/// A fee applied to every transaction regardless of value.
267	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
268	pub base_fee: Amount,
269	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
270	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
271	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
272}
273
274impl LightningSendFees {
275	/// Calculate the total fee for a lightning send operation.
276	///
277	/// Returns `None` if an overflow occurs.
278	pub fn calculate(
279		&self,
280		amount: Amount,
281		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
282	) -> Option<Amount> {
283		let ppm = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
284		Some(self.base_fee.checked_add(ppm)?.max(self.min_fee))
285	}
286
287	/// [LightningSendFees::calculate] for clients on protocol versions that calculate ppm fees
288	/// per VTXO.
289	#[deprecated(note = "only for protocol versions <= 3")]
290	#[allow(deprecated)]
291	pub fn calculate_legacy(
292		&self,
293		amount: Amount,
294		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
295	) -> Option<Amount> {
296		let ppm = calc_ppm_expiry_fee_legacy(Some(amount), &self.ppm_expiry_table, vtxos)?;
297		Some(self.base_fee.checked_add(ppm)?.max(self.min_fee))
298	}
299}
300
301/// A very basic struct to hold information for use in calculating the fees of transactions.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
303pub struct VtxoFeeInfo {
304	/// The total amount of the VTXO.
305	pub amount: Amount,
306	/// Number of blocks until expiry.
307	pub expiry_blocks: u32,
308}
309
310impl VtxoFeeInfo {
311	/// Constructs a [VtxoFeeInfo] instance from the given [Vtxo] and tip [BlockHeight]
312	pub fn from_vtxo_and_tip<G>(vtxo: &Vtxo<G>, tip: BlockHeight) -> Self {
313		Self {
314			amount: vtxo.amount(),
315			expiry_blocks: vtxo.expiry_height().saturating_sub(tip),
316		}
317	}
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
321pub struct PpmFeeRate(pub u64);
322
323impl PpmFeeRate {
324	/// The zero amount.
325	pub const ZERO: PpmFeeRate = PpmFeeRate(0);
326	/// Represents a fee rate of 1%.
327	pub const ONE_PERCENT: PpmFeeRate = PpmFeeRate(10_000);
328
329}
330
331/// A fee with sub-satoshi precision: the undivided numerator of a ppm fee calculation, in
332/// millionths of a satoshi.
333///
334/// Produced by `Amount * PpmFeeRate`. To be usable as money it must be rounded to whole
335/// satoshis with [PpmFee::to_amount_ceil]; keeping the numerator exact until then lets sums
336/// of fees round once on the total instead of once per term.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
338pub struct PpmFee(u128);
339
340impl PpmFee {
341	/// The zero amount.
342	pub const ZERO: PpmFee = PpmFee(0);
343
344	/// The fee in satoshis, rounded up to the next whole satoshi.
345	/// Returns `None` if the result exceeds `u64::MAX`.
346	pub fn to_sat_ceil(self) -> Option<u64> {
347		u64::try_from(self.0.div_ceil(1_000_000)).ok()
348	}
349
350	/// The fee rounded up to the next whole satoshi.
351	/// Returns `None` if the result exceeds `u64::MAX` satoshis.
352	pub fn to_amount_ceil(self) -> Option<Amount> {
353		Some(Amount::from_sat(self.to_sat_ceil()?))
354	}
355
356	/// Returns `None` if the sum overflows.
357	pub fn checked_add(self, other: PpmFee) -> Option<PpmFee> {
358		Some(PpmFee(self.0.checked_add(other.0)?))
359	}
360}
361
362impl ops::Mul<PpmFeeRate> for Amount {
363	type Output = PpmFee;
364
365	/// Calculates a fee for the current amount using a parts-per-million (PPM) rate.
366	///
367	/// # Example
368	///
369	/// ```rust
370	/// use ark::fees::PpmFeeRate;
371	/// use bitcoin::Amount;
372	///
373	/// let fee_chargeable_amount = Amount::from_sat(10_000);
374	/// let ppm = PpmFeeRate(5_000); // 0.5%
375	/// let fee = (fee_chargeable_amount * ppm).to_amount_ceil().unwrap();
376	/// assert_eq!(fee, Amount::from_sat(50)); // 10,000 * 5,000 / 1,000,000 = 50
377	/// ```
378	fn mul(self, ppm: PpmFeeRate) -> Self::Output {
379		PpmFee((self.to_sat() as u128).checked_mul(ppm.0 as u128)
380			.expect("widening u64 * u64 to u128 is exact and cannot overflow"))
381	}
382}
383
384/// Entry in a table to calculate the PPM (parts per million) fee rate of a transaction based on how
385/// new a VTXO is.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
387pub struct PpmExpiryFeeEntry {
388	/// A threshold for the number of blocks until a VTXO expires for the `ppm` amount to apply.
389	/// As an example, if this value is set to 50 and a VTXO expires in 60 blocks, this
390	/// [PpmExpiryFeeEntry] will be used to calculate the fee unless another entry exists with an
391	/// `expiry_blocks_threshold` with a value between 51 and 60 (inclusive).
392	pub expiry_blocks_threshold: u32,
393	/// PPM (parts per million) fee rate to apply for this expiry period.
394	pub ppm: PpmFeeRate,
395}
396
397/// Error types for fee validation.
398#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)]
399pub enum FeeValidationError {
400	#[error("Fee ({fee}) exceeds amount ({amount})")]
401	FeeExceedsAmount { amount: Amount, fee: Amount },
402
403	#[error("Amount after fee ({amount_after_fee}) is below dust limit ({dust}). Amount: {amount}, Fee: {fee}")]
404	AmountAfterFeeBelowDust {
405		amount: Amount,
406		fee: Amount,
407		dust: Amount,
408		amount_after_fee: Amount,
409	},
410}
411
412/// Validates fee amounts and calculates the resulting amount after fee.
413///
414/// This function ensures two critical conditions are met:
415/// 1. Fee doesn't exceed the original amount (prevents overflow)
416/// 2. Amount after fee is > zero
417///
418/// # Returns
419/// * `Ok(Amount)` - The amount after subtracting the fee
420/// * `Err(FeeValidationError)` - If any validation condition fails
421///
422/// # Example
423/// ```
424/// use ark::fees::{validate_and_subtract_fee, FeeValidationError};
425/// use bitcoin::Amount;
426///
427/// let amount = Amount::from_sat(10_000);
428/// let fee = Amount::from_sat(100);
429/// let result = validate_and_subtract_fee(amount, fee);
430/// assert_eq!(result.unwrap(), Amount::from_sat(9_900));
431///
432/// let amount = Amount::from_sat(10_000);
433/// let fee = Amount::from_sat(10_000);
434/// let result = validate_and_subtract_fee(amount, fee);
435/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee });
436///
437/// let amount = Amount::from_sat(10_000);
438/// let fee = Amount::from_sat(11_000);
439/// let result = validate_and_subtract_fee(amount, fee);
440/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee });
441/// ```
442pub fn validate_and_subtract_fee(
443	amount: Amount,
444	fee: Amount,
445) -> Result<Amount, FeeValidationError> {
446	let amount_after_fee = amount.checked_sub(fee)
447		.ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
448
449	if amount_after_fee == Amount::ZERO {
450		Err(FeeValidationError::FeeExceedsAmount { amount, fee })
451	} else {
452		Ok(amount_after_fee)
453	}
454}
455
456/// Validates fee amounts and calculates the resulting amount after fee.
457///
458/// This function ensures two critical conditions are met:
459/// 1. Fee doesn't exceed the original amount (prevents overflow)
460/// 2. Amount after fee is >= dust (ensures economically viable output)
461///
462/// # Returns
463/// * `Ok(Amount)` - The amount after subtracting the fee
464/// * `Err(FeeValidationError)` - If any validation condition fails
465///
466/// # Example
467/// ```
468/// use ark::fees::{validate_and_subtract_fee_min_dust, FeeValidationError};
469/// use ark::vtxo::VTXO_DUST;
470/// use bitcoin::Amount;
471///
472/// let dust = VTXO_DUST;
473/// let amount = Amount::from_sat(10_000);
474/// let fee = Amount::from_sat(100);
475/// let result = validate_and_subtract_fee_min_dust(amount, fee, dust);
476/// assert_eq!(result.unwrap(), Amount::from_sat(9_900));
477///
478/// let amount = Amount::from_sat(10_000);
479/// let fee = Amount::from_sat(9_670);
480/// let result = validate_and_subtract_fee_min_dust(amount, fee, dust);
481/// assert_eq!(result.unwrap(), dust);
482///
483/// let amount = Amount::from_sat(10_000);
484/// let fee = Amount::from_sat(11_000);
485/// let result = validate_and_subtract_fee_min_dust(amount, fee, dust);
486/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee, });
487///
488/// let amount = Amount::from_sat(10_000);
489/// let fee = Amount::from_sat(10_000);
490/// let result = validate_and_subtract_fee_min_dust(amount, fee, dust);
491/// assert_eq!(result.unwrap_err(), FeeValidationError::AmountAfterFeeBelowDust {
492/// 	amount,
493/// 	fee,
494/// 	dust,
495/// 	amount_after_fee: amount - fee,
496/// });
497/// ```
498pub fn validate_and_subtract_fee_min_dust(
499	amount: Amount,
500	fee: Amount,
501	dust: Amount,
502) -> Result<Amount, FeeValidationError> {
503	let amount_after_fee = amount.checked_sub(fee)
504		.ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
505
506	// amount - fee must be >= dust
507	if amount_after_fee < dust {
508		return Err(FeeValidationError::AmountAfterFeeBelowDust {
509			amount,
510			fee,
511			dust,
512			amount_after_fee,
513		});
514	}
515
516	Ok(amount_after_fee)
517}
518
519/// Calculates the total fee based on the provided fee-chargeable amount, a table of PPM
520/// (Parts Per Million) expiry-based fee rates, and an iterable list of VTXO information.
521///
522/// # Parameters
523///
524/// * `fee_chargeable_amount` - An optional total amount from which the fee is chargeable. If
525///   specified, this amount determines the maximum amount to be used for fee calculations across
526///   all VTXOs. The value decreases as portions of it are consumed for each VTXOs fee calculation.
527///   If `None`, each VTXOs full amount is considered chargeable.
528///
529/// * `ppm_expiry_table` - Each entry contains an expiry threshold and a corresponding PPM fee. This
530///   table is assumed to be sorted in ascending order of `expiry_blocks_threshold` for correct
531///   behavior.
532///
533/// * `vtxos` - An iterable input of `VtxoFeeInfo`, where each element contains the amount and
534///   the number of blocks until the VTXO expires, which is relevant for fee calculation.
535///
536/// # Returns
537///
538/// Returns an `Amount` representing the total calculated fee based on the provided inputs,
539/// rounded up to the next satoshi. `None` if an overflow occurs.
540///
541/// # Example Usage
542///
543/// ```rust
544/// use ark::fees::{PpmExpiryFeeEntry, PpmFeeRate, VtxoFeeInfo, calc_ppm_expiry_fee};
545/// use bitcoin::Amount;
546///
547/// let fee_chargeable_amount = Some(Amount::from_sat(15_000));
548/// let ppm_expiry_table = vec![
549///     PpmExpiryFeeEntry { expiry_blocks_threshold: 10, ppm: PpmFeeRate::ONE_PERCENT },
550///     PpmExpiryFeeEntry { expiry_blocks_threshold: 20, ppm: PpmFeeRate(50_000) }, // 5%
551/// ];
552/// let vtxos = vec![
553///     VtxoFeeInfo { amount: Amount::from_sat(5_000), expiry_blocks: 2 },
554///     VtxoFeeInfo { amount: Amount::from_sat(3_000), expiry_blocks: 12 },
555///     VtxoFeeInfo { amount: Amount::from_sat(7_000), expiry_blocks: 22 },
556/// ];
557///
558/// let total_fee = calc_ppm_expiry_fee(fee_chargeable_amount, &ppm_expiry_table, vtxos);
559/// assert_eq!(total_fee, Some(Amount::from_sat(380))); // 5,000 * 0% + 3,000 * 1% + 7,000 * 5% = 380
560/// ```
561pub fn calc_ppm_expiry_fee(
562	fee_chargeable_amount: Option<Amount>,
563	ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
564	vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
565) -> Option<Amount> {
566	// We use the PpmFee type to accumulate sub-satoshi fee values which we can later round up
567	// to the nearest satoshi.
568	let mut total_fee = PpmFee::ZERO;
569	let mut remaining = fee_chargeable_amount;
570	for v in vtxos {
571		// If a fee_chargeable_amount was provided, we should only account for that amount, else we
572		// should assume every VTXO will be fully spent.
573		let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
574			let amount = v.amount.min(*remaining);
575			*remaining -= amount;
576			amount
577		} else {
578			v.amount
579		};
580
581		// We assume the table is sorted by expiry_blocks_threshold in ascending order
582		let entry = ppm_expiry_table
583			.iter()
584			.rev()
585			.find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
586
587		// If we can't find an entry that is suitable, we assume no fee is necessary
588		if let Some(entry) = entry {
589			total_fee = total_fee.checked_add(fee_chargeable_amount * entry.ppm)?;
590		}
591	}
592	total_fee.to_amount_ceil()
593}
594
595/// [calc_ppm_expiry_fee] as calculated by clients on protocol versions that apply the fee rate
596/// to each VTXO separately rather than to the total.
597#[deprecated(note = "only for protocol versions <= 3")]
598pub fn calc_ppm_expiry_fee_legacy(
599	fee_chargeable_amount: Option<Amount>,
600	ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
601	vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
602) -> Option<Amount> {
603	let mut total_fee = Amount::ZERO;
604	let mut remaining = fee_chargeable_amount;
605	for v in vtxos {
606		// If we were given a total amount, we should only account for that amount, else we should
607		// assume every VTXO will be fully spent.
608		let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
609			let amount = v.amount.min(*remaining);
610			*remaining -= amount;
611			amount
612		} else {
613			v.amount
614		};
615
616		// We assume the table is sorted by expiry_blocks_threshold in ascending order
617		let entry = ppm_expiry_table
618			.iter()
619			.rev()
620			.find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
621
622		// If we can't find an entry that is suitable, we assume no fee is necessary
623		if let Some(entry) = entry {
624			let numerator = fee_chargeable_amount.to_sat().checked_mul(entry.ppm.0)?;
625			total_fee = total_fee.checked_add(Amount::from_sat(numerator / 1_000_000))?;
626		}
627	}
628	Some(total_fee)
629}
630
631#[cfg(test)]
632mod tests {
633	use super::*;
634
635	#[test]
636	fn test_board_fees() {
637		let mut fees = BoardFees {
638			min_fee: Amount::ZERO,
639			base_fee: Amount::from_sat(100),
640			ppm: PpmFeeRate(1_000), // 0.1%
641		};
642
643		// Test with 10,000 sats
644		let amount = Amount::from_sat(10_000);
645		let fee = fees.calculate(amount).unwrap();
646		// base (100) + (10,000 * 1,000) / 1,000,000 = 100 + 10 = 110
647		assert_eq!(fee, Amount::from_sat(110));
648
649		// Test with 10,000 sats and min fee
650		fees.min_fee = Amount::from_sat(330);
651		let amount = Amount::from_sat(10_000);
652		let fee = fees.calculate(amount).unwrap();
653		// base (100) + (10,000 * 1,000) / 1,000,000 = 100 + 10 = MAX(110, 330) = 330
654		assert_eq!(fee, Amount::from_sat(330));
655
656		// Fractional fees round up, the legacy calculation rounds down.
657		fees.min_fee = Amount::ZERO;
658		let amount = Amount::from_sat(10_500);
659		// base (100) + ceil(10.5) = 111
660		assert_eq!(fees.calculate(amount), Some(Amount::from_sat(111)));
661		#[allow(deprecated)]
662		let fee = fees.calculate_legacy(amount);
663		// base (100) + floor(10.5) = 110
664		assert_eq!(fee, Some(Amount::from_sat(110)));
665	}
666
667	#[test]
668	fn test_offboard_fees_with_single_vtxo() {
669		let fees = OffboardFees {
670			base_fee: Amount::from_sat(200),
671			fixed_additional_vb: 100,
672			ppm_expiry_table: vec![
673				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
674				PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
675				PpmExpiryFeeEntry { expiry_blocks_threshold: 1_000, ppm: PpmFeeRate(3_000) },
676			],
677		};
678
679		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
680		let destination = ScriptBuf::from_hex(script_str)
681			.expect("Failed to parse OP_RETURN script hex string");
682		let fee_rate = FeeRate::from_sat_per_vb_u32(10);
683		let amount = Amount::from_sat(100_000);
684
685		// Test with expiry < 100 blocks (should use 0 ppm)
686		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 50 };
687		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
688		// base (200) + ((100,000 * 0) / 1,000,000) + ((6 + 100) * 10) = 200 + 0 + 1,060 = 1,260
689		assert_eq!(fee, Amount::from_sat(1_260));
690
691		// Test with expiry = 150 blocks (should use 1,000 ppm)
692		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
693		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
694		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 100 + 1,060 = 1,360
695		assert_eq!(fee, Amount::from_sat(1_360));
696
697		// Test with expiry = 750 blocks (should use 2,000 ppm)
698		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 750 };
699		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
700		// base (200) + ((100,000 * 2,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 200 + 1,060 = 1,460
701		assert_eq!(fee, Amount::from_sat(1_460));
702
703		// Test with expiry = 2,000 blocks (should use 3,000 ppm)
704		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 2_000 };
705		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
706		// base (200) + ((100,000 * 3,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 300 + 1,060 = 1,560
707		assert_eq!(fee, Amount::from_sat(1_560));
708	}
709
710	#[test]
711	fn test_offboard_fees_with_multiple_vtxos() {
712		let fees = OffboardFees {
713			base_fee: Amount::from_sat(200),
714			fixed_additional_vb: 100,
715			ppm_expiry_table: vec![
716				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
717				PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
718			],
719		};
720
721		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
722		let destination = ScriptBuf::from_hex(script_str)
723			.expect("Failed to parse OP_RETURN script hex string");
724		let fee_rate = FeeRate::from_sat_per_vb_u32(10);
725		// Test with multiple VTXOs where total VTXO value exceeds amount being sent
726		// VTXOs total 120,000 but we're only sending 100,000
727		let vtxos = vec![
728			VtxoFeeInfo { amount: Amount::from_sat(30_000), expiry_blocks: 50 },  // 0 ppm (< 100)
729			VtxoFeeInfo { amount: Amount::from_sat(50_000), expiry_blocks: 150 }, // 1,000 ppm
730			VtxoFeeInfo { amount: Amount::from_sat(40_000), expiry_blocks: 600 }, // 2,000 ppm
731		];
732
733		let amount_to_send = Amount::from_sat(100_000);
734		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
735		// We consume VTXOs in order until we have enough:
736		// - First VTXO: 30,000 at 0 ppm -> fee = 30,000 * 0 / 1,000,000 = 0
737		// - Second VTXO: 50,000 at 1,000 ppm -> fee = 50,000 * 1,000 / 1,000,000 = 50
738		// - Third VTXO: Only need 20,000 at 2,000 ppm -> fee = 20,000 * 2,000 / 1,000,000 = 40
739		// Total: base (200) + (0 + 50 + 40) + ((6 + 100) * 10) = 200 + 90 + 1,060 = 1,350
740		assert_eq!(fee, Amount::from_sat(1_350));
741	}
742
743	#[test]
744	fn test_offboard_fees_with_no_fee_rate() {
745		let fees = OffboardFees {
746			base_fee: Amount::from_sat(200),
747			fixed_additional_vb: 100,
748			ppm_expiry_table: vec![
749				PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
750			],
751		};
752
753		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
754		let destination = ScriptBuf::from_hex(script_str)
755			.expect("Failed to parse OP_RETURN script hex string");
756		let fee_rate = FeeRate::from_sat_per_vb_u32(0);
757		let vtxos = vec![
758			VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 },  // 1,000 ppm (> 1)
759		];
760
761		let amount_to_send = Amount::from_sat(100_000);
762		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
763		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 100) * 0) = 200 + 100 + 0 = 300
764		assert_eq!(fee, Amount::from_sat(300));
765	}
766
767	#[test]
768	fn test_offboard_fees_with_no_additional_vb() {
769		let fees = OffboardFees {
770			base_fee: Amount::from_sat(200),
771			fixed_additional_vb: 0,
772			ppm_expiry_table: vec![
773				PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
774			],
775		};
776
777		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
778		let destination = ScriptBuf::from_hex(script_str)
779			.expect("Failed to parse OP_RETURN script hex string");
780		let fee_rate = FeeRate::from_sat_per_vb_u32(10);
781		let vtxos = vec![
782			VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 },  // 1,000 ppm (> 1)
783		];
784
785		let amount_to_send = Amount::from_sat(100_000);
786		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
787		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 0) * 10) = 200 + 100 + 60 = 360
788		assert_eq!(fee, Amount::from_sat(360));
789	}
790
791	#[test]
792	fn test_refresh_fees_with_single_vtxo() {
793		let fees = RefreshFees {
794			base_fee: Amount::from_sat(150),
795			ppm_expiry_table: vec![
796				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
797				PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
798			],
799		};
800
801		let amount = Amount::from_sat(200_000);
802
803		// Test with expiry = 400 blocks (should use 500 ppm)
804		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 400 };
805		let fee = fees.calculate(vec![vtxo]).unwrap();
806		// base (150) + (200,000 * 500) / 1,000,000 = 150 + 100 = 250
807		assert_eq!(fee, Amount::from_sat(250));
808
809		// Test with expiry = 800 blocks (should use 1,500 ppm)
810		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 800 };
811		let fee = fees.calculate(vec![vtxo]).unwrap();
812		// base (150) + (200,000 * 1,500) / 1,000,000 = 150 + 300 = 450
813		assert_eq!(fee, Amount::from_sat(450));
814	}
815
816	#[test]
817	fn test_refresh_fees_with_multiple_vtxos() {
818		let fees = RefreshFees {
819			base_fee: Amount::from_sat(50),
820			ppm_expiry_table: vec![
821				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
822				PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
823			],
824		};
825
826		// Test with multiple VTXOs
827		let vtxos = vec![
828			VtxoFeeInfo { amount: Amount::from_sat(70_000), expiry_blocks: 100 },  // 0 ppm (< 200)
829			VtxoFeeInfo { amount: Amount::from_sat(100_000), expiry_blocks: 300 }, // 500 ppm
830			VtxoFeeInfo { amount: Amount::from_sat(80_000), expiry_blocks: 700 },  // 1,500 ppm
831		];
832
833		let fee = fees.calculate(vtxos).unwrap();
834		// We consume VTXOs in order until we have enough:
835		// - First VTXO: 70,000 at 0 ppm -> fee = 70,000 * 0 / 1,000,000 = 0
836		// - Second VTXO: 100,000 at 500 ppm -> fee = 100,000 * 500 / 1,000,000 = 50
837		// - Third VTXO: 80,000 at 1,500 ppm -> fee = 80,000 * 1,500 / 1,000,000 = 120
838		// Total: base (50) + 0 + 50 + 120 = 220
839		assert_eq!(fee, Amount::from_sat(220));
840	}
841
842	#[test]
843	fn test_lightning_receive_fees() {
844		let fees = LightningReceiveFees {
845			base_fee: Amount::from_sat(100),
846			ppm: PpmFeeRate(2_000), // 0.2%
847		};
848
849		let amount = Amount::from_sat(10_000);
850		let fee = fees.calculate(amount).unwrap();
851		// base (100) + (10,000 * 2,000) / 1,000,000 = 100 + 20 = 120
852		assert_eq!(fee, Amount::from_sat(120));
853
854		// Fractional fees round up, the legacy calculation rounds down.
855		let amount = Amount::from_sat(10_400);
856		// base (100) + ceil(20.8) = 121
857		assert_eq!(fees.calculate(amount), Some(Amount::from_sat(121)));
858		#[allow(deprecated)]
859		let fee = fees.calculate_legacy(amount);
860		// base (100) + floor(20.8) = 120
861		assert_eq!(fee, Some(Amount::from_sat(120)));
862	}
863
864	#[test]
865	fn test_lightning_send_fees_with_single_vtxo() {
866		let mut fees = LightningSendFees {
867			min_fee: Amount::from_sat(10),
868			base_fee: Amount::from_sat(75),
869			ppm_expiry_table: vec![
870				PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
871				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
872			],
873		};
874
875		let amount = Amount::from_sat(1_000_000);
876
877		// Test with expiry = 75 blocks (should use 250 ppm)
878		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 75 };
879		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
880		// base (75) + (1,000,000 * 250) / 1,000,000 = 75 + 250 = 325
881		assert_eq!(fee, Amount::from_sat(325));
882
883		// Test with expiry = 150 blocks (should use 750 ppm)
884		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
885		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
886		// base (75) + (1,000,000 * 750) / 1,000,000 = 75 + 750 = 825
887		assert_eq!(fee, Amount::from_sat(825));
888
889		// Test with 1,000 sats and min fee
890		fees.min_fee = Amount::from_sat(330);
891		let vtxo = VtxoFeeInfo { amount: Amount::from_sat(1_000), expiry_blocks: 150 };
892		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
893		// base (75) + ceil((1,000 * 750) / 1,000,000) = 75 + 1 = MAX(76, 330) = 330
894		assert_eq!(fee, Amount::from_sat(330));
895	}
896
897	#[test]
898	fn test_lightning_send_fees_with_multiple_vtxos() {
899		let fees = LightningSendFees {
900			min_fee: Amount::from_sat(10),
901			base_fee: Amount::from_sat(25),
902			ppm_expiry_table: vec![
903				PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
904				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
905				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(1_500) },
906			],
907		};
908
909		// Test with multiple VTXOs where the total VTXO value exceeds the amount being paid.
910		// The VTXOs total 1,500,000 sats but we're only sending 1,000,000.
911		let vtxos = vec![
912			VtxoFeeInfo { amount: Amount::from_sat(400_000), expiry_blocks: 75 },  // 250 ppm
913			VtxoFeeInfo { amount: Amount::from_sat(500_000), expiry_blocks: 150 }, // 750 ppm
914			VtxoFeeInfo { amount: Amount::from_sat(600_000), expiry_blocks: 250 }, // 1,500 ppm
915		];
916
917		let amount_to_send = Amount::from_sat(1_000_000);
918		let fee = fees.calculate(amount_to_send, vtxos).unwrap();
919		// We consume VTXOs in order until we have enough:
920		// - First VTXO: 400,000 at 250 ppm -> fee = 400,000 * 250 / 1,000,000 = 100
921		// - Second VTXO: 500,000 at 750 ppm -> fee = 500,000 * 750 / 1,000,000 = 375
922		// - Third VTXO: only need 100,000 at 1,500 ppm -> fee = 100,000 * 1,500 / 1,000,000 = 150
923		// Total: base (25) + 100 + 375 + 150 = 650
924		assert_eq!(fee, Amount::from_sat(650));
925	}
926
927	#[test]
928	#[allow(deprecated)]
929	fn test_ppm_expiry_fee_totals() {
930		let table = vec![
931			PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(2_000) },
932			PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(4_000) },
933		];
934
935		// Small amounts truncate to zero per VTXO but not on the total.
936		let vtxos = vec![VtxoFeeInfo { amount: Amount::from_sat(330), expiry_blocks: 1_500 }; 100];
937		let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
938		// floor(330 * 2,000 / 1,000,000) = 0 per VTXO
939		assert_eq!(fee, Some(Amount::ZERO));
940		let fee = calc_ppm_expiry_fee(None, &table, vtxos);
941		// 100 * 330 = 33,000; 33,000 * 2,000 / 1,000,000 = 66
942		assert_eq!(fee, Some(Amount::from_sat(66)));
943
944		// The fee is rounded once on the total across entries.
945		let vtxos = vec![
946			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_100 },
947			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_200 },
948			VtxoFeeInfo { amount: Amount::from_sat(1_300), expiry_blocks: 2_500 },
949		];
950		let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
951		// floor(1.8) + floor(1.8) + floor(5.2) = 1 + 1 + 5 = 7
952		assert_eq!(fee, Some(Amount::from_sat(7)));
953		let fee = calc_ppm_expiry_fee(None, &table, vtxos);
954		// ceil((1,800 * 2,000 + 1,300 * 4,000) / 1,000,000) = ceil(8.8) = 9
955		assert_eq!(fee, Some(Amount::from_sat(9)));
956
957		// A capped chargeable amount is allocated to VTXOs in order in both variants.
958		let vtxos = vec![
959			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_100 },
960			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_200 },
961			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 2_500 },
962		];
963		let cap = Some(Amount::from_sat(1_500));
964		let fee = calc_ppm_expiry_fee_legacy(cap, &table, vtxos.clone());
965		// Chargeable 900 + 600 + 0: floor(1.8) + floor(1.2) = 2
966		assert_eq!(fee, Some(Amount::from_sat(2)));
967		let fee = calc_ppm_expiry_fee(cap, &table, vtxos);
968		// Chargeable 900 + 600 + 0: ceil(1,500 * 2,000 / 1,000,000) = 3
969		assert_eq!(fee, Some(Amount::from_sat(3)));
970
971		// VTXOs below every threshold are free in both variants.
972		let vtxos = vec![VtxoFeeInfo { amount: Amount::from_sat(100_000), expiry_blocks: 500 }; 10];
973		let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
974		assert_eq!(fee, Some(Amount::ZERO));
975		let fee = calc_ppm_expiry_fee(None, &table, vtxos);
976		assert_eq!(fee, Some(Amount::ZERO));
977	}
978
979	#[test]
980	fn test_ppm_expiry_fee_lagging_tip_pays_at_least_ours() {
981		let table = vec![
982			PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate::ZERO },
983			PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(2_000) },
984			PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(4_000) },
985		];
986
987		// A party one block behind charges threshold-straddling VTXOs at the next entry.
988		// The single rounding on the total keeps its fee monotone in the entry rates, so
989		// it always covers our own calculation.
990		let ours = vec![
991			VtxoFeeInfo { amount: Amount::from_sat(100), expiry_blocks: 2_015 },
992			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_500 },
993		];
994		let theirs = vec![
995			VtxoFeeInfo { amount: Amount::from_sat(100), expiry_blocks: 2_016 },
996			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_501 },
997		];
998		// ceil((100 * 2,000 + 900 * 2,000) / 1,000,000) = ceil(2.0) = 2
999		let ours = calc_ppm_expiry_fee(None, &table, ours).unwrap();
1000		assert_eq!(ours, Amount::from_sat(2));
1001		// ceil((100 * 4,000 + 900 * 2,000) / 1,000,000) = ceil(2.2) = 3
1002		let theirs = calc_ppm_expiry_fee(None, &table, theirs).unwrap();
1003		assert_eq!(theirs, Amount::from_sat(3));
1004		assert!(theirs >= ours);
1005	}
1006}