• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

payjoin / rust-payjoin / 17624704310

10 Sep 2025 07:28PM UTC coverage: 86.041% (+0.02%) from 86.019%
17624704310

Pull #1055

github

web-flow
Merge 2a1875d38 into 52cfeef1a
Pull Request #1055: Remove optional return types from session history methods

14 of 14 new or added lines in 3 files covered. (100.0%)

4 existing lines in 2 files now uncovered.

8235 of 9571 relevant lines covered (86.04%)

488.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.59
/payjoin/src/core/receive/v2/session.rs
1
use std::time::SystemTime;
2

3
use serde::{Deserialize, Serialize};
4

5
use super::{ReceiveSession, SessionContext};
6
use crate::error::{InternalReplayError, ReplayError};
7
use crate::output_substitution::OutputSubstitution;
8
use crate::persist::SessionPersister;
9
use crate::receive::v2::{extract_err_req, InternalSessionError, SessionError};
10
use crate::receive::{common, JsonReply, OriginalPayload, PsbtContext};
11
use crate::{ImplementationError, IntoUrl, PjUri, Request};
12

13
/// Replay a receiver event log to get the receiver in its current state [ReceiveSession]
14
/// and a session history [SessionHistory]
15
pub fn replay_event_log<P>(
10✔
16
    persister: &P,
10✔
17
) -> Result<(ReceiveSession, SessionHistory), ReplayError<ReceiveSession, SessionEvent>>
10✔
18
where
10✔
19
    P: SessionPersister,
10✔
20
    P::SessionEvent: Into<SessionEvent> + Clone,
10✔
21
    P::SessionEvent: From<SessionEvent>,
10✔
22
{
23
    let mut logs = persister
10✔
24
        .load()
10✔
25
        .map_err(|e| InternalReplayError::PersistenceFailure(ImplementationError::new(e)))?;
10✔
26

27
    let mut history = SessionHistory::default();
10✔
28
    let first_event = logs.next().ok_or(InternalReplayError::NoEvents)?.into();
10✔
29
    history.events.push(first_event.clone());
10✔
30
    let mut receiver = match first_event {
10✔
31
        SessionEvent::Created(context) => ReceiveSession::new(context),
10✔
32
        _ => return Err(InternalReplayError::InvalidEvent(Box::new(first_event), None).into()),
×
33
    };
34
    for event in logs {
33✔
35
        history.events.push(event.clone().into());
23✔
36
        receiver = receiver.process_event(event.into()).map_err(|e| {
23✔
37
            if let Err(storage_err) = persister.close() {
×
38
                return InternalReplayError::PersistenceFailure(ImplementationError::new(
×
39
                    storage_err,
×
40
                ))
×
41
                .into();
×
42
            }
×
43
            e
×
44
        })?;
×
45
    }
46

47
    let ctx = history.session_context();
10✔
48
    if SystemTime::now() > ctx.expiry {
10✔
49
        // Session has expired: close the session and persist a fatal error
50
        let err = SessionError(InternalSessionError::Expired(ctx.expiry));
1✔
51
        persister
1✔
52
            .save_event(SessionEvent::SessionInvalid(err.to_string(), None).into())
1✔
53
            .map_err(|e| InternalReplayError::PersistenceFailure(ImplementationError::new(e)))?;
1✔
54
        persister
1✔
55
            .close()
1✔
56
            .map_err(|e| InternalReplayError::PersistenceFailure(ImplementationError::new(e)))?;
1✔
57

58
        return Ok((ReceiveSession::TerminalFailure, history));
1✔
59
    }
9✔
60

61
    Ok((receiver, history))
9✔
62
}
10✔
63

64
/// A collection of events that have occurred during a receiver's session.
65
/// It is obtained by calling [replay_event_log].
66
#[derive(Default, Clone)]
67
pub struct SessionHistory {
68
    events: Vec<SessionEvent>,
69
}
70

