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

payjoin / rust-payjoin / 18230715366

03 Oct 2025 06:34PM UTC coverage: 84.18% (-0.06%) from 84.237%
18230715366

Pull #1141

github

web-flow
Merge 1460d24bb into 5335134f3
Pull Request #1141: Return String instead of Url for endpoint accessors

97 of 99 new or added lines in 8 files covered. (97.98%)

4 existing lines in 3 files now uncovered.

8934 of 10613 relevant lines covered (84.18%)

466.0 hits per line

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

94.01
/payjoin/src/core/send/v2/session.rs
1
use super::{Sender, WithReplyKey};
2
use crate::error::{InternalReplayError, ReplayError};
3
use crate::persist::SessionPersister;
4
use crate::send::v2::SendSession;
5
use crate::uri::v2::PjParam;
6
use crate::ImplementationError;
7

8
pub fn replay_event_log<P>(
3✔
9
    persister: &P,
3✔
10
) -> Result<(SendSession, SessionHistory), ReplayError<SendSession, SessionEvent>>
3✔
11
where
3✔
12
    P: SessionPersister + Clone,
3✔
13
    P::SessionEvent: Into<SessionEvent> + Clone,
3✔
14
    P::SessionEvent: From<SessionEvent>,
3✔
15
{
16
    let mut logs = persister
3✔
17
        .load()
3✔
18
        .map_err(|e| InternalReplayError::PersistenceFailure(ImplementationError::new(e)))?;
3✔
19
    let first_event = logs.next().ok_or(InternalReplayError::NoEvents)?.into();
3✔
20
    let mut session_events = vec![first_event.clone()];
3✔
21
    let mut sender = match first_event {
3✔
22
        SessionEvent::Created(sender) => SendSession::new(*sender),
3✔
23
        _ => return Err(InternalReplayError::InvalidEvent(Box::new(first_event), None).into()),
×
24
    };
25

26
    for log in logs {
4✔
27
        let session_event = log.into();
1✔
28
        session_events.push(session_event.clone());
1✔
29
        match sender.clone().process_event(session_event) {
1✔
30
            Ok(next_sender) => sender = next_sender,
1✔
31
            Err(_e) => {
×
32
                persister.close().map_err(|e| {
×
33
                    InternalReplayError::PersistenceFailure(ImplementationError::new(e))
×
34
                })?;
×
35
                break;
×
36
            }
37
        }
38
    }
39

40
    let history = SessionHistory::new(session_events.clone());
3✔
41
    let pj_param = history.pj_param();
3✔
42
    if pj_param.expiration().elapsed() {
3✔
43
        return Err(InternalReplayError::Expired(pj_param.expiration()).into());
1✔
44
    }
2✔
45
    Ok((sender, history))
2✔
46
}
3✔
47

48
#[derive(Debug, Clone)]
49
pub struct SessionHistory {
50
    events: Vec<SessionEvent>,
51
}
52

53
impl SessionHistory {
54
    pub(crate) fn new(events: Vec<SessionEvent>) -> Self {
3✔
55
        debug_assert!(!events.is_empty(), "Session event log must contain at least one event");
3✔
56
        Self { events }
3✔
57
    }
3✔
58

59
    /// Fallback transaction from the session
60
    pub fn fallback_tx(&self) -> bitcoin::Transaction {
1✔
61
        self.events
1✔
62
            .iter()
1✔
63
            .find_map(|event| match event {
1✔
64
                SessionEvent::Created(sender) =>
1✔
65
                    Some(sender.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate()),
1✔
UNCOV
66
                _ => None,
×
67
            })
1✔
68
            .expect("Session event log must contain at least one event with fallback_tx")
1✔
69
    }
1✔
70

71
    pub fn pj_param(&self) -> &PjParam {
5✔
72
        self.events
5✔
73
            .iter()
5✔
74
            .find_map(|event| match event {
5✔
75
                SessionEvent::Created(sender) => Some(&sender.pj_param),
5✔
76
                _ => None,
×
77
            })
5✔
78
            .expect("Session event log must contain at least one event with pj_param")
5✔
79
    }
5✔
80

81
    pub fn status(&self) -> SessionStatus {
1✔
82
        if self.pj_param().expiration().elapsed() {
1✔
83
            return SessionStatus::Expired;
×
84
        }
1✔
85

86
        match self.events.last() {
1✔
87
            Some(SessionEvent::Closed(outcome)) => match outcome {
1✔
88
                SessionOutcome::Success => SessionStatus::Completed,
1✔
89
                SessionOutcome::Failure | SessionOutcome::Cancel => SessionStatus::Failed,
×
90
            },
91
            _ => SessionStatus::Active,
×
92
        }
93
    }
1✔
94
}
95

