mostro 0.17.5

Lightning Network peer-to-peer nostr platform
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
use crate::app::context::AppContext;
use crate::db::update_user_trade_index;
use crate::util::{get_bitcoin_price, publish_order, validate_invoice};
use mostro_core::prelude::*;
use nostr_sdk::prelude::*;
use nostr_sdk::Keys;

async fn calculate_and_check_quote(
    ctx: &AppContext,
    order: &SmallOrder,
    fiat_amount: &i64,
) -> Result<(), MostroError> {
    // Get mostro settings
    let mostro_settings = &ctx.settings().mostro;
    // Calculate quote
    let quote = match order.amount {
        0 => match get_bitcoin_price(&order.fiat_code) {
            Ok(price) => {
                let quote = *fiat_amount as f64 / price;
                (quote * 1E8) as i64
            }
            Err(_) => {
                return Err(MostroInternalErr(ServiceError::NoAPIResponse));
            }
        },
        _ => order.amount,
    };

    // Check amount is positive - extra safety check
    if quote < 0 {
        return Err(MostroCantDo(CantDoReason::InvalidAmount));
    }

    if quote > mostro_settings.max_order_amount as i64
        || quote < mostro_settings.min_payment_amount as i64
    {
        return Err(MostroCantDo(CantDoReason::OutOfRangeSatsAmount));
    }

    Ok(())
}