71
impl SessionHistory {
72
    /// Receiver session Payjoin URI
73
    pub fn pj_uri<'a>(&self) -> PjUri<'a> {
2✔
74
        self.events
2✔
75
            .iter()
2✔
76
            .find_map(|event| match event {
2✔
77
                SessionEvent::Created(session_context) =>
2✔
78
                    Some(crate::receive::v2::pj_uri(session_context, OutputSubstitution::Disabled)),
2✔
UNCOV
79
                _ => None,
×
80
            })
2✔
81
            .expect("Session event log must contain at least one event with pj_uri")
2✔
82
    }
2✔
83

84
    fn get_unchecked_proposal(&self) -> Option<OriginalPayload> {
3✔
85
        self.events.iter().find_map(|event| match event {
6✔
86
            SessionEvent::UncheckedOriginalPayload { original, .. } => Some(original.clone()),
3✔
87
            _ => None,
3✔
88
        })
6✔
89
    }
3✔
90

91
    /// Fallback transaction from the session if present
92
    pub fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
7✔
93
        self.events.iter().find_map(|event| match event {
15✔
94
            SessionEvent::MaybeInputsOwned() => Some(
3✔
95
                self.get_unchecked_proposal()
3✔
96
                    .expect("Should exist if this event is present")
3✔
97
                    .psbt
3✔
98
                    .extract_tx_unchecked_fee_rate(),
3✔
99
            ),
3✔
100
            _ => None,
12✔
101
        })
15✔
102
    }
7✔
103

104
    /// Psbt with fee contributions applied
105
    pub fn psbt_ready_for_signing(&self) -> Option<bitcoin::Psbt> {
7✔
106
        self.events.iter().find_map(|event| match event {
27✔
107
            SessionEvent::ProvisionalProposal(psbt_context) =>
2✔
108
                Some(psbt_context.payjoin_psbt.clone()),
2✔
109
            _ => None,
25✔
110
        })
27✔
111
    }
7✔
112

113
    /// Terminal error from the session if present
114
    pub fn terminal_error(&self) -> Option<(String, Option<JsonReply>)> {
4✔
115
        self.events.iter().find_map(|event| match event {
10✔
116
            SessionEvent::SessionInvalid(err_str, reply) => Some((err_str.clone(), reply.clone())),
3✔
117
            _ => None,
7✔
118
        })
10✔
119
    }
4✔
120

121
    /// Construct the error request to be posted on the directory if an error occurred.
122
    /// To process the response, use [crate::receive::v2::process_err_res]
123
    pub fn extract_err_req(
6✔
124
        &self,
6✔
125
        ohttp_relay: impl IntoUrl,
6✔
126
    ) -> Result<Option<(Request, ohttp::ClientResponse)>, SessionError> {
6✔
127
        // FIXME ideally this should be more like a method of
128
        // Receiver<UncheckedOriginalPayload> and subsequent states instead of the
129
        // history as a whole since it doesn't make sense to call it before,
130
        // reaching that state.
131
        if !self.received_sender_proposal() {
6✔
132
            return Ok(None);
3✔
133
        }
3✔
134

135
        let session_context = self.session_context();
3✔
136
        let json_reply = match self.terminal_error() {
3✔
137
            Some((_, Some(json_reply))) => json_reply,
3✔
UNCOV
138
            _ => return Ok(None),
×
139
        };
140
        let (req, ctx) = extract_err_req(&json_reply, ohttp_relay, &session_context)?;
3✔
141
        Ok(Some((req, ctx)))
3✔
142
    }
6✔
143

144
    fn received_sender_proposal(&self) -> bool {
6✔
145
        self.events
6✔
146
            .iter()
6✔
147
            .any(|event| matches!(event, SessionEvent::UncheckedOriginalPayload { .. }))
12✔
148
    }
6✔
149

150
    fn session_context(&self) -> SessionContext {
15✔
151
        let mut initial_session_context = self
15✔
152
            .events
15✔
153
            .iter()
15✔
154
            .find_map(|event| match event {
15✔
155
                SessionEvent::Created(session_context) => Some(session_context.clone()),
15✔
156
                _ => None,
×
157
            })
15✔
158
            .expect("Session event log must contain at least one event with session_context");
15✔
159

160
        initial_session_context.reply_key = self.events.iter().find_map(|event| match event {
26✔
161
            SessionEvent::UncheckedOriginalPayload { reply_key, .. } => reply_key.clone(),
11✔
162
            _ => None,
15✔
163
        });
26✔
164

165
        initial_session_context
15✔
166
    }