96
/// Represents the status of a session that can be inferred from the information in the session
97
/// event log.
98
#[derive(Debug, Clone, PartialEq, Eq)]
99
pub enum SessionStatus {
100
    Expired,
101
    Active,
102
    Failed,
103
    Completed,
104
}
105

106
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107
pub enum SessionEvent {
108
    /// Sender was created with session data
109
    Created(Box<Sender<WithReplyKey>>),
110
    /// Sender POSTed the Original PSBT and is waiting to receive a Proposal PSBT
111
    PostedOriginalPsbt(),
112
    /// Sender received a Proposal PSBT
113
    ReceivedProposalPsbt(bitcoin::Psbt),
114
    /// Closed successful or failed session
115
    Closed(SessionOutcome),
116
}
117

118
/// Represents all possible outcomes for a closed Payjoin session
119
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
120
pub enum SessionOutcome {
121
    /// Successful payjoin
122
    Success,
123
    /// Payjoin failed to complete due to a counterparty deviation from the protocol
124
    Failure,
125
    /// Payjoin was cancelled by the user
126
    Cancel,
127
}
128

129
#[cfg(test)]
130
mod tests {
131
    use bitcoin::{FeeRate, ScriptBuf};
132
    use payjoin_test_utils::{KEM, KEY_ID, PARSED_ORIGINAL_PSBT, SYMMETRIC};
133
    use url::Url;
134

135
    use super::*;
136
    use crate::output_substitution::OutputSubstitution;
137
    use crate::persist::test_utils::InMemoryTestPersister;
138
    use crate::persist::NoopSessionPersister;
139
    use crate::send::v2::{Sender, SenderBuilder};
140
    use crate::send::PsbtContext;
141
    use crate::time::Time;
142
    use crate::{HpkeKeyPair, Uri, UriExt};
143

144
    /// Expired V2 Payjoin URI without Amount inspired by BIP 77 test vector
145
    const PJ_URI: &str = "bitcoin:2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7?pjos=0&pj=HTTPS://PAYJO.IN/TXJCGKTKXLUUZ%23EX1WKV8CEC-OH1QYPM59NK2LXXS4890SUAXXYT25Z2VAPHP0X7YEYCJXGWAG6UG9ZU6NQ-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV";
146

147
    #[test]
148
    fn test_sender_session_event_serialization_roundtrip() {
1✔
149
        let keypair = HpkeKeyPair::gen_keypair();
1✔
150
        let id = crate::uri::ShortId::try_from(&b"12345670"[..]).expect("valid short id");
1✔
151
        let endpoint = url::Url::parse("http://localhost:1234").expect("valid url");
1✔
152
        let expiration =
1✔
153
            Time::from_now(std::time::Duration::from_secs(60)).expect("expiration should be valid");
1✔
154
        let pj_param = crate::uri::v2::PjParam::new(
1✔
155
            endpoint,
1✔
156
            id,
1✔
157
            expiration,
1✔
158
            crate::OhttpKeys(
1✔
159
                ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
1✔
160
            ),
1✔
161
            HpkeKeyPair::gen_keypair().1,
1✔
162
        );
163
        let sender_with_reply_key = Sender {
1✔
164
            state: WithReplyKey,
1✔
165
            pj_param: pj_param.clone(),
1✔
166
            psbt_ctx: PsbtContext {
1✔
167
                original_psbt: PARSED_ORIGINAL_PSBT.clone(),
1✔
168
                output_substitution: OutputSubstitution::Enabled,
1✔
169
                fee_contribution: None,
1✔
170
                min_fee_rate: FeeRate::ZERO,
1✔
171
                payee: ScriptBuf::from(vec![0x00]),
1✔
172
            },
1✔
173
            reply_key: keypair.0.clone(),
1✔
174
        };
1✔
175

176
        let test_cases = vec![
1✔
177
            SessionEvent::Created(Box::new(sender_with_reply_key.clone())),
1✔
178
            SessionEvent::PostedOriginalPsbt(),
1✔
179
            SessionEvent::ReceivedProposalPsbt(PARSED_ORIGINAL_PSBT.clone()),
1✔
180
            SessionEvent::Closed(SessionOutcome::Success),
1✔
181
            SessionEvent::Closed(SessionOutcome::Failure),
1✔
182
            SessionEvent::Closed(SessionOutcome::Cancel),
1✔
183
        ];
184

185
        for event in test_cases {
7✔
186
            let serialized = serde_json::to_string(&event).expect("Should serialize");
6✔
187
            let deserialized: SessionEvent =
6✔
188
                serde_json::from_str(&serialized).expect("Should deserialize");
6✔
189
            assert_eq!(event, deserialized);
6✔
190
        }
191
    }
1✔
192

193
    struct SessionHistoryExpectedOutcome {
194
        fallback_tx: bitcoin::Transaction,
195
        pj_param: PjParam,
196
        expected_status: SessionStatus,
197
    }
198

199
    struct SessionHistoryTest {
200
        events: Vec<SessionEvent>,
201
        expected_session_history: SessionHistoryExpectedOutcome,
202
        expected_sender_state: SendSession,
203
        expected_error: Option<String>,
204
    }
205

206
    fn run_session_history_test(test: SessionHistoryTest) {
2✔
207
        let persister = InMemoryTestPersister::<SessionEvent>::default();
2✔
208
        for event in test.events {
4✔
209
            persister.save_event(event).expect("In memory persister shouldn't fail");
2✔
210
        }
2✔
211

212
        let session_result = replay_event_log(&persister);
2✔
213

214
        match session_result {
2✔
215
            Ok((sender_state, session_history)) => {
1✔
216
                assert!(test.expected_error.is_none(), "Expected an error but got Ok");
1✔
217
                assert_eq!(sender_state, test.expected_sender_state);
1✔
218
                assert_eq!(
1✔
219
                    session_history.fallback_tx(),
1✔
220
                    test.expected_session_history.fallback_tx
221
                );
222
                assert_eq!(session_history.pj_param(), &test.expected_session_history.pj_param);
1✔
223
                assert_eq!(SessionStatus::Active, test.expected_session_history.expected_status);
1✔
224
            }
225
            Err(e) => {
1✔
226
                let err_str = e.to_string();
1✔
227
                if let Some(expected) = &test.expected_error {
1✔
228
                    assert!(
1✔
229
                        err_str.contains(expected),
1✔
230
                        "Expected error containing '{expected}', got '{err_str}'"
×
231
                    );
232
                } else {
233
                    panic!("Unexpected error: {err_str}");
×
234
                }
235
                assert_eq!(
1✔
236
                    SendSession::Closed(SessionOutcome::Failure),
237
                    test.expected_sender_state
238
                );
239
                assert_eq!(test.expected_session_history.expected_status, SessionStatus::Expired);
1✔
240
            }
241
        };
242
    }
2✔
243

244
    #[test]
245
    fn test_sender_session_history_with_expired_session() {
1✔
246
        let psbt = PARSED_ORIGINAL_PSBT.clone();
1✔
247
        let sender = SenderBuilder::new(
1✔
248
            psbt.clone(),
1✔
249
            Uri::try_from(PJ_URI)
1✔
250
                .expect("Valid uri")
1✔
251
                .assume_checked()
1✔
252
                .check_pj_supported()
1✔
253
                .expect("Payjoin to be supported"),
1✔
254
        )
255
        .build_recommended(FeeRate::BROADCAST_MIN)
1✔
256
        .unwrap()
1✔
257
        .save(&NoopSessionPersister::default())
1✔
258
        .unwrap();
1✔
259
        let test = SessionHistoryTest {
1✔
260
            events: vec![SessionEvent::Created(Box::new(sender.clone()))],
1✔
261
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
262
                fallback_tx: sender.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate(),
1✔
263
                pj_param: sender.pj_param.clone(),
1✔
264
                expected_status: SessionStatus::Expired,
1✔
265
            },
1✔
266
            expected_sender_state: SendSession::Closed(SessionOutcome::Failure),
1✔
267
            expected_error: Some("Session expired at".to_string()),
1✔
268
        };
1✔
269
        run_session_history_test(test);
1✔
270
    }
