fedimint-client-module 0.13.0-beta.0

Library for sending transactions to the Fedimint federation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
use core::fmt;
use std::fmt::Write as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use assert_matches::assert_matches;
use bitcoin::key::Secp256k1;
use fedimint_core::Amount;
use fedimint_core::core::{Input, IntoDynInstance, ModuleKind, Output};
use fedimint_core::encoding::{Decodable, Encodable};
use fedimint_core::module::{AmountUnit, Amounts};

use super::{
    ClientInputBundle, ClientOutput, ClientOutputBundle, ClientOutputSM, FeeQuote,
    TransactionBuilder, max_affordable_send_amount,
};
use crate::error::{InsufficientBalanceError, TransactionSubmitError};
use crate::module::OutPointRange;
use crate::transaction::{ClientInput, ClientInputSM};

#[derive(Encodable, Decodable, Clone, Debug, Hash, PartialEq, Eq)]
pub struct NoopInput;

impl fmt::Display for NoopInput {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("NoopInput")
    }
}

impl Input for NoopInput {
    const KIND: ModuleKind = ModuleKind::from_static_str("test");
}

impl IntoDynInstance for NoopInput {
    type DynType = fedimint_core::core::DynInput;

    fn into_dyn(self, instance_id: fedimint_core::core::ModuleInstanceId) -> Self::DynType {
        fedimint_core::core::DynInput::from_typed(instance_id, self)
    }
}

#[derive(Encodable, Decodable, Clone, Debug, Hash, PartialEq, Eq)]
pub struct NoopOutput;

impl fmt::Display for NoopOutput {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("NoopOutput")
    }
}

impl Output for NoopOutput {
    const KIND: ModuleKind = ModuleKind::from_static_str("test");
}

impl IntoDynInstance for NoopOutput {
    type DynType = fedimint_core::core::DynOutput;

    fn into_dyn(self, instance_id: fedimint_core::core::ModuleInstanceId) -> Self::DynType {
        fedimint_core::core::DynOutput::from_typed(instance_id, self)
    }
}

#[test]
/// Exercise empty bundles
///
/// Actually exercise empty bundles (since it's rare in real code)
/// and ensure that their SM-gens are *not* called, while the SM-gens
/// of non-empty bundles work as expected.
fn tx_builder_empty_bundles() {
    // We'll collect ranges sms were called into this thing to compare at the end
    let sm_called = Arc::new(Mutex::new(String::new()));

    let no_call_input_sm = ClientInputSM {
        state_machines: Arc::new(move |_out_point_range: OutPointRange| {
            panic!("Don't call me maybe");
        }),
    };

    let no_call_output_sm = ClientOutputSM {
        state_machines: Arc::new(move |_out_point_range: OutPointRange| {
            panic!("Don't call me maybe");
        }),
    };
    let yes_call_input_sm = ClientInputSM {
        state_machines: Arc::new({
            let sm_called = sm_called.clone();
            move |out_point_range: OutPointRange| {
                sm_called
                    .lock()
                    .unwrap()
                    .write_fmt(format_args!("i-{:?},", out_point_range.start_idx()))
                    .expect("Can't fail");
                vec![]
            }
        }),
    };

    let yes_call_output_sm = ClientOutputSM {
        state_machines: Arc::new({
            let sm_called = sm_called.clone();
            move |out_point_range: OutPointRange| {
                sm_called
                    .clone()
                    .lock()
                    .unwrap()
                    .write_fmt(format_args!("o-{:?},", out_point_range.start_idx()))
                    .expect("Can't fail");
                vec![]
            }
        }),
    };

    // This is ugly due to repetition, but the more manual, the better, and in real
    // code some of these conversions are hidden in the generics. Oh well.
    TransactionBuilder::new()
        .with_inputs(
            ClientInputBundle::<NoopInput>::new(vec![], vec![no_call_input_sm.clone()])
                .into_instanceless()
                .into_dyn(0),
        )
        .with_inputs(
            ClientInputBundle::<NoopInput>::new(
                vec![ClientInput {
                    input: NoopInput,
                    keys: vec![],
                    amounts: Amounts::new_bitcoin_msats(1),
                }],
                vec![yes_call_input_sm.clone()],
            )
            .into_instanceless()
            .into_dyn(3),
        )
        .with_inputs(
            ClientInputBundle::<NoopInput>::new(vec![], vec![no_call_input_sm.clone()])
                .into_instanceless()
                .into_dyn(0),
        )
        .with_inputs(
            ClientInputBundle::<NoopInput>::new(
                vec![ClientInput {
                    input: NoopInput,
                    keys: vec![],
                    amounts: Amounts::new_bitcoin_msats(1),
                }],
                vec![yes_call_input_sm],
            )
            .into_instanceless()
            .into_dyn(3),
        )
        .with_outputs(
            ClientOutputBundle::<NoopOutput>::new(vec![], vec![no_call_output_sm.clone()])
                .into_instanceless()
                .into_dyn(0),
        )
        .with_outputs(
            ClientOutputBundle::<NoopOutput>::new(
                vec![ClientOutput {
                    output: NoopOutput,
                    amounts: Amounts::new_bitcoin_msats(1),
                }],
                vec![yes_call_output_sm.clone()],
            )
            .into_instanceless()
            .into_dyn(3),
        )
        .with_outputs(
            ClientOutputBundle::<NoopOutput>::new(vec![], vec![no_call_output_sm.clone()])
                .into_instanceless()
                .into_dyn(0),
        )
        .with_outputs(
            ClientOutputBundle::<NoopOutput>::new(
                vec![ClientOutput {
                    output: NoopOutput,
                    amounts: Amounts::new_bitcoin_msats(1),
                }],
                vec![yes_call_output_sm],
            )
            .into_instanceless()
            .into_dyn(3),
        )
        .build(&Secp256k1::new(), rand::thread_rng());

    // This actually depends on how builder processes inputs and outputs,
    // but if it ever changes, just adjust the string.
    assert_eq!(*sm_called.lock().unwrap(), String::from("i-0,i-1,o-0,o-1,"));
}