15✔
167
}
168

169
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
170
/// Represents a piece of information that the receiver has obtained from the session
171
/// Each event can be used to transition the receiver state machine to a new state
172
pub enum SessionEvent {
173
    Created(SessionContext),
174
    UncheckedOriginalPayload {
175
        original: OriginalPayload,
176
        reply_key: Option<crate::HpkePublicKey>,
177
    },
178
    MaybeInputsOwned(),
179
    MaybeInputsSeen(),
180
    OutputsUnknown(),
181
    WantsOutputs(common::WantsOutputs),
182
    WantsInputs(common::WantsInputs),
183
    WantsFeeRange(common::WantsFeeRange),
184
    ProvisionalProposal(PsbtContext),
185
    PayjoinProposal(bitcoin::Psbt),
186
    /// Session is invalid. This is a irrecoverable error. Fallback tx should be broadcasted.
187
    /// TODO this should be any error type that is impl std::error and works well with serde, or as a fallback can be formatted as a string
188
    /// Reason being in some cases we still want to preserve the error b/c we can action on it. For now this is a terminal state and there is nothing to replay and is saved to be displayed.
189
    /// b/c its a terminal state and there is nothing to replay. So serialization will be lossy and that is fine.
190
    SessionInvalid(String, Option<JsonReply>),
191
}
192

