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::{ScriptTemplateLock, P2PKH};
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: None,
96                    outputs: Some(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: None,
106                    }]),
107                    lock_time: None,
108                    version: None,
109                    labels: Some(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(
170            recipient,
171            "payment_inbox",
172            &token_json,
173            false,
174            false,
175            None,
176            None,
177        )
178        .await
179    }
180
181    /// Send a payment to `recipient` over WebSocket with HTTP fallback.
182    ///
183    /// Creates a payment token via `create_payment_token`, serializes it as JSON,
184    /// and sends via `send_live_message` (which handles WS timeout + HTTP fallback).
185    /// Thin wrapper — matches TS `PeerPayClient.sendLivePayment`.
186    ///
187    /// Returns the message ID regardless of whether delivery was live or persisted.
188    /// Callers that need to distinguish live vs persisted should call
189    /// `send_live_message` directly and inspect `DeliveryMode`.
190    pub async fn send_live_payment(
191        &self,
192        recipient: &str,
193        amount: u64,
194    ) -> Result<String, MessageBoxError> {
195        let token = self.create_payment_token(recipient, amount).await?;
196        let token_json = serde_json::to_string(&token)?;
197        let delivery = self
198            .send_live_message(
199                recipient,
200                "payment_inbox",
201                &token_json,
202                false,
203                false,
204                None,
205                None,
206            )
207            .await?;
208        Ok(delivery.message_id().to_string())
209    }
210
211    /// Subscribe to live payment notifications on the payment_inbox.
212    ///
213    /// Wraps `listen_for_live_messages` with a callback that parses the message
214    /// body as a `PaymentToken` and constructs an `IncomingPayment`. Messages
215    /// whose bodies are not valid payment tokens are silently ignored (matches
216    /// TS safeParse behavior).
217    pub async fn listen_for_live_payments(
218        &self,
219        on_payment: Arc<dyn Fn(IncomingPayment) + Send + Sync>,
220    ) -> Result<(), MessageBoxError> {
221        let wrapper: Arc<dyn Fn(PeerMessage) + Send + Sync> = Arc::new(move |msg: PeerMessage| {
222            if let Ok(token) = serde_json::from_str::<PaymentToken>(&msg.body) {
223                let incoming = IncomingPayment {
224                    token,
225                    sender: msg.sender,
226                    message_id: msg.message_id,
227                };
228                on_payment(incoming);
229            }
230            // Silently skip messages that aren't valid payment tokens
231        });
232
233        self.listen_for_live_messages("payment_inbox", wrapper, None)
234            .await
235    }
236
237    /// Internalize a received payment and acknowledge the message.
238    ///
239    /// Base64-decodes derivation_prefix/suffix back to raw bytes so the SDK's
240    /// bytes_as_base64 serde re-encodes them to the original base64 strings
241    /// that BSV Desktop expects.
242    pub async fn accept_payment(&self, payment: &IncomingPayment) -> Result<(), MessageBoxError> {
243        use base64::{engine::general_purpose::STANDARD, Engine};
244
245        let sender_pk = PublicKey::from_string(&payment.sender)
246            .map_err(|e| MessageBoxError::Wallet(format!("invalid sender key: {e}")))?;
247
248        let prefix_bytes = STANDARD
249            .decode(&payment.token.custom_instructions.derivation_prefix)
250            .map_err(|e| MessageBoxError::Wallet(format!("base64 decode prefix: {e}")))?;
251        let suffix_bytes = STANDARD
252            .decode(&payment.token.custom_instructions.derivation_suffix)
253            .map_err(|e| MessageBoxError::Wallet(format!("base64 decode suffix: {e}")))?;
254
255        self.wallet()
256            .internalize_action(
257                InternalizeActionArgs {
258                    tx: payment.token.transaction.clone(),
259                    description: "PeerPay Payment".to_string(),
260                    labels: Some(vec!["peerpay".to_string()]),
261                    seek_permission: BooleanDefaultTrue(Some(true)),
262                    outputs: vec![InternalizeOutput::WalletPayment {
263                        output_index: payment.token.output_index.unwrap_or(0),
264                        payment: Payment {
265                            derivation_prefix: prefix_bytes,
266                            derivation_suffix: suffix_bytes,
267                            sender_identity_key: sender_pk,
268                        },
269                    }],
270                },
271                self.originator(),
272            )
273            .await
274            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
275
276        self.acknowledge_message(vec![payment.message_id.clone()], None)
277            .await?;
278        Ok(())
279    }
280
281    /// Reject a received payment.
282    ///
283    /// - If `amount < 2000`: only acknowledges (refund after fee would be ≤ 0).
284    /// - If `amount >= 2000`: accepts (internalizes), sends a refund of `amount - 1000`,
285    ///   then double-acknowledges (intentional TS parity — server is idempotent).
286    ///
287    /// TS parity: 401 auth errors are silently swallowed (logged but not propagated).
288    /// All other errors propagate normally.
289    pub async fn reject_payment(&self, payment: &IncomingPayment) -> Result<(), MessageBoxError> {
290        if payment.token.amount < 2000 {
291            return self
292                .acknowledge_message(vec![payment.message_id.clone()], None)
293                .await;
294        }
295
296        self.accept_payment(payment).await?;
297
298        if let Err(e) = self
299            .send_payment(&payment.sender, payment.token.amount - 1000)
300            .await
301        {
302            if Self::is_401_error(&e) {
303                return Ok(());
304            }
305            return Err(e);
306        }
307
308        if let Err(e) = self
309            .acknowledge_message(vec![payment.message_id.clone()], None)
310            .await
311        {
312            if Self::is_401_error(&e) {
313                return Ok(());
314            }
315            return Err(e);
316        }
317
318        Ok(())
319    }
320
321    /// Check if an error is a 401 auth error (TS swallows these in reject_payment).
322    fn is_401_error(e: &MessageBoxError) -> bool {
323        matches!(e, MessageBoxError::Http(401, _))
324            || matches!(e, MessageBoxError::Auth(msg) if msg.contains("401"))
325    }
326
327    /// List all incoming payments from the payment_inbox.
328    ///
329    /// Uses the full multi-host `list_messages` path (matching TS `listIncomingPayments`
330    /// which calls `this.listMessages`), so payments on all advertised hosts are returned.
331    /// Silently skips messages whose bodies are not valid JSON payment tokens
332    /// (mirrors TS `safeParse` behavior).
333    pub async fn list_incoming_payments(&self) -> Result<Vec<IncomingPayment>, MessageBoxError> {
334        let messages = self.list_messages("payment_inbox", false, None).await?;
335
336        let payments = messages
337            .into_iter()
338            .filter_map(|msg| {
339                serde_json::from_str::<PaymentToken>(&msg.body)
340                    .ok()
341                    .map(|token| IncomingPayment {
342                        token,
343                        sender: msg.sender,
344                        message_id: msg.message_id,
345                    })
346            })
347            .collect();
348
349        Ok(payments)
350    }
351
352    /// Acknowledge a notification message, internalizing any embedded delivery-fee payment.
353    ///
354    /// Matches TS `acknowledgeNotification` exactly:
355    /// 1. Acknowledges the message FIRST (removes from server queue).
356    /// 2. Parses body for a `{ message, payment }` delivery-fee wrapper (NOT a PeerPay token).
357    /// 3. If a delivery-fee payment exists with `wallet payment` outputs, internalizes it.
358    /// 4. Returns true if payment was internalized, false otherwise.
359    pub async fn acknowledge_notification(
360        &self,
361        message: &PeerMessage,
362    ) -> Result<bool, MessageBoxError> {
363        // Step 1: Acknowledge first — matches TS line 1702
364        self.acknowledge_message(vec![message.message_id.clone()], None)
365            .await?;
366
367        // Step 2: Parse body for delivery-fee wrapper { message, payment }
368        let parsed = serde_json::from_str::<crate::http_ops::WrappedMessageBody>(&message.body);
369        let payment_data = parsed.ok().and_then(|w| w.payment);
370
371        // Step 3: Internalize delivery-fee payment if present
372        if let Some(payment) = payment_data {
373            if let (Some(tx_bytes), Some(outputs)) = (&payment.tx, &payment.outputs) {
374                let description = payment
375                    .description
376                    .clone()
377                    .unwrap_or_else(|| "MessageBox recipient payment".to_string());
378
379                // Filter to wallet payment outputs (TS: output.protocol === 'wallet payment')
380                let internalize_outputs: Vec<InternalizeOutput> = outputs
381                    .iter()
382                    .filter_map(|o| {
383                        let sender_pk = o.sender_identity_key.as_deref().and_then(|k| {
384                            bsv::primitives::public_key::PublicKey::from_string(k).ok()
385                        })?;
386                        Some(InternalizeOutput::WalletPayment {
387                            output_index: o.output_index.unwrap_or(0),
388                            payment: Payment {
389                                derivation_prefix: o.derivation_prefix.clone().unwrap_or_default(),
390                                derivation_suffix: o.derivation_suffix.clone().unwrap_or_default(),
391                                sender_identity_key: sender_pk,
392                            },
393                        })
394                    })
395                    .collect();
396
397                if internalize_outputs.is_empty() {
398                    return Ok(false);
399                }
400
401                let args = InternalizeActionArgs {
402                    tx: tx_bytes.clone(),
403                    description,
404                    labels: Some(vec!["notification-payment".to_string()]),
405                    seek_permission: bsv::wallet::types::BooleanDefaultTrue(Some(false)),
406                    outputs: internalize_outputs,
407                };
408
409                match self
410                    .wallet()
411                    .internalize_action(args, self.originator())
412                    .await
413                {
414                    Ok(_) => return Ok(true),
415                    Err(_) => return Ok(false),
416                }
417            }
418        }
419
420        Ok(false)
421    }
422}
423
424// ---------------------------------------------------------------------------
425// Tests
426// ---------------------------------------------------------------------------
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::types::{
432        IncomingPayment, PaymentCustomInstructions, PaymentToken, ServerPeerMessage,
433    };
434    use bsv::primitives::private_key::PrivateKey;
435    use bsv::remittance::types::PeerMessage;
436    use bsv::wallet::error::WalletError;
437    use bsv::wallet::interfaces::*;
438    use bsv::wallet::proto_wallet::ProtoWallet;
439    use std::sync::Arc;
440
441    // Thin Arc wrapper so ProtoWallet satisfies W: Clone bound on MessageBoxClient
442    #[derive(Clone)]
443    struct ArcWallet(Arc<ProtoWallet>);
444
445    impl ArcWallet {
446        fn new() -> Self {
447            let key = PrivateKey::from_random().expect("random key");
448            ArcWallet(Arc::new(ProtoWallet::new(key)))
449        }
450
451        async fn identity_hex(&self) -> String {
452            self.get_public_key(
453                GetPublicKeyArgs {
454                    identity_key: true,
455                    protocol_id: None,
456                    key_id: None,
457                    counterparty: None,
458                    privileged: false,
459                    privileged_reason: None,
460                    for_self: None,
461                    seek_permission: None,
462                },
463                None,
464            )
465            .await
466            .expect("get_public_key")
467            .public_key
468            .to_der_hex()
469        }
470    }
471
472    #[async_trait::async_trait]
473    impl WalletInterface for ArcWallet {
474        async fn create_action(
475            &self,
476            args: CreateActionArgs,
477            orig: Option<&str>,
478        ) -> Result<CreateActionResult, WalletError> {
479            self.0.create_action(args, orig).await
480        }
481        async fn sign_action(
482            &self,
483            args: SignActionArgs,
484            orig: Option<&str>,
485        ) -> Result<SignActionResult, WalletError> {
486            self.0.sign_action(args, orig).await
487        }
488        async fn abort_action(
489            &self,
490            args: AbortActionArgs,
491            orig: Option<&str>,
492        ) -> Result<AbortActionResult, WalletError> {
493            self.0.abort_action(args, orig).await
494        }
495        async fn list_actions(
496            &self,
497            args: ListActionsArgs,
498            orig: Option<&str>,
499        ) -> Result<ListActionsResult, WalletError> {
500            self.0.list_actions(args, orig).await
501        }
502        async fn internalize_action(
503            &self,
504            args: InternalizeActionArgs,
505            orig: Option<&str>,
506        ) -> Result<InternalizeActionResult, WalletError> {
507            self.0.internalize_action(args, orig).await
508        }
509        async fn list_outputs(
510            &self,
511            args: ListOutputsArgs,
512            orig: Option<&str>,
513        ) -> Result<ListOutputsResult, WalletError> {
514            self.0.list_outputs(args, orig).await
515        }
516        async fn relinquish_output(
517            &self,
518            args: RelinquishOutputArgs,
519            orig: Option<&str>,
520        ) -> Result<RelinquishOutputResult, WalletError> {
521            self.0.relinquish_output(args, orig).await
522        }
523        async fn get_public_key(
524            &self,
525            args: GetPublicKeyArgs,
526            orig: Option<&str>,
527        ) -> Result<GetPublicKeyResult, WalletError> {
528            self.0.get_public_key(args, orig).await
529        }
530        async fn reveal_counterparty_key_linkage(
531            &self,
532            args: RevealCounterpartyKeyLinkageArgs,
533            orig: Option<&str>,
534        ) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> {
535            self.0.reveal_counterparty_key_linkage(args, orig).await
536        }
537        async fn reveal_specific_key_linkage(
538            &self,
539            args: RevealSpecificKeyLinkageArgs,
540            orig: Option<&str>,
541        ) -> Result<RevealSpecificKeyLinkageResult, WalletError> {
542            self.0.reveal_specific_key_linkage(args, orig).await
543        }
544        async fn encrypt(
545            &self,
546            args: EncryptArgs,
547            orig: Option<&str>,
548        ) -> Result<EncryptResult, WalletError> {
549            self.0.encrypt(args, orig).await
550        }
551        async fn decrypt(
552            &self,
553            args: DecryptArgs,
554            orig: Option<&str>,
555        ) -> Result<DecryptResult, WalletError> {
556            self.0.decrypt(args, orig).await
557        }
558        async fn create_hmac(
559            &self,
560            args: CreateHmacArgs,
561            orig: Option<&str>,
562        ) -> Result<CreateHmacResult, WalletError> {
563            self.0.create_hmac(args, orig).await
564        }
565        async fn verify_hmac(
566            &self,
567            args: VerifyHmacArgs,
568            orig: Option<&str>,
569        ) -> Result<VerifyHmacResult, WalletError> {
570            self.0.verify_hmac(args, orig).await
571        }
572        async fn create_signature(
573            &self,
574            args: CreateSignatureArgs,
575            orig: Option<&str>,
576        ) -> Result<CreateSignatureResult, WalletError> {
577            self.0.create_signature(args, orig).await
578        }
579        async fn verify_signature(
580            &self,
581            args: VerifySignatureArgs,
582            orig: Option<&str>,
583        ) -> Result<VerifySignatureResult, WalletError> {
584            self.0.verify_signature(args, orig).await
585        }
586        async fn acquire_certificate(
587            &self,
588            args: AcquireCertificateArgs,
589            orig: Option<&str>,
590        ) -> Result<Certificate, WalletError> {
591            self.0.acquire_certificate(args, orig).await
592        }
593        async fn list_certificates(
594            &self,
595            args: ListCertificatesArgs,
596            orig: Option<&str>,
597        ) -> Result<ListCertificatesResult, WalletError> {
598            self.0.list_certificates(args, orig).await
599        }
600        async fn prove_certificate(
601            &self,
602            args: ProveCertificateArgs,
603            orig: Option<&str>,
604        ) -> Result<ProveCertificateResult, WalletError> {
605            self.0.prove_certificate(args, orig).await
606        }
607        async fn relinquish_certificate(
608            &self,
609            args: RelinquishCertificateArgs,
610            orig: Option<&str>,
611        ) -> Result<RelinquishCertificateResult, WalletError> {
612            self.0.relinquish_certificate(args, orig).await
613        }
614        async fn discover_by_identity_key(
615            &self,
616            args: DiscoverByIdentityKeyArgs,
617            orig: Option<&str>,
618        ) -> Result<DiscoverCertificatesResult, WalletError> {
619            self.0.discover_by_identity_key(args, orig).await
620        }
621        async fn discover_by_attributes(
622            &self,
623            args: DiscoverByAttributesArgs,
624            orig: Option<&str>,
625        ) -> Result<DiscoverCertificatesResult, WalletError> {
626            self.0.discover_by_attributes(args, orig).await
627        }
628        async fn is_authenticated(
629            &self,
630            orig: Option<&str>,
631        ) -> Result<AuthenticatedResult, WalletError> {
632            self.0.is_authenticated(orig).await
633        }
634        async fn wait_for_authentication(
635            &self,
636            orig: Option<&str>,
637        ) -> Result<AuthenticatedResult, WalletError> {
638            self.0.wait_for_authentication(orig).await
639        }
640        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> {
641            self.0.get_height(orig).await
642        }
643        async fn get_header_for_height(
644            &self,
645            args: GetHeaderArgs,
646            orig: Option<&str>,
647        ) -> Result<GetHeaderResult, WalletError> {
648            self.0.get_header_for_height(args, orig).await
649        }
650        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> {
651            self.0.get_network(orig).await
652        }
653        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> {
654            self.0.get_version(orig).await
655        }
656    }
657
658    // -----------------------------------------------------------------------
659    // Task 1 tests: create_payment_token / send_payment
660    // -----------------------------------------------------------------------
661
662    /// Verify create_payment_token executes nonce + key derivation path.
663    ///
664    /// ProtoWallet's create_action may fail (no funded wallet), but the function
665    /// should at least get past the nonce and public key derivation step.
666    /// We test the compilation and the nonce/key derivation by checking
667    /// the error comes from create_action (not from nonce or key derivation).
668    #[tokio::test]
669    async fn create_payment_token_uses_create_nonce() {
670        let sender = ArcWallet::new();
671        let recipient = ArcWallet::new();
672        let recipient_pk = recipient.identity_hex().await;
673
674        let client = crate::client::MessageBoxClient::new(
675            "https://example.com".to_string(),
676            sender,
677            None,
678            bsv::services::overlay_tools::Network::Mainnet,
679        );
680
681        // create_payment_token will call create_nonce twice then get_public_key
682        // then create_action (which will fail with ProtoWallet as no network).
683        // We verify the failure is from create_action, not the nonce/key steps.
684        let result = client.create_payment_token(&recipient_pk, 1000).await;
685        // ProtoWallet will return an error at create_action — that's expected
686        // (no funded wallet). The important thing is it compiles and the
687        // nonce/key path executes without panicking.
688        match &result {
689            Err(e) => {
690                let msg = e.to_string();
691                // Should NOT fail at nonce or key derivation steps
692                assert!(
693                    !msg.contains("create_nonce prefix:"),
694                    "should not fail at nonce step"
695                );
696                // It will fail at create_action (wallet error) — acceptable
697                println!("create_payment_token expected error: {msg}");
698            }
699            Ok(_) => {
700                // If ProtoWallet succeeds somehow, that's also fine
701                println!("create_payment_token succeeded unexpectedly — wallet may have funds");
702            }
703        }
704    }
705
706    /// Verify send_payment compiles and delegates to create_payment_token then send_message.
707    /// (compile-check only — network will fail)
708    #[allow(dead_code)]
709    fn send_payment_compile_check(client: &crate::client::MessageBoxClient<ArcWallet>) {
710        // Drop the future without awaiting — compile-check only.
711        drop(client.send_payment("03abc", 1000));
712    }
713
714    // -----------------------------------------------------------------------
715    // Task 2 tests: accept_payment, reject_payment, list_incoming_payments
716    // -----------------------------------------------------------------------
717
718    /// reject_payment with amount < 2000 should only ack (not accept/refund).
719    ///
720    /// We verify this by checking the logic path — since we can't intercept
721    /// internal calls, we test via the threshold boundary value.
722    #[test]
723    fn reject_payment_threshold_below_2000() {
724        // Verify the threshold value in the compiled code.
725        // The implementation uses `amount >= 2000` to decide accept + refund path.
726        // We document the boundary via assert_eq on the threshold constant itself.
727        const THRESHOLD: u64 = 2000;
728        assert_eq!(THRESHOLD, 2000, "threshold must be 2000 sats");
729
730        // Verify refund amount calculation: amount - 1000
731        let amount: u64 = 3000;
732        let refund = amount - 1000;
733        assert_eq!(refund, 2000, "refund is amount minus 1000 sat fee");
734    }
735
736    /// list_incoming_payments silently skips messages with invalid bodies.
737    ///
738    /// Verifies the filter_map+serde_json safeParse behavior at the parsing level.
739    #[test]
740    fn list_incoming_payments_skips_unparseable() {
741        // Simulate what list_incoming_payments does internally:
742        // parse each msg.body as PaymentToken, skip failures
743        let messages = vec![
744            ServerPeerMessage {
745                message_id: "msg1".to_string(),
746                body: "not valid json".to_string(),
747                sender: "03sender1".to_string(),
748                created_at: "2024-01-01T00:00:00Z".to_string(),
749                updated_at: "2024-01-01T00:00:00Z".to_string(),
750                acknowledged: None,
751                authenticated_decrypt: false,
752            },
753            ServerPeerMessage {
754                message_id: "msg2".to_string(),
755                body: r#"{"customInstructions":{"derivationPrefix":"p","derivationSuffix":"s"},"transaction":[1,2,3],"amount":1000}"#.to_string(),
756                sender: "03sender2".to_string(),
757                created_at: "2024-01-01T00:00:00Z".to_string(),
758                updated_at: "2024-01-01T00:00:00Z".to_string(),
759                acknowledged: None,
760                authenticated_decrypt: false,
761            },
762            ServerPeerMessage {
763                message_id: "msg3".to_string(),
764                body: r#"{"foo":"bar"}"#.to_string(),
765                sender: "03sender3".to_string(),
766                created_at: "2024-01-01T00:00:00Z".to_string(),
767                updated_at: "2024-01-01T00:00:00Z".to_string(),
768                acknowledged: None,
769                authenticated_decrypt: false,
770            },
771        ];
772
773        // Apply the same filter_map logic as list_incoming_payments
774        let payments: Vec<IncomingPayment> = messages
775            .into_iter()
776            .filter_map(|msg| {
777                serde_json::from_str::<PaymentToken>(&msg.body)
778                    .ok()
779                    .map(|token| IncomingPayment {
780                        token,
781                        sender: msg.sender,
782                        message_id: msg.message_id,
783                    })
784            })
785            .collect();
786
787        // Only msg2 has a valid PaymentToken body
788        assert_eq!(
789            payments.len(),
790            1,
791            "only valid payment token should be included"
792        );
793        assert_eq!(payments[0].message_id, "msg2");
794        assert_eq!(payments[0].sender, "03sender2");
795        assert_eq!(payments[0].token.amount, 1000);
796    }
797
798    /// accept_payment base64-decodes derivation prefix/suffix before passing to SDK.
799    ///
800    /// The SDK's bytes_as_base64 serde then re-encodes them to the original strings.
801    #[test]
802    fn accept_payment_base64_round_trip() {
803        use base64::{engine::general_purpose::STANDARD, Engine};
804
805        // create_nonce returns base64 strings like these
806        let prefix = "dGVzdC1wcmVmaXg="; // base64("test-prefix")
807        let suffix = "dGVzdC1zdWZmaXg="; // base64("test-suffix")
808
809        // accept_payment decodes to raw bytes
810        let prefix_bytes = STANDARD.decode(prefix).unwrap();
811        let suffix_bytes = STANDARD.decode(suffix).unwrap();
812        assert_eq!(prefix_bytes, b"test-prefix");
813        assert_eq!(suffix_bytes, b"test-suffix");
814
815        // SDK's bytes_as_base64 serde would re-encode back to the original strings
816        let re_encoded = STANDARD.encode(&prefix_bytes);
817        assert_eq!(
818            re_encoded, prefix,
819            "round-trip must produce original base64"
820        );
821    }
822
823    /// Construct IncomingPayment from a PaymentToken, verify all fields preserved.
824    #[test]
825    fn incoming_payment_round_trip() {
826        let token = PaymentToken {
827            custom_instructions: PaymentCustomInstructions {
828                derivation_prefix: "pfx".to_string(),
829                derivation_suffix: "sfx".to_string(),
830                payee: Some("03recipient".to_string()),
831            },
832            transaction: vec![0xde, 0xad, 0xbe, 0xef],
833            amount: 5000,
834            output_index: None,
835        };
836
837        let incoming = IncomingPayment {
838            token: token.clone(),
839            sender: "03sender_key".to_string(),
840            message_id: "abc123".to_string(),
841        };
842
843        assert_eq!(incoming.sender, "03sender_key");
844        assert_eq!(incoming.message_id, "abc123");
845        assert_eq!(incoming.token.amount, 5000);
846        assert_eq!(incoming.token.transaction, vec![0xde, 0xad, 0xbe, 0xef]);
847        assert_eq!(incoming.token.custom_instructions.derivation_prefix, "pfx");
848        assert_eq!(incoming.token.custom_instructions.derivation_suffix, "sfx");
849        assert_eq!(incoming.token.output_index, None);
850    }
851
852    // -----------------------------------------------------------------------
853    // Task 3 tests: send_live_payment / listen_for_live_payments
854    // -----------------------------------------------------------------------
855
856    /// Verify send_live_payment compiles — delegates to create_payment_token then send_live_message.
857    #[allow(dead_code)]
858    fn send_live_payment_compile_check(client: &crate::client::MessageBoxClient<ArcWallet>) {
859        let _fut = client.send_live_payment("03abc", 1000);
860    }
861
862    /// The listen_for_live_payments callback wrapper correctly parses a valid PaymentToken.
863    ///
864    /// Tests the parsing logic directly without a live WS connection.
865    #[test]
866    fn listen_for_live_payments_callback_parses_token() {
867        use std::sync::Mutex as StdMutex;
868
869        let received = Arc::new(StdMutex::new(Vec::<IncomingPayment>::new()));
870        let received_clone = received.clone();
871
872        // Construct a PeerMessage whose body is a valid PaymentToken JSON
873        let msg = PeerMessage {
874            message_id: "msg-live-1".to_string(),
875            sender: "03sender".to_string(),
876            recipient: "03recipient".to_string(),
877            message_box: "payment_inbox".to_string(),
878            body: r#"{"customInstructions":{"derivationPrefix":"p","derivationSuffix":"s"},"transaction":[1,2],"amount":500}"#.to_string(),
879        };
880
881        // Apply the same parsing logic as listen_for_live_payments wrapper
882        if let Ok(token) = serde_json::from_str::<PaymentToken>(&msg.body) {
883            let incoming = IncomingPayment {
884                token,
885                sender: msg.sender.clone(),
886                message_id: msg.message_id.clone(),
887            };
888            received_clone.lock().unwrap().push(incoming);
889        }
890
891        let payments = received.lock().unwrap();
892        assert_eq!(payments.len(), 1, "one valid payment should be parsed");
893        assert_eq!(payments[0].token.amount, 500);
894        assert_eq!(payments[0].sender, "03sender");
895        assert_eq!(payments[0].message_id, "msg-live-1");
896    }
897
898    /// The listen_for_live_payments callback silently skips non-payment messages.
899    ///
900    /// Verifies safeParse behavior — invalid body produces no IncomingPayment.
901    #[test]
902    fn listen_for_live_payments_callback_skips_non_payment() {
903        use std::sync::Mutex as StdMutex;
904
905        let received = Arc::new(StdMutex::new(Vec::<IncomingPayment>::new()));
906        let received_clone = received.clone();
907
908        // Body is not a valid PaymentToken
909        let msg = PeerMessage {
910            message_id: "msg-bad-1".to_string(),
911            sender: "03sender".to_string(),
912            recipient: "03recipient".to_string(),
913            message_box: "payment_inbox".to_string(),
914            body: r#"{"not":"a payment token"}"#.to_string(),
915        };
916
917        // Apply the same parsing logic as listen_for_live_payments wrapper
918        if let Ok(token) = serde_json::from_str::<PaymentToken>(&msg.body) {
919            let incoming = IncomingPayment {
920                token,
921                sender: msg.sender.clone(),
922                message_id: msg.message_id.clone(),
923            };
924            received_clone.lock().unwrap().push(incoming);
925        }
926
927        let payments = received.lock().unwrap();
928        assert_eq!(
929            payments.len(),
930            0,
931            "non-payment message must be silently skipped"
932        );
933    }
934}