Skip to content

Commit 808989b

Browse files
decofegrandizzy
andcommitted
feat: add WebSocket client transport
Client-side WS transport implementing the Transport trait: - WsClientMessage (client→server): credential, data variants - WsServerMessage (server→client): challenge, message, needVoucher, receipt, error variants (mirrors server::ws types) - WsTransport implements is_payment_required, get_challenge, set_credential for WS frames Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com>
1 parent 8a92119 commit 808989b

3 files changed

Lines changed: 236 additions & 1 deletion

File tree

‎src/client/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ mod error;
2121
mod provider;
2222
pub mod transport;
2323

24+
#[cfg(feature = "ws")]
25+
pub mod ws;
26+
2427
#[cfg(feature = "tempo")]
2528
pub mod tempo;
2629

‎src/client/ws.rs‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
//! WebSocket transport for client-side session payments.
2+
//!
3+
//! Provides a WebSocket transport that implements [`Transport`](super::transport::Transport)
4+
//! for bidirectional payment flows. The client can send vouchers inline over the
5+
//! same WebSocket connection (no separate HTTP request needed).
6+
//!
7+
//! # Message Protocol
8+
//!
9+
//! Uses the same JSON message format as [`server::ws`](crate::server::ws):
10+
//!
11+
//! **Client → Server:**
12+
//! - `{ "type": "credential", "credential": "Payment ..." }`
13+
//!
14+
//! **Server → Client:**
15+
//! - `{ "type": "challenge", "challenge": { ... } }`
16+
//! - `{ "type": "message", "data": "..." }`
17+
//! - `{ "type": "needVoucher", ... }`
18+
//! - `{ "type": "receipt", ... }`
19+
//!
20+
//! # Example
21+
//!
22+
//! ```ignore
23+
//! use mpp::client::ws::WsTransport;
24+
//! use mpp::client::transport::Transport;
25+
//!
26+
//! let transport = WsTransport;
27+
//! ```
28+
29+
use serde::{Deserialize, Serialize};
30+
31+
use crate::error::MppError;
32+
use crate::protocol::core::PaymentChallenge;
33+
34+
use super::transport::Transport;
35+
36+
/// Outgoing WebSocket message from client.
37+
#[derive(Debug, Clone, Serialize, Deserialize)]
38+
#[serde(tag = "type", rename_all = "camelCase")]
39+
pub enum WsClientMessage {
40+
/// Client sends a payment credential.
41+
Credential {
42+
/// The serialized credential string.
43+
credential: String,
44+
},
45+
/// Client sends application data.
46+
#[serde(rename = "message")]
47+
Data {
48+
/// Application payload.
49+
data: serde_json::Value,
50+
},
51+
}
52+
53+
impl WsClientMessage {
54+
/// Serialize this message to a JSON string for sending over WebSocket.
55+
pub fn to_text(&self) -> String {
56+
serde_json::to_string(self).expect("WsClientMessage serialization cannot fail")
57+
}
58+
}
59+
60+
/// Incoming WebSocket message from server.
61+
#[derive(Debug, Clone, Serialize, Deserialize)]
62+
#[serde(tag = "type", rename_all = "camelCase")]
63+
pub enum WsServerMessage {
64+
/// Server issues a payment challenge.
65+
Challenge {
66+
/// The payment challenge.
67+
challenge: serde_json::Value,
68+
/// Optional error context.
69+
#[serde(default)]
70+
error: Option<String>,
71+
},
72+
/// Server sends application data.
73+
#[serde(rename = "message")]
74+
Data {
75+
/// Application payload.
76+
data: String,
77+
},
78+
/// Server signals balance exhausted.
79+
NeedVoucher {
80+
/// Channel identifier.
81+
#[serde(rename = "channelId")]
82+
channel_id: String,
83+
/// Minimum cumulative amount required.
84+
#[serde(rename = "requiredCumulative")]
85+
required_cumulative: String,
86+
/// Current highest accepted cumulative amount.
87+
#[serde(rename = "acceptedCumulative")]
88+
accepted_cumulative: String,
89+
/// Current on-chain deposit.
90+
deposit: String,
91+
},
92+
/// Server sends final payment receipt.
93+
Receipt {
94+
/// The payment receipt.
95+
receipt: serde_json::Value,
96+
},
97+
/// Server sends an error.
98+
Error {
99+
/// Error message.
100+
error: String,
101+
},
102+
}
103+
104+
/// WebSocket transport for client-side payment handling.
105+
///
106+
/// Detects payment challenges from JSON WebSocket messages and attaches
107+
/// credentials as JSON messages (no HTTP headers involved).
108+
pub struct WsTransport;
109+
110+
/// Create a WebSocket transport instance.
111+
pub fn ws() -> WsTransport {
112+
WsTransport
113+
}
114+
115+
impl Transport for WsTransport {
116+
type Request = WsClientMessage;
117+
type Response = WsServerMessage;
118+
119+
fn name(&self) -> &str {
120+
"ws"
121+
}
122+
123+
fn is_payment_required(&self, response: &Self::Response) -> bool {
124+
matches!(response, WsServerMessage::Challenge { .. })
125+
}
126+
127+
fn get_challenge(&self, response: &Self::Response) -> Result<PaymentChallenge, MppError> {
128+
match response {
129+
WsServerMessage::Challenge { challenge, .. } => {
130+
let parsed: PaymentChallenge =
131+
serde_json::from_value(challenge.clone()).map_err(|e| {
132+
MppError::MalformedCredential(Some(format!(
133+
"failed to parse WS challenge: {e}"
134+
)))
135+
})?;
136+
Ok(parsed)
137+
}
138+
_ => Err(MppError::MissingHeader(
139+
"no challenge in WS message".to_string(),
140+
)),
141+
}
142+
}
143+
144+
fn set_credential(&self, _request: Self::Request, credential: &str) -> Self::Request {
145+
WsClientMessage::Credential {
146+
credential: credential.to_string(),
147+
}
148+
}
149+
}
150+
151+
#[cfg(test)]
152+
mod tests {
153+
use super::*;
154+
155+
#[test]
156+
fn test_ws_transport_name() {
157+
let transport = ws();
158+
assert_eq!(transport.name(), "ws");
159+
}
160+
161+
#[test]
162+
fn test_ws_client_message_credential_serde() {
163+
let msg = WsClientMessage::Credential {
164+
credential: "Payment id=\"abc\"".to_string(),
165+
};
166+
let json = msg.to_text();
167+
assert!(json.contains("\"type\":\"credential\""));
168+
169+
let parsed: WsClientMessage = serde_json::from_str(&json).unwrap();
170+
assert!(matches!(parsed, WsClientMessage::Credential { .. }));
171+
}
172+
173+
#[test]
174+
fn test_ws_client_message_data_serde() {
175+
let msg = WsClientMessage::Data {
176+
data: serde_json::json!({"prompt": "hello"}),
177+
};
178+
let json = msg.to_text();
179+
assert!(json.contains("\"type\":\"message\""));
180+
}
181+
182+
#[test]
183+
fn test_ws_server_message_challenge() {
184+
let json = r#"{"type":"challenge","challenge":{"id":"ch-1","realm":"test","method":"tempo","intent":"charge","request":"eyJ0ZXN0IjoidmFsdWUifQ"}}"#;
185+
let parsed: WsServerMessage = serde_json::from_str(json).unwrap();
186+
assert!(matches!(parsed, WsServerMessage::Challenge { .. }));
187+
}
188+
189+
#[test]
190+
fn test_ws_server_message_need_voucher() {
191+
let json = r#"{"type":"needVoucher","channelId":"0xabc","requiredCumulative":"2000","acceptedCumulative":"1000","deposit":"5000"}"#;
192+
let parsed: WsServerMessage = serde_json::from_str(json).unwrap();
193+
match parsed {
194+
WsServerMessage::NeedVoucher { channel_id, .. } => {
195+
assert_eq!(channel_id, "0xabc");
196+
}
197+
_ => panic!("expected NeedVoucher"),
198+
}
199+
}
200+
201+
#[test]
202+
fn test_is_payment_required() {
203+
let transport = ws();
204+
205+
let challenge = WsServerMessage::Challenge {
206+
challenge: serde_json::json!({}),
207+
error: None,
208+
};
209+
assert!(transport.is_payment_required(&challenge));
210+
211+
let data = WsServerMessage::Data {
212+
data: "hello".into(),
213+
};
214+
assert!(!transport.is_payment_required(&data));
215+
}
216+
217+
#[test]
218+
fn test_set_credential() {
219+
let transport = ws();
220+
let dummy = WsClientMessage::Data {
221+
data: serde_json::json!({}),
222+
};
223+
224+
let result = transport.set_credential(dummy, "Payment id=\"abc\"");
225+
match result {
226+
WsClientMessage::Credential { credential } => {
227+
assert_eq!(credential, "Payment id=\"abc\"");
228+
}
229+
_ => panic!("expected Credential message"),
230+
}
231+
}
232+
}

‎src/server/ws.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
use serde::{Deserialize, Serialize};
3434

3535
use crate::error::MppError;
36-
use crate::protocol::core::{PaymentChallenge, PaymentCredential, Receipt};
36+
use crate::protocol::core::PaymentCredential;
3737

3838
use super::transport::{ChallengeContext, ReceiptContext, Transport};
3939

0 commit comments

Comments
 (0)