193
#[cfg(test)]
194
mod tests {
195
    use std::time::{Duration, SystemTime};
196

197
    use payjoin_test_utils::{BoxError, EXAMPLE_URL};
198

199
    use super::*;
200
    use crate::persist::test_utils::InMemoryTestPersister;
201
    use crate::persist::NoopSessionPersister;
202
    use crate::receive::tests::original_from_test_vector;
203
    use crate::receive::v2::test::{mock_err, SHARED_CONTEXT};
204
    use crate::receive::v2::{
205
        Initialized, MaybeInputsOwned, PayjoinProposal, ProvisionalProposal, Receiver,
206
        UncheckedOriginalPayload,
207
    };
208

209
    fn unchecked_receiver_from_test_vector() -> Receiver<UncheckedOriginalPayload> {
4✔
210
        Receiver {
4✔
211
            state: UncheckedOriginalPayload { original: original_from_test_vector() },
4✔
212
            session_context: SHARED_CONTEXT.clone(),
4✔
213
        }
4✔
214
    }
4✔
215

216
    #[test]
217
    fn test_session_event_serialization_roundtrip() {
1✔
218
        let persister = NoopSessionPersister::<SessionEvent>::default();
1✔
219

220
        let original = original_from_test_vector();
1✔
221
        let unchecked_proposal = unchecked_receiver_from_test_vector();
1✔
222
        let maybe_inputs_owned = unchecked_proposal
1✔
223
            .clone()
1✔
224
            .assume_interactive_receiver()
1✔
225
            .save(&persister)
1✔
226
            .expect("Save should not fail");
1✔
227
        let maybe_inputs_seen = maybe_inputs_owned
1✔
228
            .clone()
1✔
229
            .check_inputs_not_owned(&mut |_| Ok(false))
1✔
230
            .save(&persister)
1✔
231
            .expect("No inputs should be owned");
1✔
232
        let outputs_unknown = maybe_inputs_seen
1✔
233
            .clone()
1✔
234
            .check_no_inputs_seen_before(&mut |_| Ok(false))
1✔
235
            .save(&persister)
1✔
236
            .expect("No inputs should be seen before");
1✔
237
        let wants_outputs = outputs_unknown
1✔
238
            .clone()
1✔
239
            .identify_receiver_outputs(&mut |_| Ok(true))
1✔
240
            .save(&persister)
1✔
241
            .expect("Outputs should be identified");
1✔
242
        let wants_inputs =
1✔
243
            wants_outputs.clone().commit_outputs().save(&persister).expect("Save should not fail");
1✔
244
        let wants_fee_range =
1✔
245
            wants_inputs.clone().commit_inputs().save(&persister).expect("Save should not fail");
1✔
246
        let provisional_proposal = wants_fee_range
1✔
247
            .clone()
1✔
248
            .apply_fee_range(None, None)
1✔
249
            .save(&persister)
1✔
250
            .expect("Save should not fail");
1✔
251
        let payjoin_proposal = provisional_proposal
1✔
252
            .clone()
1✔
253
            .finalize_proposal(|psbt| Ok(psbt.clone()))
1✔
254
            .save(&persister)
1✔
255
            .expect("Payjoin proposal should be finalized");
1✔
256

257
        let test_cases = vec![
1✔
258
            SessionEvent::Created(SHARED_CONTEXT.clone()),
1✔
259
            SessionEvent::UncheckedOriginalPayload { original: original.clone(), reply_key: None },
1✔
260
            SessionEvent::UncheckedOriginalPayload {
1✔
261
                original,
1✔
262
                reply_key: Some(crate::HpkeKeyPair::gen_keypair().1),
1✔
263
            },
1✔
264
            SessionEvent::MaybeInputsOwned(),
1✔
265
            SessionEvent::MaybeInputsSeen(),
1✔
266
            SessionEvent::OutputsUnknown(),
1✔
267
            SessionEvent::WantsOutputs(wants_outputs.state.inner.clone()),
1✔
268
            SessionEvent::WantsInputs(wants_inputs.state.inner.clone()),
1✔
269
            SessionEvent::WantsFeeRange(wants_fee_range.state.inner.clone()),
1✔
270
            SessionEvent::ProvisionalProposal(provisional_proposal.state.psbt_context.clone()),
1✔
271
            SessionEvent::PayjoinProposal(payjoin_proposal.psbt().clone()),
1✔
272
        ];
273

274
        for event in test_cases {
12✔
275
            let serialized = serde_json::to_string(&event).expect("Serialization should not fail");
11✔
276
            let deserialized: SessionEvent =
11✔
277
                serde_json::from_str(&serialized).expect("Deserialization should not fail");
11✔
278
            assert_eq!(event, deserialized);
11✔
279
        }
280
    }
1✔
281

282
    struct SessionHistoryExpectedOutcome {
283
        psbt_with_fee_contributions: Option<bitcoin::Psbt>,
284
        fallback_tx: Option<bitcoin::Transaction>,
285
    }
286

287
    struct SessionHistoryTest {
288
        events: Vec<SessionEvent>,
289
        expected_session_history: SessionHistoryExpectedOutcome,
290
        expected_receiver_state: ReceiveSession,
291
    }
292

293
    fn run_session_history_test(test: SessionHistoryTest) -> Result<(), BoxError> {
7✔
294
        let persister = InMemoryTestPersister::<SessionEvent>::default();
7✔
295
        for event in test.events {
35✔
296
            persister.save_event(event)?;
28✔
297
        }
298

299
        let (receiver, session_history) = replay_event_log(&persister)?;
7✔
300
        assert_eq!(receiver, test.expected_receiver_state);
7✔
301
        assert_eq!(
7✔
302
            session_history.psbt_ready_for_signing(),
7✔
303
            test.expected_session_history.psbt_with_fee_contributions
304
        );
305
        assert_eq!(session_history.fallback_tx(), test.expected_session_history.fallback_tx);
7✔
306
        Ok(())
7✔
307
    }
7✔
308

309
    #[test]
310
    fn test_replaying_session_creation() -> Result<(), BoxError> {
1✔
311
        let session_context = SHARED_CONTEXT.clone();
1✔
312
        let test = SessionHistoryTest {
1✔
313
            events: vec![SessionEvent::Created(session_context.clone())],
1✔
314
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
315
                psbt_with_fee_contributions: None,
1✔
316
                fallback_tx: None,
1✔
317
            },
1✔
318
            expected_receiver_state: ReceiveSession::Initialized(Receiver {
1✔
319
                state: Initialized {},
1✔
320
                session_context,
1✔
321
            }),
1✔
322
        };
1✔
323
        run_session_history_test(test)
1✔
324
    }
1✔
325

326
    #[test]