/// Processes a trading order message by validating, updating, and publishing the order.
///
/// This asynchronous function inspects the provided message for an order and, if found, proceeds to:
/// - Validate the associated invoice.
/// - Check if fiat currency is accepted by mostro instance
/// - Check order constraints such as range limits and zero-amount premium conditions.
/// - Calculate a valid quote (in satoshis) for each fiat amount in the order.
/// - Determine the appropriate trade index, using a fallback when the sender matches the rumor's public key.
/// - Update the user's trade index in the database and publish the order.
///
/// If the message does not contain an order, the function simply returns `Ok(())`.
///
/// # Parameters
/// - `ctx`: Application context containing the database pool and other dependencies.
/// - `msg`: Trading message containing order details and a request ID.
/// - `event`: Event data providing sender and rumor details required for determining the trade index.
/// - `my_keys`: Local signing keys used during order publication.
///
/// # Errors
/// Returns a `MostroError` if any validation, quote calculation, trade index update, or order publication fails.
///
/// # Examples
///
/// ```rust,ignore
/// # use your_crate::{order_action, Message, UnwrappedMessage, Keys, AppContext};
/// # async fn run_example(ctx: &AppContext) -> Result<(), MostroError> {
/// // Initialize dummy instances; in a real application, replace these with actual values.
/// let msg = Message::default();
/// let event = UnwrappedMessage::default();
/// let my_keys = Keys::default();
///
/// // Process the order if present in the message.
/// order_action(&ctx, msg, &event, &my_keys).await?;
/// # Ok(())
/// # }
/// ```
pub async fn order_action(
    ctx: &AppContext,
    msg: Message,
    event: &UnwrappedMessage,
    my_keys: &Keys,
) -> Result<(), MostroError> {
    let pool = ctx.pool();
    // Get request id
    let request_id = msg.get_inner_message_kind().request_id;

    if let Some(order) = msg.get_inner_message_kind().get_order() {
        // Validate invoice
        let _invoice = validate_invoice(&msg, &Order::from(order.clone())).await?;

        // Check if fiat currency is accepted
        let mostro_settings = &ctx.settings().mostro;
        if let Err(cause) = order.check_fiat_currency(&mostro_settings.fiat_currencies_accepted) {
            return Err(MostroCantDo(cause));
        }

        // `check_fiat_amount` in mostro-core requires fiat_amount > 0. Range orders set
        // min/max and use fiat_amount == 0, so only run it for single-amount orders.
        if order.min_amount.is_none() && order.max_amount.is_none() {
            if let Err(cause) = order.check_fiat_amount() {
                return Err(MostroCantDo(cause));
            }
        }

        // Validate amount (sats) is non-negative
        if let Err(cause) = order.check_amount() {
            return Err(MostroCantDo(cause));
        }

        // Default case single amount
        let mut amount_vec = vec![order.fiat_amount];
        // Get max and and min amount in case of range order
        // in case of single order do like usual
        if let Err(cause) = order.check_range_order_limits(&mut amount_vec) {
            return Err(MostroCantDo(cause));
        }

        // Check if zero amount with premium
        if let Err(cause) = order.check_zero_amount_with_premium() {
            return Err(MostroCantDo(cause));
        }

        // Check quote in sats for each amount
        for fiat_amount in amount_vec.iter() {
            calculate_and_check_quote(ctx, order, fiat_amount).await?;
        }

        let trade_index = match msg.get_inner_message_kind().trade_index {
            Some(trade_index) => trade_index,
            None => {
                if event.identity == event.sender {
                    0
                } else {
                    return Err(MostroInternalErr(ServiceError::InvalidPayload));
                }
            }
        };

        // Update trade index only after all checks are done
        update_user_trade_index(pool, event.identity.to_string(), trade_index)
            .await
            .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;

        // Publish order
        publish_order(
            pool,
            my_keys,
            order,
            event.sender,
            event.identity,
            event.sender,
            request_id,
            msg.get_inner_message_kind().trade_index,
        )
        .await?
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use mostro_core::message::MessageKind;

    use nostr_sdk::{Keys, Timestamp};
    use sqlx::SqlitePool;

    async fn create_test_pool() -> SqlitePool {
        SqlitePool::connect(":memory:").await.unwrap()
    }

    fn create_test_keys() -> Keys {
        Keys::generate()
    }

    fn create_test_message(trade_index: Option<u32>) -> Message {
        Message::new_order(
            Some(uuid::Uuid::new_v4()),
            Some(1),
            trade_index.map(|i| i as i64),
            Action::NewOrder,
            None, // We don't need payload for structure tests
        )
    }

    fn create_test_unwrapped_message() -> UnwrappedMessage {
        let identity = create_test_keys();
        let trade = create_test_keys();

        UnwrappedMessage {
            message: create_test_message(None),
            signature: None,
            sender: trade.public_key(),
            identity: identity.public_key(),
            created_at: Timestamp::now(),
        }
    }

    fn create_test_order_message(fiat_amount: i64, amount: i64) -> Message {
        let order = mostro_core::order::SmallOrder::new(
            Some(uuid::Uuid::new_v4()),
            Some(mostro_core::order::Kind::Sell),
            Some(mostro_core::order::Status::Pending),
            amount,
            "USD".to_string(),
            None,
            None,
            fiat_amount,
            "BANK".to_string(),
            0,
            None,
            None,
            None,
            None,
            None,
        );
        Message::new_order(
            Some(uuid::Uuid::new_v4()),
            Some(1),
            None,
            Action::NewOrder,
            Some(Payload::Order(order)),
        )
    }

    #[tokio::test]
    async fn test_order_action_no_order() {
        let pool = create_test_pool().await;
        use crate::app::context::test_utils::{test_settings, TestContextBuilder};
        let ctx = TestContextBuilder::new()
            .with_pool(std::sync::Arc::new(pool.clone()))
            .with_settings(test_settings())
            .build();
        let keys = create_test_keys();
        let event = create_test_unwrapped_message();

        // Create message without order payload
        let msg = Message::Order(MessageKind {
            version: 1,
            request_id: Some(1),
            trade_index: None,
            id: Some(uuid::Uuid::new_v4()),
            action: Action::NewOrder,
            payload: None,
        });

        let result = order_action(&ctx, msg, &event, &keys).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn test_order_action_invalid_fiat_amount() {
        let pool = create_test_pool().await;
        use crate::app::context::test_utils::{test_settings, TestContextBuilder};
        let ctx = TestContextBuilder::new()
            .with_pool(std::sync::Arc::new(pool.clone()))
            .with_settings(test_settings())
            .build();
        let keys = create_test_keys();
        let event = create_test_unwrapped_message();

        // fiat_amount = 0 should be rejected by check_fiat_amount
        let msg = create_test_order_message(0, 50000);
        let result = order_action(&ctx, msg, &event, &keys).await;
        let err = result.unwrap_err();
        assert!(
            matches!(err, MostroCantDo(CantDoReason::InvalidAmount)),
            "expected InvalidAmount, got: {:?}",
            err
        );

        // fiat_amount < 0 should also be rejected with same error
        let msg = create_test_order_message(-100, 50000);
        let result = order_action(&ctx, msg, &event, &keys).await;
        let err = result.unwrap_err();
        assert!(
            matches!(err, MostroCantDo(CantDoReason::InvalidAmount)),
            "expected InvalidAmount for negative, got: {:?}",
            err
        );
    }

    #[tokio::test]
    async fn test_order_action_invalid_amount() {
        let pool = create_test_pool().await;
        use crate::app::context::test_utils::{test_settings, TestContextBuilder};
        let ctx = TestContextBuilder::new()
            .with_pool(std::sync::Arc::new(pool.clone()))
            .with_settings(test_settings())
            .build();
        let keys = create_test_keys();
        let event = create_test_unwrapped_message();

        // amount < 0 should be rejected by check_amount
        let msg = create_test_order_message(100, -50000);
        let result = order_action(&ctx, msg, &event, &keys).await;
        let err = result.unwrap_err();
        assert!(
            matches!(err, MostroCantDo(CantDoReason::InvalidAmount)),
            "expected InvalidAmount, got: {:?}",
            err
        );
    }

    #[tokio::test]
    async fn test_order_action_with_valid_order() {
        let pool = create_test_pool().await;
        use crate::app::context::test_utils::{test_settings, TestContextBuilder};
        let ctx = TestContextBuilder::new()
            .with_pool(std::sync::Arc::new(pool.clone()))
            .with_settings(test_settings())
            .build();
        let keys = create_test_keys();
        let event = create_test_unwrapped_message();
        let msg = create_test_message(Some(1));

        // This test would require:
        // 1. Mocking validate_invoice
        // 2. Setting up database tables
        // 3. Mocking publish_order
        // For now, we test the structure
        let _ = order_action(&ctx, msg, &event, &keys).await;
    }

    #[tokio::test]
    async fn test_order_action_range_order_validation() {
        let pool = create_test_pool().await;
        use crate::app::context::test_utils::{test_settings, TestContextBuilder};
        let ctx = TestContextBuilder::new()
            .with_pool(std::sync::Arc::new(pool.clone()))
            .with_settings(test_settings())
            .build();
        let keys = create_test_keys();
        let event = create_test_unwrapped_message();

        let msg = create_test_message(Some(1));

        let _ = order_action(&ctx, msg, &event, &keys).await;
    }

    #[tokio::test]
    async fn test_order_action_zero_amount_with_premium() {
        let pool = create_test_pool().await;
        use crate::app::context::test_utils::{test_settings, TestContextBuilder};
        let ctx = TestContextBuilder::new()
            .with_pool(std::sync::Arc::new(pool.clone()))
            .with_settings(test_settings())
            .build();
        let keys = create_test_keys();
        let event = create_test_unwrapped_message();

        let msg = create_test_message(Some(1));
        // Structural check: ensure call does not panic
        let _ = order_action(&ctx, msg, &event, &keys).await;
    }

    #[tokio::test]
    async fn test_order_action_trade_index_logic() {
        let pool = create_test_pool().await;
        use crate::app::context::test_utils::{test_settings, TestContextBuilder};
        let ctx = TestContextBuilder::new()
            .with_pool(std::sync::Arc::new(pool.clone()))
            .with_settings(test_settings())
            .build();
        let keys = create_test_keys();

        // Test case 1: identity == sender, no trade_index
        let mut event = create_test_unwrapped_message();
        event.identity = event.sender;
        let msg = create_test_message(None);

        let _ = order_action(&ctx, msg, &event, &keys).await;

        // Test case 2: identity != sender, no trade_index
        let event2 = create_test_unwrapped_message();
        // identity and sender are already distinct by default
        let msg2 = create_test_message(None);

        // Structural check: ensure call returns a Result without panicking
        let _ = order_action(&ctx, msg2, &event2, &keys).await;

        // Test case 3: with trade_index
        let msg3 = create_test_message(Some(1));
        let _ = order_action(&ctx, msg3, &event2, &keys).await;
    }

    mod quote_calculation_tests {

        #[test]
        fn test_quote_calculation_logic() {
            // Test the mathematical logic for quote calculation
            let fiat_amount = 100i64;
            let price = 50000.0; // $50,000 per BTC

            // Expected: (100 / 50000) * 1E8 = 200,000 sats
            let expected_quote = (fiat_amount as f64 / price * 1E8) as i64;
            assert_eq!(expected_quote, 200_000);

            // Test with different values
            let fiat_amount2 = 1000i64;
            let price2 = 25000.0; // $25,000 per BTC
            let expected_quote2 = (fiat_amount2 as f64 / price2 * 1E8) as i64;
            assert_eq!(expected_quote2, 4_000_000); // 0.04 BTC = 4M sats
        }

        #[test]
        fn test_amount_limits_validation() {
            // Test amount validation logic
            let quote = 1000i64;
            let max_order = 100_000_000i64; // 1 BTC
            let min_payment = 1_000i64; // 1k sats

            // Valid amount
            assert!(quote >= min_payment && quote <= max_order);

            // Too small
            let small_quote = 500i64;
            assert!(small_quote < min_payment);

            // Too large
            let large_quote = 200_000_000i64; // 2 BTC
            assert!(large_quote > max_order);
        }
    }
}