Skip to main content

bsv_messagebox_client/
peer_pay.rs

1use std::sync::Arc;
2
3use bsv::auth::utils::create_nonce;
4use bsv::primitives::public_key::PublicKey;
5use bsv::primitives::utils::from_hex;
6use bsv::remittance::types::PeerMessage;
7use bsv::script::templates::{P2PKH, ScriptTemplateLock};
8use bsv::wallet::interfaces::{
9    CreateActionArgs, CreateActionOptions, CreateActionOutput, GetPublicKeyArgs,
10    InternalizeActionArgs, InternalizeOutput, Payment, SignActionArgs, WalletInterface,
11};
12use bsv::wallet::types::{BooleanDefaultTrue, Counterparty, CounterpartyType, Protocol};
13
14use crate::client::MessageBoxClient;
15use crate::error::MessageBoxError;
16use crate::types::{IncomingPayment, PaymentCustomInstructions, PaymentToken};
17
18impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
19    /// Create a PeerPay payment token for `recipient` worth `amount` satoshis.
20    ///
21    /// Steps:
22    /// 1. Generate two nonces (prefix, suffix) via `create_nonce`.
23    /// 2. Derive a P2PKH locking key via `get_public_key` with protocol `[2, "3241645161d8"]`.
24    /// 3. Build a P2PKH locking script from the derived key hash.
25    /// 4. Call `create_action` with `randomize_outputs: false` so output_index=0 is stable.
26    /// 5. Return `PaymentToken` with `output_index: None` — the TS convention is to set it
27    ///    only at accept time (defaulted to 0 via unwrap_or(0)).
28    pub async fn create_payment_token(
29        &self,
30        recipient: &str,
31        amount: u64,
32    ) -> Result<PaymentToken, MessageBoxError> {
33        // Step 1: two nonces for key derivation
34        let prefix = create_nonce(self.wallet())
35            .await
36            .map_err(|e| MessageBoxError::Auth(format!("create_nonce prefix: {e}")))?;
37        let suffix = create_nonce(self.wallet())
38            .await
39            .map_err(|e| MessageBoxError::Auth(format!("create_nonce suffix: {e}")))?;
40
41        // Step 2: derive a per-payment public key for the recipient
42        let pk_result = self
43            .wallet()
44            .get_public_key(
45                GetPublicKeyArgs {
46                    identity_key: false,
47                    protocol_id: Some(Protocol {
48                        security_level: 2,
49                        protocol: "3241645161d8".to_string(),
50                    }),
51                    key_id: Some(format!("{prefix} {suffix}")),
52                    counterparty: Some(Counterparty {
53                        counterparty_type: CounterpartyType::Other,
54                        public_key: Some(
55                            PublicKey::from_string(recipient)
56                                .map_err(|e| MessageBoxError::Wallet(e.to_string()))?,
57                        ),
58                    }),
59                    privileged: false,
60                    privileged_reason: None,
61                    for_self: None,
62                    seek_permission: None,
63                },
64                self.originator(),
65            )
66            .await
67            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
68
69        // Step 3: build P2PKH locking script from derived key
70        let hash_vec = pk_result.public_key.to_hash();
71        let mut hash = [0u8; 20];
72        hash.copy_from_slice(&hash_vec);
73        let lock_script = P2PKH::from_public_key_hash(hash)
74            .lock()
75            .map_err(|e| MessageBoxError::Wallet(format!("P2PKH lock error: {e}")))?;
76        // Convert to bytes via hex — avoids adding a `hex` crate dependency
77        let locking_script_bytes = from_hex(&lock_script.to_hex())
78            .map_err(|e| MessageBoxError::Wallet(format!("hex decode locking script: {e}")))?;
79
80        // Build custom instructions — payee matches TS wire format
81        let custom_instructions = PaymentCustomInstructions {
82            derivation_prefix: prefix.clone(),
83            derivation_suffix: suffix.clone(),
84            payee: Some(recipient.to_string()),
85        };
86
87        // Step 4: create the transaction
88        // CRITICAL: randomize_outputs must be false so output_index=0 is always correct
89        let create_result = self
90            .wallet()
91            .create_action(
92                CreateActionArgs {
93                    description: "PeerPay payment".to_string(),
94                    input_beef: None,
95                    inputs: vec![],
96                    outputs: vec![CreateActionOutput {
97                        locking_script: Some(locking_script_bytes),
98                        satoshis: amount,
99                        output_description: "Payment for PeerPay transaction".to_string(),
100                        basket: None,
101                        custom_instructions: Some(
102                            serde_json::to_string(&custom_instructions)
103                                .map_err(MessageBoxError::Json)?,
104                        ),
105                        tags: vec![],
106                    }],
107                    lock_time: None,
108                    version: None,
109                    labels: vec!["peerpay".to_string()],
110                    options: Some(CreateActionOptions {
111                        randomize_outputs: BooleanDefaultTrue(Some(false)),
112                        sign_and_process: BooleanDefaultTrue(None),
113                        accept_delayed_broadcast: BooleanDefaultTrue(None),
114                        ..Default::default()
115                    }),
116                    reference: None,
117                },
118                self.originator(),
119            )
120            .await
121            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
122
123        // Step 5: handle two-step flow — if wallet returns signable_transaction,
124        // call sign_action to complete it (BRC-100 pattern for non-admin originators)
125        let tx = if let Some(tx_bytes) = create_result.tx {
126            tx_bytes
127        } else if let Some(signable) = create_result.signable_transaction {
128            let sign_result = self
129                .wallet()
130                .sign_action(
131                    SignActionArgs {
132                        reference: signable.reference,
133                        spends: std::collections::HashMap::new(),
134                        options: None,
135                    },
136                    self.originator(),
137                )
138                .await
139                .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
140            sign_result
141                .tx
142                .ok_or_else(|| MessageBoxError::Wallet("sign_action returned no tx".to_string()))?
143        } else {
144            return Err(MessageBoxError::Wallet(
145                "create_action returned neither tx nor signable_transaction".to_string(),
146            ));
147        };
148
149        // NOTE: outputIndex is NOT set at creation — matches TS behavior.
150        // accept_payment uses unwrap_or(0) to default to 0.
151        Ok(PaymentToken {
152            custom_instructions,
153            transaction: tx,
154            amount,
155            output_index: None,
156        })
157    }
158
159    /// Send a payment to `recipient` by creating a token and posting it to their payment_inbox.
160    ///
161    /// Returns the message ID assigned by the server (or the HMAC-derived ID).
162    pub async fn send_payment(
163        &self,
164        recipient: &str,
165        amount: u64,
166    ) -> Result<String, MessageBoxError> {
167        let token = self.create_payment_token(recipient, amount).await?;
168        let token_json = serde_json::to_string(&token)?;
169        self.send_message(recipient, "payment_inbox", &token_json, false, false, None, None).await
170    }
171
172    /// Send a payment to `recipient` over WebSocket with HTTP fallback.
173    ///
174    /// Creates a payment token via `create_payment_token`, serializes it as JSON,
175    /// and sends via `send_live_message` (which handles WS timeout + HTTP fallback).
176    /// Thin wrapper — matches TS `PeerPayClient.sendLivePayment`.
177    ///
178    /// Returns the message ID regardless of whether delivery was live or persisted.
179    /// Callers that need to distinguish live vs persisted should call
180    /// `send_live_message` directly and inspect `DeliveryMode`.
181    pub async fn send_live_payment(
182        &self,
183        recipient: &str,
184        amount: u64,
185    ) -> Result<String, MessageBoxError> {
186        let token = self.create_payment_token(recipient, amount).await?;
187        let token_json = serde_json::to_string(&token)?;
188        let delivery = self.send_live_message(recipient, "payment_inbox", &token_json, false, false, None, None).await?;
189        Ok(delivery.message_id().to_string())
190    }
191
192    /// Subscribe to live payment notifications on the payment_inbox.
193    ///
194    /// Wraps `listen_for_live_messages` with a callback that parses the message
195    /// body as a `PaymentToken` and constructs an `IncomingPayment`. Messages
196    /// whose bodies are not valid payment tokens are silently ignored (matches
197    /// TS safeParse behavior).
198    pub async fn listen_for_live_payments(
199        &self,
200        on_payment: Arc<dyn Fn(IncomingPayment) + Send + Sync>,
201    ) -> Result<(), MessageBoxError> {
202        let wrapper: Arc<dyn Fn(PeerMessage) + Send + Sync> = Arc::new(move |msg: PeerMessage| {
203            if let Ok(token) = serde_json::from_str::<PaymentToken>(&msg.body) {
204                let incoming = IncomingPayment {
205                    token,
206                    sender: msg.sender,
207                    message_id: msg.message_id,
208                };
209                on_payment(incoming);
210            }
211            // Silently skip messages that aren't valid payment tokens
212        });
213
214        self.listen_for_live_messages("payment_inbox", wrapper, None).await
215    }
216
217    /// Internalize a received payment and acknowledge the message.
218    ///
219    /// Base64-decodes derivation_prefix/suffix back to raw bytes so the SDK's
220    /// bytes_as_base64 serde re-encodes them to the original base64 strings
221    /// that BSV Desktop expects.
222    pub async fn accept_payment(&self, payment: &IncomingPayment) -> Result<(), MessageBoxError> {
223        use base64::{engine::general_purpose::STANDARD, Engine};
224
225        let sender_pk = PublicKey::from_string(&payment.sender)
226            .map_err(|e| MessageBoxError::Wallet(format!("invalid sender key: {e}")))?;
227
228        let prefix_bytes = STANDARD
229            .decode(&payment.token.custom_instructions.derivation_prefix)
230            .map_err(|e| MessageBoxError::Wallet(format!("base64 decode prefix: {e}")))?;
231        let suffix_bytes = STANDARD
232            .decode(&payment.token.custom_instructions.derivation_suffix)
233            .map_err(|e| MessageBoxError::Wallet(format!("base64 decode suffix: {e}")))?;
234
235        self.wallet()
236            .internalize_action(
237                InternalizeActionArgs {
238                    tx: payment.token.transaction.clone(),
239                    description: "PeerPay Payment".to_string(),
240                    labels: vec!["peerpay".to_string()],
241                    seek_permission: BooleanDefaultTrue(Some(true)),
242                    outputs: vec![InternalizeOutput::WalletPayment {
243                        output_index: payment.token.output_index.unwrap_or(0),
244                        payment: Payment {
245                            derivation_prefix: prefix_bytes,
246                            derivation_suffix: suffix_bytes,
247                            sender_identity_key: sender_pk,
248                        },
249                    }],
250                },
251                self.originator(),
252            )
253            .await
254            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
255
256        self.acknowledge_message(vec![payment.message_id.clone()], None).await?;
257        Ok(())
258    }
259
260    /// Reject a received payment.
261    ///
262    /// - If `amount < 2000`: only acknowledges (refund after fee would be ≤ 0).
263    /// - If `amount >= 2000`: accepts (internalizes), sends a refund of `amount - 1000`,
264    ///   then double-acknowledges (intentional TS parity — server is idempotent).
265    ///
266    /// TS parity: 401 auth errors are silently swallowed (logged but not propagated).
267    /// All other errors propagate normally.
268    pub async fn reject_payment(&self, payment: &IncomingPayment) -> Result<(), MessageBoxError> {
269        if payment.token.amount < 2000 {
270            return self.acknowledge_message(vec![payment.message_id.clone()], None).await;
271        }
272
273        self.accept_payment(payment).await?;
274
275        if let Err(e) = self.send_payment(&payment.sender, payment.token.amount - 1000).await {
276            if Self::is_401_error(&e) { return Ok(()); }
277            return Err(e);
278        }
279
280        if let Err(e) = self.acknowledge_message(vec![payment.message_id.clone()], None).await {
281            if Self::is_401_error(&e) { return Ok(()); }
282            return Err(e);
283        }
284
285        Ok(())
286    }
287
288    /// Check if an error is a 401 auth error (TS swallows these in reject_payment).
289    fn is_401_error(e: &MessageBoxError) -> bool {
290        matches!(e, MessageBoxError::Http(401, _))
291            || matches!(e, MessageBoxError::Auth(msg) if msg.contains("401"))
292    }
293
294    /// List all incoming payments from the payment_inbox.
295    ///
296    /// Uses the full multi-host `list_messages` path (matching TS `listIncomingPayments`
297    /// which calls `this.listMessages`), so payments on all advertised hosts are returned.
298    /// Silently skips messages whose bodies are not valid JSON payment tokens
299    /// (mirrors TS `safeParse` behavior).
300    pub async fn list_incoming_payments(
301        &self,
302    ) -> Result<Vec<IncomingPayment>, MessageBoxError> {
303        let messages = self.list_messages("payment_inbox", false, None).await?;
304
305        let payments = messages
306            .into_iter()
307            .filter_map(|msg| {
308                serde_json::from_str::<PaymentToken>(&msg.body)
309                    .ok()
310                    .map(|token| IncomingPayment {
311                        token,
312                        sender: msg.sender,
313                        message_id: msg.message_id,
314                    })
315            })
316            .collect();
317
318        Ok(payments)
319    }
320
321    /// Acknowledge a notification message, internalizing any embedded delivery-fee payment.
322    ///
323    /// Matches TS `acknowledgeNotification` exactly:
324    /// 1. Acknowledges the message FIRST (removes from server queue).
325    /// 2. Parses body for a `{ message, payment }` delivery-fee wrapper (NOT a PeerPay token).
326    /// 3. If a delivery-fee payment exists with `wallet payment` outputs, internalizes it.
327    /// 4. Returns true if payment was internalized, false otherwise.
328    pub async fn acknowledge_notification(
329        &self,
330        message: &PeerMessage,
331    ) -> Result<bool, MessageBoxError> {
332        // Step 1: Acknowledge first — matches TS line 1702
333        self.acknowledge_message(vec![message.message_id.clone()], None).await?;
334
335        // Step 2: Parse body for delivery-fee wrapper { message, payment }
336        let parsed = serde_json::from_str::<crate::http_ops::WrappedMessageBody>(&message.body);
337        let payment_data = parsed.ok().and_then(|w| w.payment);
338
339        // Step 3: Internalize delivery-fee payment if present
340        if let Some(payment) = payment_data {
341            if let (Some(tx_bytes), Some(outputs)) = (&payment.tx, &payment.outputs) {
342                let description = payment
343                    .description
344                    .clone()
345                    .unwrap_or_else(|| "MessageBox recipient payment".to_string());
346
347                // Filter to wallet payment outputs (TS: output.protocol === 'wallet payment')
348                let internalize_outputs: Vec<InternalizeOutput> = outputs
349                    .iter()
350                    .filter_map(|o| {
351                        let sender_pk = o.sender_identity_key
352                            .as_deref()
353                            .and_then(|k| bsv::primitives::public_key::PublicKey::from_string(k).ok())?;
354                        Some(InternalizeOutput::WalletPayment {
355                            output_index: o.output_index.unwrap_or(0),
356                            payment: Payment {
357                                derivation_prefix: o.derivation_prefix.clone().unwrap_or_default(),
358                                derivation_suffix: o.derivation_suffix.clone().unwrap_or_default(),
359                                sender_identity_key: sender_pk,
360                            },
361                        })
362                    })
363                    .collect();
364
365                if internalize_outputs.is_empty() {
366                    return Ok(false);
367                }
368
369                let args = InternalizeActionArgs {
370                    tx: tx_bytes.clone(),
371                    description,
372                    labels: vec!["notification-payment".to_string()],
373                    seek_permission: bsv::wallet::types::BooleanDefaultTrue(Some(false)),
374                    outputs: internalize_outputs,
375                };
376
377                match self.wallet().internalize_action(args, self.originator()).await {
378                    Ok(_) => return Ok(true),
379                    Err(_) => return Ok(false),
380                }
381            }
382        }
383
384        Ok(false)
385    }
386}
387
388// ---------------------------------------------------------------------------
389// Tests
390// ---------------------------------------------------------------------------
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::types::{IncomingPayment, PaymentCustomInstructions, PaymentToken, ServerPeerMessage};
396    use bsv::primitives::private_key::PrivateKey;
397    use bsv::remittance::types::PeerMessage;
398    use bsv::wallet::error::WalletError;
399    use bsv::wallet::interfaces::*;
400    use bsv::wallet::proto_wallet::ProtoWallet;
401    use std::sync::Arc;
402
403    // Thin Arc wrapper so ProtoWallet satisfies W: Clone bound on MessageBoxClient
404    #[derive(Clone)]
405    struct ArcWallet(Arc<ProtoWallet>);
406
407    impl ArcWallet {
408        fn new() -> Self {
409            let key = PrivateKey::from_random().expect("random key");
410            ArcWallet(Arc::new(ProtoWallet::new(key)))
411        }
412
413        async fn identity_hex(&self) -> String {
414            self.get_public_key(
415                GetPublicKeyArgs {
416                    identity_key: true,
417                    protocol_id: None,
418                    key_id: None,
419                    counterparty: None,
420                    privileged: false,
421                    privileged_reason: None,
422                    for_self: None,
423                    seek_permission: None,
424                },
425                None,
426            )
427            .await
428            .expect("get_public_key")
429            .public_key
430            .to_der_hex()
431        }
432    }
433
434    #[async_trait::async_trait]
435    impl WalletInterface for ArcWallet {
436        async fn create_action(&self, args: CreateActionArgs, orig: Option<&str>) -> Result<CreateActionResult, WalletError> { self.0.create_action(args, orig).await }
437        async fn sign_action(&self, args: SignActionArgs, orig: Option<&str>) -> Result<SignActionResult, WalletError> { self.0.sign_action(args, orig).await }
438        async fn abort_action(&self, args: AbortActionArgs, orig: Option<&str>) -> Result<AbortActionResult, WalletError> { self.0.abort_action(args, orig).await }
439        async fn list_actions(&self, args: ListActionsArgs, orig: Option<&str>) -> Result<ListActionsResult, WalletError> { self.0.list_actions(args, orig).await }
440        async fn internalize_action(&self, args: InternalizeActionArgs, orig: Option<&str>) -> Result<InternalizeActionResult, WalletError> { self.0.internalize_action(args, orig).await }
441        async fn list_outputs(&self, args: ListOutputsArgs, orig: Option<&str>) -> Result<ListOutputsResult, WalletError> { self.0.list_outputs(args, orig).await }
442        async fn relinquish_output(&self, args: RelinquishOutputArgs, orig: Option<&str>) -> Result<RelinquishOutputResult, WalletError> { self.0.relinquish_output(args, orig).await }
443        async fn get_public_key(&self, args: GetPublicKeyArgs, orig: Option<&str>) -> Result<GetPublicKeyResult, WalletError> { self.0.get_public_key(args, orig).await }
444        async fn reveal_counterparty_key_linkage(&self, args: RevealCounterpartyKeyLinkageArgs, orig: Option<&str>) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> { self.0.reveal_counterparty_key_linkage(args, orig).await }
445        async fn reveal_specific_key_linkage(&self, args: RevealSpecificKeyLinkageArgs, orig: Option<&str>) -> Result<RevealSpecificKeyLinkageResult, WalletError> { self.0.reveal_specific_key_linkage(args, orig).await }
446        async fn encrypt(&self, args: EncryptArgs, orig: Option<&str>) -> Result<EncryptResult, WalletError> { self.0.encrypt(args, orig).await }
447        async fn decrypt(&self, args: DecryptArgs, orig: Option<&str>) -> Result<DecryptResult, WalletError> { self.0.decrypt(args, orig).await }
448        async fn create_hmac(&self, args: CreateHmacArgs, orig: Option<&str>) -> Result<CreateHmacResult, WalletError> { self.0.create_hmac(args, orig).await }
449        async fn verify_hmac(&self, args: VerifyHmacArgs, orig: Option<&str>) -> Result<VerifyHmacResult, WalletError> { self.0.verify_hmac(args, orig).await }
450        async fn create_signature(&self, args: CreateSignatureArgs, orig: Option<&str>) -> Result<CreateSignatureResult, WalletError> { self.0.create_signature(args, orig).await }
451        async fn verify_signature(&self, args: VerifySignatureArgs, orig: Option<&str>) -> Result<VerifySignatureResult, WalletError> { self.0.verify_signature(args, orig).await }
452        async fn acquire_certificate(&self, args: AcquireCertificateArgs, orig: Option<&str>) -> Result<Certificate, WalletError> { self.0.acquire_certificate(args, orig).await }
453        async fn list_certificates(&self, args: ListCertificatesArgs, orig: Option<&str>) -> Result<ListCertificatesResult, WalletError> { self.0.list_certificates(args, orig).await }
454        async fn prove_certificate(&self, args: ProveCertificateArgs, orig: Option<&str>) -> Result<ProveCertificateResult, WalletError> { self.0.prove_certificate(args, orig).await }
455        async fn relinquish_certificate(&self, args: RelinquishCertificateArgs, orig: Option<&str>) -> Result<RelinquishCertificateResult, WalletError> { self.0.relinquish_certificate(args, orig).await }
456        async fn discover_by_identity_key(&self, args: DiscoverByIdentityKeyArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_identity_key(args, orig).await }
457        async fn discover_by_attributes(&self, args: DiscoverByAttributesArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_attributes(args, orig).await }
458        async fn is_authenticated(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.is_authenticated(orig).await }
459        async fn wait_for_authentication(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.wait_for_authentication(orig).await }
460        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> { self.0.get_height(orig).await }
461        async fn get_header_for_height(&self, args: GetHeaderArgs, orig: Option<&str>) -> Result<GetHeaderResult, WalletError> { self.0.get_header_for_height(args, orig).await }
462        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> { self.0.get_network(orig).await }
463        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> { self.0.get_version(orig).await }
464    }
465
466    // -----------------------------------------------------------------------
467    // Task 1 tests: create_payment_token / send_payment
468    // -----------------------------------------------------------------------
469
470    /// Verify create_payment_token executes nonce + key derivation path.
471    ///
472    /// ProtoWallet's create_action may fail (no funded wallet), but the function
473    /// should at least get past the nonce and public key derivation step.
474    /// We test the compilation and the nonce/key derivation by checking
475    /// the error comes from create_action (not from nonce or key derivation).
476    #[tokio::test]
477    async fn create_payment_token_uses_create_nonce() {
478        let sender = ArcWallet::new();
479        let recipient = ArcWallet::new();
480        let recipient_pk = recipient.identity_hex().await;
481
482        let client = crate::client::MessageBoxClient::new(
483            "https://example.com".to_string(),
484            sender,
485            None,
486            bsv::services::overlay_tools::Network::Mainnet,
487        );
488
489        // create_payment_token will call create_nonce twice then get_public_key
490        // then create_action (which will fail with ProtoWallet as no network).
491        // We verify the failure is from create_action, not the nonce/key steps.
492        let result = client.create_payment_token(&recipient_pk, 1000).await;
493        // ProtoWallet will return an error at create_action — that's expected
494        // (no funded wallet). The important thing is it compiles and the
495        // nonce/key path executes without panicking.
496        match &result {
497            Err(e) => {
498                let msg = e.to_string();
499                // Should NOT fail at nonce or key derivation steps
500                assert!(!msg.contains("create_nonce prefix:"), "should not fail at nonce step");
501                // It will fail at create_action (wallet error) — acceptable
502                println!("create_payment_token expected error: {msg}");
503            }
504            Ok(_) => {
505                // If ProtoWallet succeeds somehow, that's also fine
506                println!("create_payment_token succeeded unexpectedly — wallet may have funds");
507            }
508        }
509    }
510
511    /// Verify send_payment compiles and delegates to create_payment_token then send_message.
512    /// (compile-check only — network will fail)
513    #[allow(dead_code)]
514    fn send_payment_compile_check(client: &crate::client::MessageBoxClient<ArcWallet>) {
515        // Drop the future without awaiting — compile-check only.
516        drop(client.send_payment("03abc", 1000));
517    }
518
519    // -----------------------------------------------------------------------
520    // Task 2 tests: accept_payment, reject_payment, list_incoming_payments
521    // -----------------------------------------------------------------------
522
523    /// reject_payment with amount < 2000 should only ack (not accept/refund).
524    ///
525    /// We verify this by checking the logic path — since we can't intercept
526    /// internal calls, we test via the threshold boundary value.
527    #[test]
528    fn reject_payment_threshold_below_2000() {
529        // Verify the threshold value in the compiled code.
530        // The implementation uses `amount >= 2000` to decide accept + refund path.
531        // We document the boundary via assert_eq on the threshold constant itself.
532        const THRESHOLD: u64 = 2000;
533        assert_eq!(THRESHOLD, 2000, "threshold must be 2000 sats");
534
535        // Verify refund amount calculation: amount - 1000
536        let amount: u64 = 3000;
537        let refund = amount - 1000;
538        assert_eq!(refund, 2000, "refund is amount minus 1000 sat fee");
539    }
540
541    /// list_incoming_payments silently skips messages with invalid bodies.
542    ///
543    /// Verifies the filter_map+serde_json safeParse behavior at the parsing level.
544    #[test]
545    fn list_incoming_payments_skips_unparseable() {
546        // Simulate what list_incoming_payments does internally:
547        // parse each msg.body as PaymentToken, skip failures
548        let messages = vec![
549            ServerPeerMessage {
550                message_id: "msg1".to_string(),
551                body: "not valid json".to_string(),
552                sender: "03sender1".to_string(),
553                created_at: "2024-01-01T00:00:00Z".to_string(),
554                updated_at: "2024-01-01T00:00:00Z".to_string(),
555                acknowledged: None,
556            },
557            ServerPeerMessage {
558                message_id: "msg2".to_string(),
559                body: r#"{"customInstructions":{"derivationPrefix":"p","derivationSuffix":"s"},"transaction":[1,2,3],"amount":1000}"#.to_string(),
560                sender: "03sender2".to_string(),
561                created_at: "2024-01-01T00:00:00Z".to_string(),
562                updated_at: "2024-01-01T00:00:00Z".to_string(),
563                acknowledged: None,
564            },
565            ServerPeerMessage {
566                message_id: "msg3".to_string(),
567                body: r#"{"foo":"bar"}"#.to_string(),
568                sender: "03sender3".to_string(),
569                created_at: "2024-01-01T00:00:00Z".to_string(),
570                updated_at: "2024-01-01T00:00:00Z".to_string(),
571                acknowledged: None,
572            },
573        ];
574
575        // Apply the same filter_map logic as list_incoming_payments
576        let payments: Vec<IncomingPayment> = messages
577            .into_iter()
578            .filter_map(|msg| {
579                serde_json::from_str::<PaymentToken>(&msg.body)
580                    .ok()
581                    .map(|token| IncomingPayment {
582                        token,
583                        sender: msg.sender,
584                        message_id: msg.message_id,
585                    })
586            })
587            .collect();
588
589        // Only msg2 has a valid PaymentToken body
590        assert_eq!(payments.len(), 1, "only valid payment token should be included");
591        assert_eq!(payments[0].message_id, "msg2");
592        assert_eq!(payments[0].sender, "03sender2");
593        assert_eq!(payments[0].token.amount, 1000);
594    }
595
596    /// accept_payment base64-decodes derivation prefix/suffix before passing to SDK.
597    ///
598    /// The SDK's bytes_as_base64 serde then re-encodes them to the original strings.
599    #[test]
600    fn accept_payment_base64_round_trip() {
601        use base64::{engine::general_purpose::STANDARD, Engine};
602
603        // create_nonce returns base64 strings like these
604        let prefix = "dGVzdC1wcmVmaXg="; // base64("test-prefix")
605        let suffix = "dGVzdC1zdWZmaXg="; // base64("test-suffix")
606
607        // accept_payment decodes to raw bytes
608        let prefix_bytes = STANDARD.decode(prefix).unwrap();
609        let suffix_bytes = STANDARD.decode(suffix).unwrap();
610        assert_eq!(prefix_bytes, b"test-prefix");
611        assert_eq!(suffix_bytes, b"test-suffix");
612
613        // SDK's bytes_as_base64 serde would re-encode back to the original strings
614        let re_encoded = STANDARD.encode(&prefix_bytes);
615        assert_eq!(re_encoded, prefix, "round-trip must produce original base64");
616    }
617
618    /// Construct IncomingPayment from a PaymentToken, verify all fields preserved.
619    #[test]
620    fn incoming_payment_round_trip() {
621        let token = PaymentToken {
622            custom_instructions: PaymentCustomInstructions {
623                derivation_prefix: "pfx".to_string(),
624                derivation_suffix: "sfx".to_string(),
625                payee: Some("03recipient".to_string()),
626            },
627            transaction: vec![0xde, 0xad, 0xbe, 0xef],
628            amount: 5000,
629            output_index: None,
630        };
631
632        let incoming = IncomingPayment {
633            token: token.clone(),
634            sender: "03sender_key".to_string(),
635            message_id: "abc123".to_string(),
636        };
637
638        assert_eq!(incoming.sender, "03sender_key");
639        assert_eq!(incoming.message_id, "abc123");
640        assert_eq!(incoming.token.amount, 5000);
641        assert_eq!(incoming.token.transaction, vec![0xde, 0xad, 0xbe, 0xef]);
642        assert_eq!(incoming.token.custom_instructions.derivation_prefix, "pfx");
643        assert_eq!(incoming.token.custom_instructions.derivation_suffix, "sfx");
644        assert_eq!(incoming.token.output_index, None);
645    }
646
647    // -----------------------------------------------------------------------
648    // Task 3 tests: send_live_payment / listen_for_live_payments
649    // -----------------------------------------------------------------------
650
651    /// Verify send_live_payment compiles — delegates to create_payment_token then send_live_message.
652    #[allow(dead_code)]
653    fn send_live_payment_compile_check(client: &crate::client::MessageBoxClient<ArcWallet>) {
654        let _fut = client.send_live_payment("03abc", 1000);
655    }
656
657    /// The listen_for_live_payments callback wrapper correctly parses a valid PaymentToken.
658    ///
659    /// Tests the parsing logic directly without a live WS connection.
660    #[test]
661    fn listen_for_live_payments_callback_parses_token() {
662        use std::sync::Mutex as StdMutex;
663
664        let received = Arc::new(StdMutex::new(Vec::<IncomingPayment>::new()));
665        let received_clone = received.clone();
666
667        // Construct a PeerMessage whose body is a valid PaymentToken JSON
668        let msg = PeerMessage {
669            message_id: "msg-live-1".to_string(),
670            sender: "03sender".to_string(),
671            recipient: "03recipient".to_string(),
672            message_box: "payment_inbox".to_string(),
673            body: r#"{"customInstructions":{"derivationPrefix":"p","derivationSuffix":"s"},"transaction":[1,2],"amount":500}"#.to_string(),
674        };
675
676        // Apply the same parsing logic as listen_for_live_payments wrapper
677        if let Ok(token) = serde_json::from_str::<PaymentToken>(&msg.body) {
678            let incoming = IncomingPayment {
679                token,
680                sender: msg.sender.clone(),
681                message_id: msg.message_id.clone(),
682            };
683            received_clone.lock().unwrap().push(incoming);
684        }
685
686        let payments = received.lock().unwrap();
687        assert_eq!(payments.len(), 1, "one valid payment should be parsed");
688        assert_eq!(payments[0].token.amount, 500);
689        assert_eq!(payments[0].sender, "03sender");
690        assert_eq!(payments[0].message_id, "msg-live-1");
691    }
692
693    /// The listen_for_live_payments callback silently skips non-payment messages.
694    ///
695    /// Verifies safeParse behavior — invalid body produces no IncomingPayment.
696    #[test]
697    fn listen_for_live_payments_callback_skips_non_payment() {
698        use std::sync::Mutex as StdMutex;
699
700        let received = Arc::new(StdMutex::new(Vec::<IncomingPayment>::new()));
701        let received_clone = received.clone();
702
703        // Body is not a valid PaymentToken
704        let msg = PeerMessage {
705            message_id: "msg-bad-1".to_string(),
706            sender: "03sender".to_string(),
707            recipient: "03recipient".to_string(),
708            message_box: "payment_inbox".to_string(),
709            body: r#"{"not":"a payment token"}"#.to_string(),
710        };
711
712        // Apply the same parsing logic as listen_for_live_payments wrapper
713        if let Ok(token) = serde_json::from_str::<PaymentToken>(&msg.body) {
714            let incoming = IncomingPayment {
715                token,
716                sender: msg.sender.clone(),
717                message_id: msg.message_id.clone(),
718            };
719            received_clone.lock().unwrap().push(incoming);
720        }
721
722        let payments = received.lock().unwrap();
723        assert_eq!(payments.len(), 0, "non-payment message must be silently skipped");
724    }
725}