327
    fn test_replaying_session_creation_with_expired_session() -> Result<(), BoxError> {
1✔
328
        let session_context = SessionContext {
1✔
329
            expiry: SystemTime::now() - Duration::from_secs(1),
1✔
330
            ..SHARED_CONTEXT.clone()
1✔
331
        };
1✔
332
        let test = SessionHistoryTest {
1✔
333
            events: vec![SessionEvent::Created(session_context.clone())],
1✔
334
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
335
                psbt_with_fee_contributions: None,
1✔
336
                fallback_tx: None,
1✔
337
            },
1✔
338
            expected_receiver_state: ReceiveSession::TerminalFailure,
1✔
339
        };
1✔
340
        // TODO: should check for the expired error message off the session history
341
        run_session_history_test(test)
1✔
342
    }
1✔
343

344
    #[test]
345
    fn test_replaying_unchecked_proposal() -> Result<(), BoxError> {
1✔
346
        let session_context = SHARED_CONTEXT.clone();
1✔
347
        let original = original_from_test_vector();
1✔
348
        let reply_key = Some(crate::HpkeKeyPair::gen_keypair().1);
1✔
349

350
        let test = SessionHistoryTest {
1✔
351
            events: vec![
1✔
352
                SessionEvent::Created(session_context.clone()),
1✔
353
                SessionEvent::UncheckedOriginalPayload {
1✔
354
                    original: original.clone(),
1✔
355
                    reply_key: reply_key.clone(),
1✔
356
                },
1✔
357
            ],
1✔
358
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
359
                psbt_with_fee_contributions: None,
1✔
360
                fallback_tx: None,
1✔
361
            },
1✔
362
            expected_receiver_state: ReceiveSession::UncheckedOriginalPayload(Receiver {
1✔
363
                state: UncheckedOriginalPayload { original },
1✔
364
                session_context: SessionContext { reply_key, ..session_context },
1✔
365
            }),
1✔
366
        };
1✔
367
        run_session_history_test(test)
1✔
368
    }
1✔
369

370
    #[test]
371
    fn test_replaying_unchecked_proposal_with_reply_key() -> Result<(), BoxError> {
1✔
372
        let session_context = SHARED_CONTEXT.clone();
1✔
373
        let original = original_from_test_vector();
1✔
374
        let reply_key = Some(crate::HpkeKeyPair::gen_keypair().1);
1✔
375

376
        let test = SessionHistoryTest {
1✔
377
            events: vec![
1✔
378
                SessionEvent::Created(session_context.clone()),
1✔
379
                SessionEvent::UncheckedOriginalPayload {
1✔
380
                    original: original.clone(),
1✔
381
                    reply_key: reply_key.clone(),
1✔
382
                },
1✔
383
            ],
1✔
384
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
385
                psbt_with_fee_contributions: None,
1✔
386
                fallback_tx: None,
1✔
387
            },
1✔
388
            expected_receiver_state: ReceiveSession::UncheckedOriginalPayload(Receiver {
1✔
389
                state: UncheckedOriginalPayload { original },
1✔
390
                session_context: SessionContext { reply_key, ..session_context },
1✔
391
            }),
1✔
392
        };
1✔
393
        run_session_history_test(test)
1✔
394
    }
1✔
395

396
    #[test]
397
    fn getting_fallback_tx() -> Result<(), BoxError> {
1✔
398
        let persister = NoopSessionPersister::<SessionEvent>::default();
1✔
399
        let session_context = SHARED_CONTEXT.clone();
1✔
400
        let mut events = vec![];
1✔
401
        let original = original_from_test_vector();
1✔
402
        let maybe_inputs_owned = unchecked_receiver_from_test_vector()
1✔
403
            .assume_interactive_receiver()
1✔
404
            .save(&persister)
1✔
405
            .unwrap();
1✔
406
        let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast();
1✔
407
        let reply_key = Some(crate::HpkeKeyPair::gen_keypair().1);
1✔
408

409
        events.push(SessionEvent::Created(session_context.clone()));
1✔
410
        events.push(SessionEvent::UncheckedOriginalPayload {
1✔
411
            original: original.clone(),
1✔
412
            reply_key: reply_key.clone(),
1✔
413
        });
1✔
414
        events.push(SessionEvent::MaybeInputsOwned());
1✔
415

416
        let test = SessionHistoryTest {
1✔
417
            events,
1✔
418
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
419
                psbt_with_fee_contributions: None,
1✔
420
                fallback_tx: Some(expected_fallback),
1✔
421
            },
1✔
422
            expected_receiver_state: ReceiveSession::MaybeInputsOwned(Receiver {
1✔
423
                state: MaybeInputsOwned { original },
1✔
424
                session_context: SessionContext { reply_key, ..session_context },
1✔
425
            }),
1✔
426
        };