1✔
271

272
    #[test]
273
    fn test_sender_session_history_with_reply_key_event() {
1✔
274
        let psbt = PARSED_ORIGINAL_PSBT.clone();
1✔
275
        let mut sender = SenderBuilder::new(
1✔
276
            psbt.clone(),
1✔
277
            Uri::try_from(PJ_URI)
1✔
278
                .expect("Valid uri")
1✔
279
                .assume_checked()
1✔
280
                .check_pj_supported()
1✔
281
                .expect("Payjoin to be supported"),
1✔
282
        )
283
        .build_recommended(FeeRate::BROADCAST_MIN)
1✔
284
        .unwrap()
1✔
285
        .save(&NoopSessionPersister::default())
1✔
286
        .unwrap();
1✔
287
        sender.pj_param.expiration = Time::from_now(std::time::Duration::from_secs(60)).unwrap();
1✔
288
        let test = SessionHistoryTest {
1✔
289
            events: vec![SessionEvent::Created(Box::new(sender.clone()))],
1✔
290
            expected_session_history: SessionHistoryExpectedOutcome {
1✔
291
                fallback_tx: sender.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate(),
1✔
292
                pj_param: sender.pj_param.clone(),
1✔
293
                expected_status: SessionStatus::Active,
1✔
294
            },
1✔
295
            expected_sender_state: SendSession::WithReplyKey(sender),
1✔
296
            expected_error: None,
1✔
297
        };
1✔
298
        run_session_history_test(test);
1✔
299
    }