/// A federation fee quote whose only cost is `fee` in the Bitcoin unit, so
/// `total().get_bitcoin() == fee`.
fn federation_fee(fee: Amount) -> FeeQuote {
    FeeQuote {
        input: Amounts::new_bitcoin(fee),
        output: Amounts::ZERO,
        dust: Amounts::ZERO,
    }
}

#[tokio::test]
async fn max_affordable_no_fees_spends_whole_balance() {
    let balance = Amount::from_msats(1_000_000);

    let result = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        balance,
        |invoice| invoice, // no gateway fee
        |_contract| async { Ok::<_, TransactionSubmitError>(federation_fee(Amount::ZERO)) },
    )
    .await
    .expect("no fatal quote failure");

    assert_eq!(result, Some(balance));
}

#[tokio::test]
async fn max_affordable_leaves_room_for_gateway_fee() {
    // Gateway fee: 1000 msat base + 10% proportional.
    let gross_up = |invoice: Amount| Amount::from_msats(invoice.msats + 1000 + invoice.msats / 10);
    let balance = Amount::from_msats(1_000_000);

    let x = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        balance,
        gross_up,
        |_contract| async { Ok::<_, TransactionSubmitError>(federation_fee(Amount::ZERO)) },
    )
    .await
    .expect("no fatal quote failure")
    .expect("balance covers a payment")
    .msats;

    // `x` is payable but `x + 1` is not: exactly the maximum.
    assert!(gross_up(Amount::from_msats(x)).msats <= balance.msats);
    assert!(gross_up(Amount::from_msats(x + 1)).msats > balance.msats);
}

#[tokio::test]
async fn max_affordable_leaves_room_for_module_fee() {
    // Federation fee: 500 msat base + 5% of the contract amount.
    let module_fee = |contract: Amount| Amount::from_msats(500 + contract.msats / 20);
    let balance = Amount::from_msats(1_000_000);

    let x = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        balance,
        |invoice| invoice, // no gateway fee, so contract == invoice
        move |contract| async move {
            Ok::<_, TransactionSubmitError>(federation_fee(module_fee(contract)))
        },
    )
    .await
    .expect("no fatal quote failure")
    .expect("balance covers a payment")
    .msats;

    let cost = |v: u64| v + module_fee(Amount::from_msats(v)).msats;
    assert!(cost(x) <= balance.msats);
    assert!(cost(x + 1) > balance.msats);
}

#[tokio::test]
async fn max_affordable_handles_stepwise_fee() {
    // A fee that is NOT a linear function of the amount: it jumps once the
    // contract crosses a threshold, as selecting an extra funding note would.
    // A closed form assuming a linear fee would missolve this.
    let module_fee = |contract: Amount| {
        if contract.msats > 500_000 {
            Amount::from_msats(100_000)
        } else {
            Amount::ZERO
        }
    };
    let balance = Amount::from_msats(550_000);

    let result = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        balance,
        |invoice| invoice,
        move |contract| async move {
            Ok::<_, TransactionSubmitError>(federation_fee(module_fee(contract)))
        },
    )
    .await
    .expect("no fatal quote failure");

    // Above 500_000 the fee jump pushes the total over the balance, so the
    // maximum spendable amount sits exactly at the step.
    assert_eq!(result, Some(Amount::from_msats(500_000)));
}