1✔
427
        run_session_history_test(test)
1✔
428
    }
1✔
429

430
    #[test]
431
    fn test_contributed_inputs() -> Result<(), BoxError> {
1✔
432
        let persister = InMemoryTestPersister::<SessionEvent>::default();
1✔
433
        let session_context = SHARED_CONTEXT.clone();
1✔
434
        let mut events = vec![];
1✔
435

436
        let original = original_from_test_vector();
1✔
437
        let maybe_inputs_owned = unchecked_receiver_from_test_vector()
1✔
438
            .assume_interactive_receiver()
1✔
439
            .save(&persister)
1✔
440
            .unwrap();
1✔
441
        let maybe_inputs_seen = maybe_inputs_owned
1✔
442
            .clone()
1✔
443
            .check_inputs_not_owned(&mut |_| Ok(false))
1✔
444
            .save(&persister)
1✔
445
            .expect("No inputs should be owned");
1✔
446
        let outputs_unknown = maybe_inputs_seen
1✔
447
            .clone()
1✔
448
            .check_no_inputs_seen_before(&mut |_| Ok(false))
1✔
449
            .save(&persister)
1✔
450
            .expect("No inputs should be seen before");
1✔
451
        let wants_outputs = outputs_unknown
1✔
452
            .clone()
1✔
453
            .identify_receiver_outputs(&mut |_| Ok(true))
1✔
454
            .save(&persister)
1✔
455
            .expect("Outputs should be identified");
1✔
456
        let wants_inputs =
1✔
457
            wants_outputs.clone().commit_outputs().save(&persister).expect("Save should not fail");
1✔
458
        let wants_fee_range =
1✔
459
            wants_inputs.clone().commit_inputs().save(&persister).expect("Save should not fail");
1✔
460
        let provisional_proposal = wants_fee_range
1✔
461
            .clone()
1✔
462
            .apply_fee_range(None, None)
1✔
463
            .save(&persister)
1✔
464
            .expect("Contributed inputs should be valid");
1✔
465
        let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast();
1✔
466
        let reply_key = Some(crate::HpkeKeyPair::gen_keypair().1);
1✔
467

468
        events.push(SessionEvent::Created(session_context.clone()));
1✔
469
        events.push(SessionEvent::UncheckedOriginalPayload {
1✔
470
            original: original.clone(),
1✔
471
            reply_key: reply_key.clone(),
1✔
472
        });
1✔
473
        events.push(SessionEvent::MaybeInputsOwned());
1✔
474
        events.push(SessionEvent::MaybeInputsSeen());
1✔
475
        events.push(SessionEvent::OutputsUnknown());
1✔
476
        events.push(SessionEvent::WantsOutputs(wants_outputs.state.inner.clone()));
1✔
477
        events.push(SessionEvent::WantsInputs(wants_inputs.state.inner.clone()));
1✔
478
        events.push(SessionEvent::WantsFeeRange(wants_fee_range.state.inner.clone()));
1✔
479
        events.push(SessionEvent::ProvisionalProposal(
1✔
480
            provisional_proposal.state.psbt_context.clone(),
1✔
481
        ));
1✔
482

483
        let test = SessionHistoryTest {
1✔
484
            events,
1✔
485
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
486
                psbt_with_fee_contributions: Some(
1✔
487
                    provisional_proposal.state.psbt_context.payjoin_psbt.clone(),
1✔
488
                ),
1✔
489
                fallback_tx: Some(expected_fallback),
1✔
490
            },
1✔
491
            expected_receiver_state: ReceiveSession::ProvisionalProposal(Receiver {
1✔
492
                state: ProvisionalProposal {
1✔
493
                    psbt_context: provisional_proposal.state.psbt_context.clone(),
1✔
494
                },
1✔
495
                session_context: SessionContext { reply_key, ..session_context },
1✔
496
            }),
1✔
497
        };
1✔
498
        run_session_history_test(test)
1✔
499
    }