1✔
300

301
    #[test]
302
    fn status_is_completed_for_closed_success() {
1✔
303
        let psbt = PARSED_ORIGINAL_PSBT.clone();
1✔
304
        let sender = SenderBuilder::new(
1✔
305
            psbt.clone(),
1✔
306
            Uri::try_from(PJ_URI)
1✔
307
                .expect("Valid uri")
1✔
308
                .assume_checked()
1✔
309
                .check_pj_supported()
1✔
310
                .expect("Payjoin to be supported"),
1✔
311
        )
312
        .build_recommended(FeeRate::BROADCAST_MIN)
1✔
313
        .unwrap()
1✔
314
        .save(&NoopSessionPersister::default())
1✔
315
        .unwrap();
1✔
316

317
        let reply_key = HpkeKeyPair::gen_keypair();
1✔
318
        let endpoint = Url::parse(&sender.endpoint()).expect("Could not parse url");
1✔
319
        let id = crate::uri::ShortId::try_from(&b"12345670"[..]).expect("valid short id");
1✔
320
        let expiration =
1✔
321
            Time::from_now(std::time::Duration::from_secs(60)).expect("Valid expiration");
1✔
322
        let pj_param = crate::uri::v2::PjParam::new(
1✔
323
            endpoint,
1✔
324
            id,
1✔
325
            expiration,
1✔
326
            crate::OhttpKeys(
1✔
327
                ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
1✔
328
            ),
1✔
329
            HpkeKeyPair::gen_keypair().1,
1✔
330
        );
331

332
        let with_reply_key = Sender {
1✔
333
            state: WithReplyKey,
1✔
334
            pj_param: pj_param.clone(),
1✔
335
            psbt_ctx: sender.psbt_ctx.clone(),
1✔
336
            reply_key: reply_key.0,
1✔
337
        };
1✔
338

339
        let events = vec![
1✔
340
            SessionEvent::Created(Box::new(with_reply_key)),
1✔
341
            SessionEvent::Closed(SessionOutcome::Success),
1✔
342
        ];
343

344
        let session = SessionHistory { events };
1✔
345
        assert_eq!(session.status(), SessionStatus::Completed);
1✔
346
    }
1✔
347
}
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

© 2025 Coveralls, Inc