#[tokio::test]
async fn max_affordable_respects_max_bound() {
    let balance = Amount::from_msats(1_000_000);

    let result = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        Amount::from_msats(100), // cap well below the balance
        |invoice| invoice,
        |_contract| async { Ok::<_, TransactionSubmitError>(federation_fee(Amount::ZERO)) },
    )
    .await
    .expect("no fatal quote failure");

    assert_eq!(result, Some(Amount::from_msats(100)));
}

#[tokio::test]
async fn max_affordable_none_when_min_unaffordable() {
    // Balance can't even cover the gateway base fee on the smallest send.
    let balance = Amount::from_msats(10);

    let result = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        balance,
        |invoice: Amount| Amount::from_msats(invoice.msats + 1000), // 1000 msat base fee
        |_contract| async { Ok::<_, TransactionSubmitError>(federation_fee(Amount::ZERO)) },
    )
    .await
    .expect("no fatal quote failure");

    assert_eq!(result, None);
}

#[tokio::test]
async fn max_affordable_treats_quote_error_as_ceiling() {
    // The fee quote reports `InsufficientFunds` once the contract exceeds what
    // the balance can fund, exactly as real note selection does. The solver
    // must treat that as the ceiling rather than overestimating.
    let cap = 400_000;
    let balance = Amount::from_msats(1_000_000);

    let result = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        balance,
        |invoice| invoice,
        move |contract: Amount| async move {
            if contract.msats > cap {
                Err(TransactionSubmitError::InsufficientFunds(
                    InsufficientBalanceError {
                        requested_amount: contract,
                        total_amount: Amount::from_msats(cap),
                    },
                ))
            } else {
                Ok(federation_fee(Amount::ZERO))
            }
        },
    )
    .await
    .expect("no fatal quote failure");

    assert_eq!(result, Some(Amount::from_msats(cap)));
}

#[tokio::test]
async fn max_affordable_surfaces_fatal_quote_failure() {
    // A quote failure unrelated to the balance (e.g. a database or federation
    // error) cannot get better by probing a smaller amount, so the solver must
    // surface it to the caller instead of treating it as unaffordable.
    let balance = Amount::from_msats(1_000_000);

    let result = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        balance,
        |invoice| invoice,
        |_contract| async {
            Err(TransactionSubmitError::NoPrimaryModule {
                unit: AmountUnit::BITCOIN,
            })
        },
    )
    .await;

    assert_matches!(result, Err(TransactionSubmitError::NoPrimaryModule { .. }));
}

#[tokio::test]
async fn max_affordable_seeds_search_near_the_top() {
    // A realistic split: a large proportional gateway fee, a tiny federation
    // fee. Seeding off the free `gross_up`-only pass should peel the gateway fee
    // off without a quote, leaving the (expensive) fee quotes to probe only the
    // small window the federation fee opens up — so their count stays well below
    // the ~log2(balance) a naive full-range search would need.
    let balance = Amount::from_msats(100_000_000); // 100k sats
    // 1000 msat base + 1% proportional gateway fee.
    let gross_up = |invoice: Amount| Amount::from_msats(invoice.msats + 1000 + invoice.msats / 100);
    // Tiny federation fee: 50 msat base + 0.001% of the contract.
    let fed_fee = |contract: Amount| Amount::from_msats(50 + contract.msats / 100_000);
    let quote_calls = AtomicUsize::new(0);

    let x = max_affordable_send_amount(
        balance,
        Amount::from_msats(1),
        balance,
        gross_up,
        |contract| {
            quote_calls.fetch_add(1, Ordering::Relaxed);
            async move { Ok::<_, TransactionSubmitError>(federation_fee(fed_fee(contract))) }
        },
    )
    .await
    .expect("no fatal quote failure")
    .expect("balance covers a payment")
    .msats;

    // Still the exact maximum: `x` is payable, `x + 1` is not.
    let cost = |v: u64| {
        let contract = gross_up(Amount::from_msats(v));
        contract.msats + fed_fee(contract).msats
    };
    assert!(cost(x) <= balance.msats);
    assert!(cost(x + 1) > balance.msats);

    // A naive binary search over the whole balance would need ~27 quotes here;
    // seeding near the top keeps it to a handful.
    let calls = quote_calls.load(Ordering::Relaxed);
    assert!(
        calls <= 16,
        "expected a small number of fee quotes, used {calls}"
    );
}