1✔
500

501
    #[test]
502
    fn test_payjoin_proposal() -> Result<(), BoxError> {
1✔
503
        let persister = NoopSessionPersister::<SessionEvent>::default();
1✔
504
        let session_context = SHARED_CONTEXT.clone();
1✔
505
        let mut events = vec![];
1✔
506

507
        let original = original_from_test_vector();
1✔
508
        let maybe_inputs_owned = unchecked_receiver_from_test_vector()
1✔
509
            .assume_interactive_receiver()
1✔
510
            .save(&persister)
1✔
511
            .unwrap();
1✔
512
        let maybe_inputs_seen = maybe_inputs_owned
1✔
513
            .clone()
1✔
514
            .check_inputs_not_owned(&mut |_| Ok(false))
1✔
515
            .save(&persister)
1✔
516
            .expect("No inputs should be owned");
1✔
517
        let outputs_unknown = maybe_inputs_seen
1✔
518
            .clone()
1✔
519
            .check_no_inputs_seen_before(&mut |_| Ok(false))
1✔
520
            .save(&persister)
1✔
521
            .expect("No inputs should be seen before");
1✔
522
        let wants_outputs = outputs_unknown
1✔
523
            .clone()
1✔
524
            .identify_receiver_outputs(&mut |_| Ok(true))
1✔
525
            .save(&persister)
1✔
526
            .expect("Outputs should be identified");
1✔
527
        let wants_inputs =
1✔
528
            wants_outputs.clone().commit_outputs().save(&persister).expect("Save should not fail");
1✔
529
        let wants_fee_range =
1✔
530
            wants_inputs.clone().commit_inputs().save(&persister).expect("Save should not fail");
1✔
531
        let provisional_proposal = wants_fee_range
1✔
532
            .clone()
1✔
533
            .apply_fee_range(None, None)
1✔
534
            .save(&persister)
1✔
535
            .expect("Contributed inputs should be valid");
1✔
536
        let payjoin_proposal = provisional_proposal
1✔
537
            .clone()
1✔
538
            .finalize_proposal(|psbt| Ok(psbt.clone()))
1✔
539
            .save(&persister)
1✔
540
            .expect("Payjoin proposal should be finalized");
1✔
541
        let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast();
1✔
542
        let reply_key = Some(crate::HpkeKeyPair::gen_keypair().1);
1✔
543

544
        events.push(SessionEvent::Created(session_context.clone()));
1✔
545
        events.push(SessionEvent::UncheckedOriginalPayload {
1✔
546
            original: original.clone(),
1✔
547
            reply_key: reply_key.clone(),
1✔
548
        });
1✔
549
        events.push(SessionEvent::MaybeInputsOwned());
1✔
550
        events.push(SessionEvent::MaybeInputsSeen());
1✔
551
        events.push(SessionEvent::OutputsUnknown());
1✔
552
        events.push(SessionEvent::WantsOutputs(wants_outputs.state.inner.clone()));
1✔
553
        events.push(SessionEvent::WantsInputs(wants_inputs.state.inner.clone()));
1✔
554
        events.push(SessionEvent::WantsFeeRange(wants_fee_range.state.inner.clone()));
1✔
555
        events.push(SessionEvent::ProvisionalProposal(
1✔
556
            provisional_proposal.state.psbt_context.clone(),
1✔
557
        ));
1✔
558
        events.push(SessionEvent::PayjoinProposal(payjoin_proposal.psbt().clone()));
1✔
559

560
        let test = SessionHistoryTest {
1✔
561
            events,
1✔
562
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
563
                psbt_with_fee_contributions: Some(
1✔
564
                    provisional_proposal.state.psbt_context.payjoin_psbt.clone(),
1✔
565
                ),
1✔
566
                fallback_tx: Some(expected_fallback),
1✔
567
            },
1✔
568
            expected_receiver_state: ReceiveSession::PayjoinProposal(Receiver {
1✔
569
                state: PayjoinProposal { psbt: payjoin_proposal.psbt().clone() },
1✔
570
                session_context: SessionContext { reply_key, ..session_context },
1✔
571
            }),
1✔
572
        };
1✔
573
        run_session_history_test(test)
1✔
574
    }
1✔
575

576
    #[test]
577
    fn test_session_history_uri() -> Result<(), BoxError> {
1✔
578
        let session_context = SHARED_CONTEXT.clone();
1✔
579
        let events = vec![SessionEvent::Created(session_context.clone())];
1✔
580

581
        let uri = SessionHistory { events }.pj_uri();
1✔
582

583
        assert_ne!(uri.extras.pj_param.endpoint(), EXAMPLE_URL.clone());
1✔
584
        assert_eq!(uri.extras.output_substitution, OutputSubstitution::Disabled);
1✔
585

586
        Ok(())
1✔
587
    }
1✔
588

589
    #[test]
590
    fn test_skipped_session_extract_err_request() -> Result<(), BoxError> {
1✔
591
        let ohttp_relay = EXAMPLE_URL.clone();
1✔
592
        let mock_err = mock_err();
1✔
593

594
        let session_history = SessionHistory { events: vec![SessionEvent::MaybeInputsOwned()] };
1✔
595
        let err_req = session_history.extract_err_req(&ohttp_relay)?;
1✔
596
        assert!(err_req.is_none());
1✔
597

598
        let session_history = SessionHistory {
1✔
599
            events: vec![
1✔
600
                SessionEvent::MaybeInputsOwned(),
1✔
601
                SessionEvent::SessionInvalid(mock_err.0.clone(), Some(mock_err.1.clone())),
1✔
602
            ],
1✔
603
        };
1✔
604

605
        let err_req = session_history.extract_err_req(&ohttp_relay)?;
1✔
606
        assert!(err_req.is_none());
1✔
607

608
        let session_history = SessionHistory {
1✔
609
            events: vec![
1✔
610
                SessionEvent::Created(SHARED_CONTEXT.clone()),
1✔
611
                SessionEvent::MaybeInputsOwned(),
1✔
612
                SessionEvent::SessionInvalid(mock_err.0.clone(), Some(mock_err.1.clone())),
1✔
613
            ],
1✔
614
        };
1✔
615

616
        let err_req = session_history.extract_err_req(&ohttp_relay)?;
1✔
617
        assert!(err_req.is_none());
1✔
618
        Ok(())
1✔
619
    }
1✔
620

621
    #[test]
622
    fn test_session_extract_err_req_reply_key() -> Result<(), BoxError> {
1✔
623
        let proposal = original_from_test_vector();
1✔
624
        let ohttp_relay = EXAMPLE_URL.clone();
1✔
625
        let mock_err = mock_err();
1✔
626

627
        let session_history_one = SessionHistory {
1✔
628
            events: vec![
1✔
629
                SessionEvent::Created(SHARED_CONTEXT.clone()),
1✔
630
                SessionEvent::UncheckedOriginalPayload {
1✔
631
                    original: proposal.clone(),
1✔
632
                    reply_key: Some(crate::HpkeKeyPair::gen_keypair().1),
1✔
633
                },
1✔
634
                SessionEvent::SessionInvalid(mock_err.0.clone(), Some(mock_err.1.clone())),
1✔
635
            ],
1✔
636
        };
1✔
637

638
        let err_req_one = session_history_one.extract_err_req(&ohttp_relay)?;
1✔
639
        assert!(err_req_one.is_some());
1✔
640

641
        let session_history_two = SessionHistory {
1✔
642
            events: vec![
1✔
643
                SessionEvent::Created(SHARED_CONTEXT.clone()),
1✔
644
                SessionEvent::UncheckedOriginalPayload {
1✔
645
                    original: proposal.clone(),
1✔
646
                    reply_key: Some(crate::HpkeKeyPair::gen_keypair().1),
1✔
647
                },
1✔
648
                SessionEvent::SessionInvalid(mock_err.0, Some(mock_err.1)),
1✔
649
            ],
1✔
650
        };
1✔
651

652
        let err_req_two = session_history_two.extract_err_req(ohttp_relay)?;
1✔
653
        assert!(err_req_two.is_some());
1✔
654
        assert_ne!(
1✔
655
            session_history_one.session_context().reply_key,
1✔
656
            session_history_two.session_context().reply_key
1✔
657
        );
658

659
        Ok(())
1✔
660
    }
1✔
661
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc